From b184a0909c60f05e0a00aa713faf70baaadc894d Mon Sep 17 00:00:00 2001 From: satyamsingh_8888 Date: Mon, 14 Sep 2026 18:22:00 +0530 Subject: [PATCH 01/32] Deploy cbsnexora UI to dev2 node (environment==dev2 constraint) --- .drone.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.drone.yml b/.drone.yml index 565892284..922e7cb3c 100644 --- a/.drone.yml +++ b/.drone.yml @@ -104,7 +104,7 @@ steps: echo "Repository : $$REPO" echo "Service : $$SERVICE_NAME" echo "Image : $$IMAGE" - echo "Placement : node.labels.environment == dev" + echo "Placement : node.labels.environment == dev2" echo "==========================================" @@ -151,7 +151,7 @@ steps: CONSTRAINT_EXISTS=$$(docker service inspect "$$SERVICE_NAME" \ --format '{{range .Spec.TaskTemplate.Placement.Constraints}}{{println .}}{{end}}' \ - | grep -Fx 'node.labels.environment == dev' || true) + | grep -Fx 'node.labels.environment == dev2' || true) # ------------------------------------------------------ @@ -161,10 +161,10 @@ steps: if [ -z "$$CONSTRAINT_EXISTS" ]; then echo "Constraint missing." - echo "Adding: node.labels.environment == dev" + echo "Adding: node.labels.environment == dev2" docker service update \ - --constraint-add 'node.labels.environment == dev' \ + --constraint-add 'node.labels.environment == dev2' \ "$$SERVICE_NAME" else @@ -220,7 +220,7 @@ steps: --name "$$SERVICE_NAME" \ --network edge-network \ --replicas 1 \ - --constraint 'node.labels.environment == dev' \ + --constraint 'node.labels.environment == dev2' \ --with-registry-auth \ --env-file /platform-env/global.env \ --env-file /platform-env/dev.env \ From 6eaf9b959f0596c0149b62a3e8915ae3a31244b3 Mon Sep 17 00:00:00 2001 From: satyamsingh_8888 Date: Mon, 14 Sep 2026 18:41:34 +0530 Subject: [PATCH 02/32] Add Harbor login to deploy step so cross-node pull auth propagates to dev2 --- .drone.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.drone.yml b/.drone.yml index 922e7cb3c..fd126c9de 100644 --- a/.drone.yml +++ b/.drone.yml @@ -132,6 +132,20 @@ steps: echo "Image verified : $$IMAGE" + # ======================================================== + # HARBOR LOGIN + # + # Zaroori hai taaki --with-registry-auth worker node (dev2) ko + # valid Harbor credentials propagate kare — warna cross-node pull + # "No such image" / "no basic auth credentials" se fail hota hai. + # ======================================================== + + echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \ + docker login harbor.lincpay:8082 \ + -u 'robot$lincpay+drone' \ + --password-stdin + + # ======================================================== # EXISTING SERVICE # ======================================================== From d132bbe716da0454f5cfac41f1437fb6b280dbe5 Mon Sep 17 00:00:00 2001 From: anshul Date: Wed, 16 Sep 2026 15:17:51 +0530 Subject: [PATCH 03/32] feat: add CKYC management tab, checker inbox views, and maker-checker diff utilities --- .../ckyc-tab/ckyc-tab.component.html | 178 +++++++++ .../ckyc-tab/ckyc-tab.component.scss | 29 ++ .../ckyc-tab/ckyc-tab.component.ts | 352 ++++++++++++------ src/app/clients/clients.service.ts | 39 +- .../product-edit-diff-dialog.component.ts | 64 +--- src/app/tasks/command-diff.util.ts | 332 +++++++++++++++++ src/app/tasks/maker-checker-entity-fetch.ts | 205 ++++++++++ .../view-checker-inbox.component.html | 63 +++- .../view-checker-inbox.component.scss | 91 +++++ .../view-checker-inbox.component.ts | 318 ++++++++++++++-- 10 files changed, 1470 insertions(+), 201 deletions(-) create mode 100644 src/app/tasks/command-diff.util.ts create mode 100644 src/app/tasks/maker-checker-entity-fetch.ts 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 6c0cb5670..2e893ca41 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 @@ -187,6 +187,124 @@ }
+ + + +
+ badge + {{ 'labels.inputs.ID Proof Document' | translate }} +
+ + @if (existing.ID_PROOF.present) { +
+
+ +
+ Already available + from {{ existing.ID_PROOF.source }} + @if (existing.ID_PROOF.previewUnavailable) { + stored file unavailable + } +
+
+ @if (existing.ID_PROOF.previewUrl && isImage('ID_PROOF')) { + ID proof + } + @if (existing.ID_PROOF.previewUrl) { + + } +
+ + {{ existing.ID_PROOF.name }} + @if (existing.ID_PROOF.documentKey) { + {{ existing.ID_PROOF.documentKey }} + } + @if (existing.ID_PROOF.fileCount && existing.ID_PROOF.fileCount > 0) { + + {{ existing.ID_PROOF.fileCount }} + {{ existing.ID_PROOF.fileCount === 1 ? 'file' : 'files' }} + + } +
+ +
+ } + + @if (!existing.ID_PROOF.present || existing.ID_PROOF.expanded) { +

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

+
+ + + + + +
+ + @if (uploadErrors['ID_PROOF']) { +
+ error_outline + {{ uploadErrors['ID_PROOF'] }} +
+ } + @if (uploadResponses['ID_PROOF']) { +
+

+ {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['ID_PROOF'].status }} +

+

+ {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['ID_PROOF'].requestId }} +

+

+ {{ 'labels.inputs.Acknowledgement Number' | translate }}: + {{ uploadResponses['ID_PROOF'].acknowledgementNumber }} +

+

+ {{ 'labels.inputs.Processing Status' | translate }}: + {{ uploadResponses['ID_PROOF'].processingStatus }} +

+
+ } + } +
+
+ @@ -202,8 +320,25 @@
Already available from {{ existing.ADDRESS_PROOF.source }} + @if (existing.ADDRESS_PROOF.previewUnavailable) { + stored file unavailable + }
+ @if (existing.ADDRESS_PROOF.previewUrl && isImage('ADDRESS_PROOF')) { + Address proof + } + @if (existing.ADDRESS_PROOF.previewUrl) { + + }
{{ existing.ADDRESS_PROOF.name }} @@ -308,8 +443,25 @@
Already available from {{ existing.PHOTO_PROOF.source }} + @if (existing.PHOTO_PROOF.previewUnavailable) { + stored file unavailable + }
+ @if (existing.PHOTO_PROOF.previewUrl && isImage('PHOTO_PROOF')) { + Photo proof + } + @if (existing.PHOTO_PROOF.previewUrl) { + + }
{{ existing.PHOTO_PROOF.name }} @@ -413,10 +565,22 @@
Already available from {{ existing.CLIENT_IMAGE.source }} + @if (existing.CLIENT_IMAGE.previewUnavailable) { + stored file unavailable + }
@if (existing.CLIENT_IMAGE.previewUrl) { Client photo + } + }
{{ 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 66d527c48..51f57dc01 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 @@ -638,6 +638,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 adcd3d486..c5e72c1f0 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts @@ -9,7 +9,7 @@ /** Angular Imports */ import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Subject, takeUntil, catchError, of, forkJoin } from 'rxjs'; +import { Subject, takeUntil, catchError, of } from 'rxjs'; /** Custom Services */ import { ClientsService } from '../../clients.service'; @@ -28,8 +28,60 @@ interface ExistingArtefact { documentKey?: string; fileCount?: number; expanded?: boolean; + /** 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; } +/** + * Multipart `name` / `description` sent for each CKYC artefact type. + * + * The platform has no CKYC-specific document API; these are stored through the generic + * DocumentApiResource, whose contract carries no document-type field. The artefact type therefore + * travels in `name`, which is the same mechanism the client signature already uses - so + * `CLIENT_SIGNATURE` deliberately reuses the existing `clientSignature` value that + * loadExistingArtefacts() (and uploadClientSignatureImage()) match on. Changing that string would + * orphan every previously uploaded signature. + */ +export type CkycDocumentType = 'ID_PROOF' | 'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE'; + +const CKYC_DOCUMENT_META: Record = { + ID_PROOF: { name: 'ckycIdProof', description: 'CKYC ID proof' }, + ADDRESS_PROOF: { name: 'ckycAddressProof', description: 'CKYC address proof' }, + PHOTO_PROOF: { name: 'ckycPhotoProof', description: 'CKYC photo proof' }, + CLIENT_IMAGE: { name: 'ckycClientImage', description: 'CKYC client photograph' }, + CLIENT_SIGNATURE: { name: 'clientSignature', description: 'Client signature' } +}; + +const CKYC_DOCUMENT_TYPES = Object.keys(CKYC_DOCUMENT_META) as CkycDocumentType[]; + +/** + * 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. + * `accept=` on the input is only a file-picker hint and is trivially bypassed, so the real check + * lives in onFileSelect(). + */ +const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; +const IMAGE_EXTENSIONS = [ + 'png', + 'jpeg', + 'jpg' +]; +const DOCUMENT_EXTENSIONS = [ + ...IMAGE_EXTENSIONS, + 'pdf' +]; + +/** Types whose card renders an inline preview, so a PDF would not display. */ +const IMAGE_ONLY_TYPES: CkycDocumentType[] = [ + 'CLIENT_IMAGE', + 'CLIENT_SIGNATURE' +]; + @Component({ selector: 'mifosx-ckyc-tab', templateUrl: './ckyc-tab.component.html', @@ -79,8 +131,9 @@ export class CkycTabComponent implements OnInit, OnDestroy { return this.panVerified && this.aadhaarVerified; } - /** Pre-existing artefacts detected from client data */ - existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = { + /** CKYC artefacts already uploaded for this client, keyed by artefact type. */ + existing: Record = { + ID_PROOF: { present: false }, ADDRESS_PROOF: { present: false }, PHOTO_PROOF: { present: false }, CLIENT_IMAGE: { present: false }, @@ -88,6 +141,7 @@ export class CkycTabComponent implements OnInit, OnDestroy { }; /** File references */ + idProofFile: File | null = null; addressProofFile: File | null = null; photoProofFile: File | null = null; clientImageFile: File | null = null; @@ -95,6 +149,7 @@ export class CkycTabComponent implements OnInit, OnDestroy { /** Per-document upload state */ uploading: { [key: string]: boolean } = { + ID_PROOF: false, ADDRESS_PROOF: false, PHOTO_PROOF: false, CLIENT_IMAGE: false, @@ -106,6 +161,7 @@ export class CkycTabComponent implements OnInit, OnDestroy { /** Per-document error messages */ uploadErrors: { [key: string]: string | null } = { + ID_PROOF: null, ADDRESS_PROOF: null, PHOTO_PROOF: null, CLIENT_IMAGE: null, @@ -207,98 +263,110 @@ export class CkycTabComponent implements OnInit, OnDestroy { } /** - * Fetch photo (profile image), signature (client document), - * ID proof and address proof (client identifiers) so already-uploaded - * artefacts are shown here instead of forcing re-upload. + * Loads every CKYC artefact from the client's documents (`m_document`), which is the single + * source of truth for this screen: each card matches the `name` its own upload writes, so what + * the checker sees is exactly what CKYC stored. + * + * Deliberately does NOT read the profile image or client identifiers any more. Those belong to + * client registration and the Identities section; reading them here showed artefacts this screen + * had never uploaded, and hid the ones it had. Those other screens are untouched. + * + * A client can accumulate several rows for one artefact (older uploads pre-date replacement, and + * the signature is also written by client registration), so the newest row - highest `id` - wins. */ private loadExistingArtefacts(): void { this.checkingExisting = true; - forkJoin({ - photo: this.clientsService.getClientProfileImage(this.clientId).pipe(catchError(() => of(null))), - documents: this.clientsService.getClientDocuments(this.clientId).pipe(catchError(() => of([]))), - identifiers: this.clientsService.getClientIdentifiers(this.clientId).pipe(catchError(() => of([]))), - proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ idProofs: [], addressProofs: [] }))) - }) - .pipe(takeUntil(this.destroy$)) - .subscribe(({ photo, documents, identifiers, proofTypes }) => { + this.clientsService + .getClientDocuments(this.clientId) + .pipe( + takeUntil(this.destroy$), + catchError(() => of([])) + ) + .subscribe((documents: any) => { this.checkingExisting = false; - // 1. Photo / image of client — from m_client.image_id - if (photo && typeof photo === 'string' && photo.length > 0) { - this.existing.CLIENT_IMAGE = { - present: true, - source: 'Client registration', - previewUrl: photo, - name: 'Profile photo' - }; - } + const list: any[] = Array.isArray(documents) ? documents : (documents?.pageItems ?? []); - // 2. Signature — client document with name='clientSignature'. Multiple rows can exist - // for clients who re-cropped/replaced their signature before uploads became single-slot, - // so pick the highest id (latest) rather than the first unordered match. - const signatureDoc = (documents as any[]) - ?.filter((d: any) => d?.name === 'clientSignature') - ?.sort((a: any, b: any) => b.id - a.id)?.[0]; - if (signatureDoc) { - this.existing.CLIENT_SIGNATURE = { - present: true, - source: 'Client registration', - name: signatureDoc.fileName || 'Client signature', - documentId: signatureDoc.id - }; - // Fetch blob for preview - this.clientsService - .getClientSignatureImage(this.clientId, signatureDoc.id) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (blob: any) => { - if (blob) { - this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); - } - }, - error: () => undefined - }); - } + for (const type of CKYC_DOCUMENT_TYPES) { + const expectedName = CKYC_DOCUMENT_META[type].name; - // 3 & 4. ID proof + Address proof — client identifiers classified via proof-types - const idProofIds = new Set((proofTypes?.idProofs || []).map((p: any) => String(p.id))); - const addressProofIds = new Set((proofTypes?.addressProofs || []).map((p: any) => String(p.id))); + const doc = list + .filter((d: any) => d?.name === expectedName) + .sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0))[0]; - const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || []; - - for (const identity of idList) { - const docTypeId = String(identity?.documentType?.id ?? ''); - const isActive = - !identity?.status || - identity?.status?.code === 'clientIdentifierStatusType.active' || - identity?.status === 'clientIdentifierStatusType.active' || - identity?.status === 'Active'; - if (!isActive) continue; - - const proofName = identity?.documentType?.name || 'Identifier'; - // Address proof documentKey is auto-generated as " - " - // in identities-tab; hide it when it's just that synthetic key. - const rawKey = identity?.documentKey || ''; - const isSyntheticKey = rawKey.startsWith(`${proofName} - `); - const common = { - present: true, - source: 'Identities section', - name: proofName, - documentKey: isSyntheticKey ? '' : rawKey, - documentId: identity?.id, - fileCount: (identity?.documents || []).length - }; - - if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) { - this.existing.PHOTO_PROOF = common; - } else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) { - this.existing.ADDRESS_PROOF = common; + if (!doc) { + // No upload yet for this artefact - leave the card in its empty state. + this.existing[type] = { present: false }; + continue; } + + this.existing[type] = { + present: true, + source: 'CKYC upload', + name: doc.fileName || expectedName, + documentId: doc.id, + mimeType: doc.type + }; + + this.loadPreview(type, doc.id); } }); } + /** + * Fetches the stored file so the card can show a thumbnail. A failure here is reported on the + * card rather than swallowed: the usual cause is a `m_document` row whose file is no longer in + * the content store, and silently showing "Already available" with no image hides that. + */ + private loadPreview(type: CkycDocumentType, documentId: string | number): void { + this.clientsService + // silent: a missing stored file returns 500, and the card below already reports that. Without + // this the global error interceptor pops an "Internal Server Error" modal on every page load + // and after every upload, for a failure the user cannot act on. + .downloadClientDocument(this.clientId, String(documentId), true) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (blob: any) => { + if (blob && blob.size > 0) { + this.existing[type].previewUrl = URL.createObjectURL(blob); + } else { + // A 200 with an empty body still means there is nothing to show. Without this the card + // would sit there claiming "Already available" with no thumbnail and no explanation. + this.existing[type].previewUnavailable = true; + } + }, + error: () => { + this.existing[type].previewUnavailable = true; + } + }); + } + + /** + * 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(documentType: CkycDocumentType): boolean { + const entry = this.existing[documentType]; + 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(documentType: CkycDocumentType): void { + const url = this.existing[documentType]?.previewUrl; + if (url) { + window.open(url, '_blank', 'noopener'); + } + } + toggleReplace(documentType: string): void { const entry = (this.existing as any)[documentType]; if (entry) entry.expanded = !entry.expanded; @@ -309,11 +377,38 @@ export class CkycTabComponent implements OnInit, OnDestroy { this.selectedAccountType = this.selectedAccount?.accountType || ''; } - onFileSelect(event: any, documentType: string): void { - const file = event.target?.files?.[0]; - if (!file) return; + /** Extensions a given card can both store and render. */ + private allowedExtensions(documentType: string): string[] { + return IMAGE_ONLY_TYPES.includes(documentType as CkycDocumentType) ? 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, documentType: string): string | null { + const allowed = this.allowedExtensions(documentType); + 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; + } + + private setFile(documentType: string, file: File | null): void { switch (documentType) { + case 'ID_PROOF': + this.idProofFile = file; + break; case 'ADDRESS_PROOF': this.addressProofFile = file; break; @@ -327,11 +422,28 @@ export class CkycTabComponent implements OnInit, OnDestroy { this.clientSignatureFile = file; break; } + } + + onFileSelect(event: any, documentType: string): void { + const file = event.target?.files?.[0]; + if (!file) return; + + const error = this.validationError(file, documentType); + if (error) { + this.setFile(documentType, null); + this.uploadErrors[documentType] = error; + event.target.value = ''; + return; + } + + this.setFile(documentType, file); this.uploadErrors[documentType] = null; } getFile(documentType: string): File | null { switch (documentType) { + case 'ID_PROOF': + return this.idProofFile; case 'ADDRESS_PROOF': return this.addressProofFile; case 'PHOTO_PROOF': @@ -345,38 +457,64 @@ export class CkycTabComponent implements OnInit, OnDestroy { } } - uploadDocument(documentType: string): void { + uploadDocument(documentType: CkycDocumentType): void { const file = this.getFile(documentType); if (!file) return; this.uploading[documentType] = true; this.uploadErrors[documentType] = null; + // DocumentApiResource consumes multipart/form-data with body parts `file`, `name` and + // `description` (file + description mandatory). There is no document-type field in that + // contract, so the artefact type is carried in `name` - which is also the key + // loadExistingArtefacts() matches on when reading the document back. + const meta = CKYC_DOCUMENT_META[documentType]; + const formData = new FormData(); formData.append('file', file); - formData.append('documentType', documentType); + formData.append('name', meta.name); + formData.append('description', meta.description); - this.clientsService - .uploadCkycDocument(this.clientId, formData) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: any) => { - this.uploading[documentType] = false; - this.uploadResponses[documentType] = response; - this.alertService.alert({ - type: 'success', - message: response?.message || 'Document uploaded successfully.' - }); - }, - error: (err: any) => { - this.uploading[documentType] = false; - this.uploadErrors[documentType] = - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - err?.message || - 'Failed to upload document.'; - } - }); + // Replace in place when this artefact already has a usable document, so a re-upload updates the + // existing row (same document id, same content-store location) instead of stacking up another + // copy. Falls back to create for the first upload of an artefact. + // + // Deliberately create instead of replace when the stored file is missing: the backend's update + // path writes the new content relative to the row's existing `location` and never calls + // setLocation(), so replacing a row whose file is already gone writes the upload to a fresh + // folder while the row keeps pointing at the missing one - the artefact would stay broken and + // the user would get a success message. Creating a new row repairs the card instead. + const entry = this.existing[documentType]; + const replacing = !!entry?.documentId && !entry?.previewUnavailable; + const existingId = entry?.documentId; + + const request$ = replacing + ? this.clientsService.replaceClientDocument(this.clientId, String(existingId), formData) + : this.clientsService.uploadCkycDocument(this.clientId, formData); + + request$.pipe(takeUntil(this.destroy$)).subscribe({ + next: (response: any) => { + this.uploading[documentType] = false; + this.uploadResponses[documentType] = response; + this.setFile(documentType, null); + this.alertService.alert({ + type: 'success', + message: + response?.message || (replacing ? 'Document replaced successfully.' : 'Document uploaded successfully.') + }); + // Re-read from m_document so the card reflects what was actually stored, rather than + // assuming the write succeeded exactly as sent. + this.loadExistingArtefacts(); + }, + error: (err: any) => { + this.uploading[documentType] = false; + this.uploadErrors[documentType] = + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'Failed to upload document.'; + } + }); } ngOnDestroy(): void { 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/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts index 43e1e7dea..07c51f9f8 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts @@ -18,6 +18,7 @@ import { import { MatButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { humanizeKey, sameValue, toIsoDate } from 'app/tasks/command-diff.util'; interface DiffRow { label: string; @@ -106,9 +107,9 @@ export class ProductEditDiffDialogComponent implements OnInit { this.rows = Object.keys(proposed) .filter((key) => !ProductEditDiffDialogComponent.IGNORED_KEYS.has(key)) .map((key) => ({ key, originalValue: this.originalValueFor(key, original) })) - .filter(({ key, originalValue }) => !this.sameValue(originalValue, proposed[key])) + .filter(({ key, originalValue }) => !sameValue(originalValue, proposed[key])) .map(({ key, originalValue }) => ({ - label: this.humanizeKey(key), + label: humanizeKey(key), original: this.displayValue(originalValue, key, original), proposed: this.displayValue(proposed[key], key, original) })) @@ -173,58 +174,11 @@ export class ProductEditDiffDialogComponent implements OnInit { * carries just the id, so a raw comparison reports every such field as changed. Comparing on the id * (and coercing numeric strings) keeps the list to fields whose value genuinely moved. */ - private sameValue(a: any, b: any): boolean { - return JSON.stringify(this.normalize(a)) === JSON.stringify(this.normalize(b)); - } - - private normalize(value: any): any { - if (value === null || value === undefined || value === '') { - return null; - } - // Dates come back from the product as [yyyy, m, d] but are submitted as "yyyy-MM-dd"; without - // folding both to one form every date field reads as changed on every edit. - const asDate = this.toIsoDate(value); - if (asDate !== null) { - return asDate; - } - if (Array.isArray(value)) { - // An empty array and an absent value mean the same thing here. - return value.length === 0 ? null : value.map((item) => this.normalize(item)); - } - if (typeof value === 'object') { - if (value.id !== undefined) { - return this.normalize(value.id); - } - return Object.keys(value).length === 0 ? null : JSON.stringify(value); - } - if (typeof value === 'boolean') { - return value; - } - const asNumber = Number(value); - return Number.isNaN(asNumber) ? value : asNumber; - } - - /** Recognises both date shapes the API uses and returns "yyyy-MM-dd", or null if not a date. */ - private toIsoDate(value: any): string | null { - if (Array.isArray(value) && value.length === 3 && value.every((part) => typeof part === 'number')) { - const [ - year, - month, - day - ] = value; - return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - } - if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) { - return value; - } - return null; - } - private displayValue(value: any, fieldKey: string, template: any): string { if (value === null || value === undefined || value === '') { return '—'; } - const asDate = this.toIsoDate(value); + const asDate = toIsoDate(value); if (asDate !== null) { return asDate; } @@ -248,14 +202,4 @@ export class ProductEditDiffDialogComponent implements OnInit { // A bare id that names an option -- show the option's label, not the number. return this.resolveOptionLabel(fieldKey, value, template) ?? String(value); } - - /** camelCase / PascalCase field name to a spaced, capitalised label. */ - private humanizeKey(key: string): string { - const spaced = key - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') - .replace(/[_-]+/g, ' ') - .trim(); - return spaced.charAt(0).toUpperCase() + spaced.slice(1); - } } diff --git a/src/app/tasks/command-diff.util.ts b/src/app/tasks/command-diff.util.ts new file mode 100644 index 000000000..f7a19f941 --- /dev/null +++ b/src/app/tasks/command-diff.util.ts @@ -0,0 +1,332 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Shared comparison helpers for maker-checker review screens. + * + * Extracted verbatim from ProductEditDiffDialogComponent, which had already solved the awkward part: + * a naive comparison of a submitted command against the stored entity reports almost every field as + * changed, because the two sides encode the same value differently. The normalisation below is what + * keeps a diff down to the fields that genuinely moved: + * + * - dates arrive as `[yyyy, m, d]` from the API but as `"yyyy-MM-dd"` in the payload + * - option/enum fields come back as `{ id, value }` but are submitted as a bare id + * - `null`, `undefined`, `''`, `[]` and `{}` all mean "not set" + * - numeric strings and numbers are the same value + * + * Product-specific concerns (GL account mappings, `Options` lookups, currency aliases) stay in + * the product dialog - they have no meaning for a role or a client. + */ + +/** + * Keys that are never a setting a checker reviews, in two groups. + * + * The first is request plumbing - how the command was encoded, plus the identifiers naming the record + * it applies to, both of which the screen already shows. + * + * The second is values derived from another field rather than entered. An age is computed from + * `dateOfBirth` at the moment it is read, so a command submitted last month always disagrees with the + * entity read today - a difference caused by the calendar, not by the maker. The field the maker can + * actually change (`dateOfBirth`) is still compared, so a real edit is never hidden by this. + */ +export const COMMAND_NOISE_KEYS: ReadonlySet = new Set([ + // request plumbing + 'locale', + 'dateFormat', + 'monthDayFormat', + 'id', + 'productId', + 'savingsProductId', + // derived from dateOfBirth + 'ageYears', + 'ageMonths', + 'ageDays', + 'isMinor', + 'isSeniorCitizen' +]); + +const MONTH_NAMES = [ + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december' +]; + +/** + * Recognises every date shape these two sides use and returns "yyyy-MM-dd", or null if not a date. + * + * Three encodings are in play for the same day: + * - `[yyyy, m, d]` what the read APIs return + * - `"yyyy-MM-dd"` what some forms submit + * - `"29 June 2026"` what Fineract commands carry, per the payload's own `dateFormat` + * + * Missing the third one makes every dated field read as changed on every request, which is exactly + * the noise this whole comparison exists to remove. + */ +export function toIsoDate(value: any): string | null { + if (Array.isArray(value) && value.length === 3 && value.every((part) => typeof part === 'number')) { + const [ + year, + month, + day + ] = value; + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + } + if (typeof value !== 'string') { + return null; + } + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return value; + } + const spelled = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(value.trim()); + if (spelled) { + const day = Number(spelled[1]); + const monthName = spelled[2].toLowerCase(); + const monthIndex = MONTH_NAMES.findIndex((name) => name === monthName || name.slice(0, 3) === monthName); + if (monthIndex !== -1) { + return `${spelled[3]}-${String(monthIndex + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + } + } + return null; +} + +/** + * Folds a value into the one form both sides of a comparison can be measured in. See the class + * comment above for why each case exists. + */ +export function normalizeValue(value: any): any { + if (value === null || value === undefined || value === '') { + return null; + } + const asDate = toIsoDate(value); + if (asDate !== null) { + return asDate; + } + if (Array.isArray(value)) { + return value.length === 0 ? null : value.map((item) => normalizeValue(item)); + } + if (typeof value === 'object') { + if (value.id !== undefined) { + return normalizeValue(value.id); + } + return Object.keys(value).length === 0 ? null : JSON.stringify(value); + } + if (typeof value === 'boolean') { + return value; + } + const asNumber = Number(value); + return Number.isNaN(asNumber) ? value : asNumber; +} + +/** + * True when two values mean the same thing. + * + * The structural comparison handles most cases, but the two sides of a maker-checker review come + * from different APIs that expand the same option differently: `/audits/{id}` returns an enum as its + * label ("Daily") while the entity's own read API returns `{ id, code, value }`. Those normalise to + * different things yet are the same setting, so a second pass asks the question that actually + * matters to a checker - would these two render identically on screen? If they would, there is + * nothing to report. + */ +export function sameValue(a: any, b: any): boolean { + if (JSON.stringify(normalizeValue(a)) === JSON.stringify(normalizeValue(b))) { + return true; + } + return displayScalar(a) === displayScalar(b); +} + +/** camelCase / PascalCase / snake_case field name to a spaced, capitalised label. */ +export function humanizeKey(key: string): string { + const spaced = String(key ?? '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** + * Renders one value for display, without any entity-specific option lookup. Arrays and objects are + * summarised rather than dumped - a review row states *that* a collection changed; the detail belongs + * in the entity's own screen. + */ +export function displayScalar(value: any): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + const asDate = toIsoDate(value); + if (asDate !== null) { + return asDate; + } + if (Array.isArray(value)) { + return value.length === 0 ? '—' : `${value.length} item${value.length === 1 ? '' : 's'}`; + } + if (typeof value === 'object') { + if (Object.keys(value).length === 0) { + return '—'; + } + if (value.name && value.glCode) { + return `${value.name} (${value.glCode})`; + } + return value.value ?? value.name ?? value.code ?? (value.id !== undefined ? String(value.id) : '—'); + } + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + return String(value); +} + +/** + * Sections a CREATE payload is bucketed into, so a checker reads a submitted product the way it was + * entered rather than as one flat run of fields. Matching is by key name, tried in order, so the + * grouping works for any entity - a role or a GL account simply lands everything in "Details". + */ +const CREATE_SECTIONS: { title: string; matches: RegExp }[] = [ + { title: 'Accounting', matches: /account(ing)?|glaccount|writeOff|receivable|payable|suspense|escheat|fund/i }, + { title: 'Charges & Fees', matches: /charge|fee|penalt|tax/i }, + { + title: 'Interest & Terms', + matches: /interest|term|period|lockin|compound|annualis|maturity|deposit(Amount|Min|Max)/i + }, + { title: 'Balances & Limits', matches: /balance|overdraft|limit|amount|minimum|maximum|multiplesOf|decimal/i }, + { title: 'Facilities & Features', matches: /facility|facilities|nominee|nomination|alert|banking|card|withdrawal/i } +]; + +const CREATE_DEFAULT_SECTION = 'General'; + +/** One named group of payload keys for a CREATE review. */ +export interface CreateSection { + title: string; + keys: string[]; +} + +/** + * Buckets a CREATE payload's keys into readable sections. Keys the sections do not recognise fall + * into "Details", which is also where everything lands for entities that have no product-like shape. + * Empty sections are dropped, and section order is stable so the same entity always reads the same. + */ +export function groupCreateFields(payload: Record, ignoredKeys?: ReadonlySet): CreateSection[] { + const ignored = ignoredKeys ?? COMMAND_NOISE_KEYS; + const buckets = new Map(); + buckets.set(CREATE_DEFAULT_SECTION, []); + CREATE_SECTIONS.forEach((section) => buckets.set(section.title, [])); + + Object.keys(payload ?? {}) + .filter((key) => !ignored.has(key)) + .forEach((key) => { + const section = CREATE_SECTIONS.find((candidate) => candidate.matches.test(key)); + buckets.get(section ? section.title : CREATE_DEFAULT_SECTION).push(key); + }); + + const ordered = [ + CREATE_DEFAULT_SECTION, + ...CREATE_SECTIONS.map((section) => section.title) + ]; + + return ordered + .map((title) => ({ title, keys: buckets.get(title) ?? [] })) + .filter((section) => section.keys.length > 0); +} + +/** + * Reads the current value for a payload key, allowing for the two naming conventions the read APIs + * use that the command payload does not: + * + * - lifecycle dates live under `timeline` (`timeline.submittedOnDate`) + * - a payload `Id` is usually the expanded `` object on the entity + * + * Returns `undefined` when the key cannot be located at all, which the caller reports honestly + * rather than claiming the field was newly added. + */ +export function currentValueFor(current: any, key: string): any { + if (current && key in current) { + return current[key]; + } + if (current?.timeline && key in current.timeline) { + return current.timeline[key]; + } + if (key.endsWith('Id')) { + const base = key.slice(0, -2); + if (current && base in current) { + return current[base]; + } + } + return undefined; +} + +/** + * How a single field moved between the stored entity and the submitted command. + * + * `SUBMITTED` is not a change: it marks a field the command carries but which could not be located + * on the entity, so no before-value exists to compare against. Calling those ADDED would tell the + * checker something untrue. + */ +export type ChangeKind = 'ADDED' | 'REMOVED' | 'MODIFIED' | 'SUBMITTED'; + +export interface CommandChange { + label: string; + kind: ChangeKind; + oldValue: string; + newValue: string; +} + +/** + * Compares a submitted command payload against the entity as it stands today, returning only the + * fields that actually moved. + * + * Only keys present in the payload are considered: a command says what the maker submitted, so a + * field absent from it was not part of the request and must not be reported as removed. + */ +export function diffCommandAgainstEntity( + submitted: Record, + current: Record, + options?: { ignoredKeys?: ReadonlySet; currentValueFor?: (key: string) => any } +): CommandChange[] { + const ignored = options?.ignoredKeys ?? COMMAND_NOISE_KEYS; + const readCurrent = options?.currentValueFor ?? ((key: string) => currentValueFor(current, key)); + const knownOnEntity = (key: string) => + !!current && (key in current || (!!current.timeline && key in current.timeline) || `${key}Id` in current); + + const rank: Record = { MODIFIED: 0, ADDED: 1, REMOVED: 2, SUBMITTED: 3 }; + + return ( + Object.keys(submitted ?? {}) + .filter((key) => !ignored.has(key)) + .map((key) => ({ key, before: readCurrent(key), after: submitted[key] })) + .filter(({ before, after }) => !sameValue(before, after)) + .map(({ key, before, after }) => { + const hadBefore = normalizeValue(before) !== null; + const hasAfter = normalizeValue(after) !== null; + // No before-value *and* no such field on the entity means there is nothing to compare, which is + // different from a field that exists and was empty. + const kind: ChangeKind = !hadBefore + ? knownOnEntity(key) || !!options?.currentValueFor + ? 'ADDED' + : 'SUBMITTED' + : !hasAfter + ? 'REMOVED' + : 'MODIFIED'; + return { + label: humanizeKey(key), + kind, + oldValue: displayScalar(before), + newValue: displayScalar(after) + }; + }) + // Real changes first, then the fields that could only be reported as submitted. + .sort((a, b) => rank[a.kind] - rank[b.kind] || a.label.localeCompare(b.label)) + ); +} diff --git a/src/app/tasks/maker-checker-entity-fetch.ts b/src/app/tasks/maker-checker-entity-fetch.ts new file mode 100644 index 000000000..d09f20563 --- /dev/null +++ b/src/app/tasks/maker-checker-entity-fetch.ts @@ -0,0 +1,205 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Observable } from 'rxjs'; + +import { ProductsService } from 'app/products/products.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SavingsService } from 'app/savings/savings.service'; +import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service'; +import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service'; +import { SystemService } from 'app/system/system.service'; +import { AccountingService } from 'app/accounting/accounting.service'; +import { UsersService } from 'app/users/users.service'; + +/** + * The services a maker-checker review screen needs in order to read an entity's current state. + * Passed in rather than injected here so this stays a plain lookup table with no Angular wiring. + */ +export interface EntityFetchServices { + products: ProductsService; + clients: ClientsService; + savings: SavingsService; + fixedDeposits: FixedDepositsService; + recurringDeposits: RecurringDepositsService; + system: SystemService; + accounting: AccountingService; + users: UsersService; +} + +/** + * Maps a maker-checker `entityName` to the existing API that returns that entity as it stands today, + * so an UPDATE can be shown as OLD -> NEW and a DELETE can show what is about to be removed. + * + * Every entry reuses an API the application already calls elsewhere - nothing new was added on the + * backend. Products are fetched with `template=true` because that response also carries the option + * lists (`accountingRuleOptions`, `interestCompoundingPeriodTypeOptions`, ...) the payload's bare ids + * refer to. + * + * An entity absent from this table simply has no current-state lookup; callers fall back to rendering + * the submitted payload, which is what every entity did before this table existed. + */ +const ENTITY_FETCHERS: Record Observable> = { + // ---- Products ---------------------------------------------------------------- + SAVINGSPRODUCT: (id, s) => s.products.getSavingProduct(id, true), + CURRENTPRODUCT: (id, s) => s.products.getCurrentProduct(id, true), + SHAREPRODUCT: (id, s) => s.products.getShareProduct(id, true), + LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true), + FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id), + RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true), + CHARGE: (id, s) => s.products.getCharge(id, true), + + // ---- Accounts ---------------------------------------------------------------- + SAVINGSACCOUNT: (id, s) => s.savings.getSavingsAccountData(id), + // Deposit accounts must be read through their own endpoints: /savingsaccounts omits the deposit + // terms (depositAmount, depositPeriod, charts, ...), so comparing against it reports every one of + // those as newly added. + FIXEDDEPOSITACCOUNT: (id, s) => s.fixedDeposits.getFixedDepositsAccountData(id), + RECURRINGDEPOSITACCOUNT: (id, s) => s.recurringDeposits.getRecurringDepositsAccountAndTemplate(id), + + // ---- People and administration ---------------------------------------------- + CLIENT: (id, s) => s.clients.getClientData(id, true), + ROLE: (id, s) => s.system.getRole(id), + USER: (id, s) => s.users.getUser(id), + GLACCOUNT: (id, s) => s.accounting.getGlAccount(id, true) +}; + +/** Whether this screen can read the entity's current state, and therefore produce a real diff. */ +export function canFetchEntity(entityName: string | undefined | null): boolean { + return !!ENTITY_FETCHERS[(entityName ?? '').toUpperCase()]; +} + +/** + * Returns the entity as it stands today, or null when there is no mapping for it or no resource to + * read - in which case the caller keeps showing the submitted payload rather than failing. + */ +export function fetchCurrentEntity( + entityName: string | undefined | null, + resourceId: string | number | undefined | null, + services: EntityFetchServices +): Observable | null { + const fetcher = ENTITY_FETCHERS[(entityName ?? '').toUpperCase()]; + if (!fetcher || resourceId === null || resourceId === undefined || resourceId === '') { + return null; + } + return fetcher(String(resourceId), services); +} + +/** + * The handful of fields worth showing for a DELETE, whose payload is empty - the checker needs to + * recognise *which* record is about to go, not read its full configuration. Keys are tried in order + * and the first present one wins; anything not listed falls back to a generic set. + */ +const DELETE_SUMMARY_KEYS: Record = { + SAVINGSPRODUCT: [ + 'name', + 'shortName', + 'description', + 'currency' + ], + CURRENTPRODUCT: [ + 'name', + 'shortName', + 'description', + 'currency' + ], + SHAREPRODUCT: [ + 'name', + 'shortName', + 'description', + 'currency' + ], + LOANPRODUCT: [ + 'name', + 'shortName', + 'description', + 'currency' + ], + FIXEDDEPOSITPRODUCT: [ + 'name', + 'shortName', + 'description', + 'currency' + ], + RECURRINGDEPOSITPRODUCT: [ + 'name', + 'shortName', + 'description', + 'currency' + ], + CHARGE: [ + 'name', + 'currency', + 'amount', + 'chargeAppliesTo', + 'chargeTimeType' + ], + CLIENT: [ + 'displayName', + 'accountNo', + 'officeName', + 'status', + 'mobileNo', + 'emailAddress' + ], + SAVINGSACCOUNT: [ + 'accountNo', + 'clientName', + 'savingsProductName', + 'status', + 'currency' + ], + FIXEDDEPOSITACCOUNT: [ + 'accountNo', + 'clientName', + 'savingsProductName', + 'status', + 'currency' + ], + RECURRINGDEPOSITACCOUNT: [ + 'accountNo', + 'clientName', + 'savingsProductName', + 'status', + 'currency' + ], + ROLE: [ + 'name', + 'description', + 'disabled' + ], + USER: [ + 'username', + 'firstname', + 'lastname', + 'email', + 'officeName' + ], + GLACCOUNT: [ + 'name', + 'glCode', + 'type', + 'usage', + 'description' + ] +}; + +const GENERIC_SUMMARY_KEYS = [ + 'name', + 'displayName', + 'accountNo', + 'shortName', + 'username', + 'description', + 'status' +]; + +/** Field names worth surfacing for the entity about to be deleted. */ +export function deleteSummaryKeys(entityName: string | undefined | null): string[] { + return DELETE_SUMMARY_KEYS[(entityName ?? '').toUpperCase()] ?? GENERIC_SUMMARY_KEYS; +} diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.html b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.html index 7c382fb18..cf694305e 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.html +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.html @@ -39,9 +39,6 @@ - @@ -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..c7ee498e1 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,23 @@ 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'; /** * One row of the rendered Command payload. The dialog turns the parsed command JSON into a tree of @@ -35,6 +53,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 +105,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 +140,28 @@ 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); + + /** 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 + }; + } /** 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 +191,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 +236,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 +264,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 +273,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 +285,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 +343,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 +360,186 @@ 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); + } + } + + /** + * 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])) + })); + } + + /** + * 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 +597,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(); - }); - } - }); - } } From 08a427467a45a6d3ad71b18c058c17c5df147b95 Mon Sep 17 00:00:00 2001 From: anshul Date: Wed, 23 Sep 2026 15:51:10 +0530 Subject: [PATCH 04/32] A --- ...te-financial-activity-mapping.component.ts | 21 +- ...it-financial-activity-mapping.component.ts | 21 +- .../financial-activity-account-options.ts | 38 + src/app/aml/aml.service.ts | 45 + .../bor-report.component.html | 147 +++ .../bor-report.component.scss | 313 ++++++ .../board-resolution/bor-report.component.ts | 221 ++++ .../board-resolution/bor-report.service.ts | 94 ++ src/app/cashier/cashier-routing.module.ts | 6 - src/app/cashier/cashier.module.ts | 4 +- src/app/cashier/cashier.service.ts | 52 +- .../closing-cash-report.component.ts | 19 +- .../cashier/my-work/my-work.component.html | 20 +- .../cashier/my-work/my-work.component.scss | 15 + src/app/cashier/my-work/my-work.component.ts | 37 +- .../voucher-detail-dialog.component.ts | 320 ++++++ .../share-collection.component.html | 375 ------- .../share-collection.component.scss | 701 ------------ .../share-collection.component.ts | 282 ----- .../cashier-issue-dd-dialog.component.ts | 660 +++++++++--- .../transaction-entry.component.html | 322 ++++-- .../transaction-entry.component.scss | 14 + .../transaction-entry.component.ts | 779 ++++++++++++-- .../kyc-tab/kyc-tab.component.html | 40 + .../kyc-tab/kyc-tab.component.scss | 25 + .../clients-view/kyc-tab/kyc-tab.component.ts | 44 +- src/app/core/shell/shell.component.html | 100 +- ...ount-interest-rate-chart-step.component.ts | 14 +- .../fixed-deposits/fixed-deposits.service.ts | 3 +- src/app/fiu/fiu.component.html | 131 +-- src/app/fiu/fiu.component.ts | 110 +- .../certificate-preview-dialog.component.scss | 16 +- .../declare-dividend.component.html | 68 +- .../declare-dividend.component.scss | 13 + .../declare-dividend.component.ts | 51 +- ...share-product-accounting-step.component.ts | 35 +- .../share-product-preview-step.component.html | 8 - .../share-product-general-tab.component.html | 10 - .../aml-bulk-review-dialog.component.ts | 311 ++++++ .../aml-flagged-transactions.component.html | 445 +++++++- .../aml-flagged-transactions.component.scss | 476 ++++++++- .../aml-flagged-transactions.component.ts | 831 ++++++++++++++- .../aml-review-alert-dialog.component.ts | 24 +- src/app/reports/reports-routing.module.ts | 5 + .../risk-change-approvals.component.ts | 401 +++++++ .../risk-change-dialog.component.ts | 222 ++++ src/app/risk-review/risk-review-data.ts | 24 +- .../risk-review-report.component.html | 39 +- .../risk-review-report.component.scss | 7 + .../risk-review-report.component.ts | 250 ++++- src/app/risk-review/risk-review.compute.ts | 161 ++- src/app/shared/icons.module.ts | 2 + .../share-account-actions.resolver.ts | 1 - .../share-account-statement.component.html | 209 ++++ .../share-account-statement.component.ts | 238 +++++ .../share-area-wise.component.html | 174 +++ .../share-area-wise.component.ts | 188 ++++ .../share-detail-report.component.html | 192 ++++ .../share-detail-report.component.ts | 293 ++++++ .../share-distinctive-numbers.component.html | 152 +++ .../share-distinctive-numbers.component.ts | 206 ++++ .../share-dividend-statement.component.html | 171 +++ .../share-dividend-statement.component.ts | 151 +++ .../share-enquiry.component.html | 291 +++++ .../share-enquiry/share-enquiry.component.ts | 246 +++++ .../share-final-balance.component.html | 208 ++++ .../share-final-balance.component.ts | 279 +++++ .../share-folio-statement.component.html | 250 +++++ .../share-folio-statement.component.ts | 217 ++++ .../share-guarantor-report.component.html | 162 +++ .../share-guarantor-report.component.ts | 208 ++++ .../share-issued-report.component.html | 204 ++++ .../share-issued-report.component.ts | 264 +++++ .../share-label/share-label.component.html | 124 +++ .../share-label/share-label.component.ts | 197 ++++ .../share-letter/share-letter.component.html | 171 +++ .../share-letter/share-letter.component.ts | 227 ++++ ...hare-member-interest-report.component.html | 166 +++ .../share-member-interest-report.component.ts | 197 ++++ .../share-nominal-report.component.html | 244 +++++ .../share-nominal-report.component.ts | 337 ++++++ .../share-payment-certificate.component.html | 160 +++ .../share-payment-certificate.component.ts | 236 +++++ .../share-product-summary.component.html | 168 +++ .../share-product-summary.component.ts | 168 +++ .../shares/reports/share-report.shared.scss | 511 +++++++++ src/app/shares/reports/share-report.util.ts | 185 ++++ .../share-status-report.component.html | 230 ++++ .../share-status-report.component.ts | 268 +++++ .../share-transfer.component.html | 282 +++++ .../share-transfer.component.ts | 339 ++++++ .../share-wise-report.component.html | 165 +++ .../share-wise-report.component.ts | 167 +++ src/app/shares/share-dialog.shared.scss | 996 ++++++++++++++++++ src/app/shares/share-receipt-option.ts | 85 ++ .../apply-shares/apply-shares.component.html | 639 ++++++----- .../apply-shares/apply-shares.component.scss | 354 +++---- .../apply-shares/apply-shares.component.ts | 643 +++++------ .../shares-account-actions.component.html | 3 - .../shares-account-actions.component.ts | 4 - .../apply-collection-dialog.component.html | 596 +++++++---- .../apply-collection-dialog.component.scss | 265 +---- .../apply-collection-dialog.component.ts | 432 ++++---- .../share-certificate-dialog.component.scss | 181 +++- .../shares-account-view.component.ts | 67 +- src/app/shares/shares-routing.module.ts | 141 +++ src/app/shares/shares.module.ts | 2 - src/app/shares/shares.service.ts | 180 ++++ .../dd-inventory-list.component.html | 332 ++++-- .../dd-inventory-list.component.ts | 577 ++++++++-- .../dd-issue-approvals.component.html | 64 +- .../dd-issue-approvals.component.ts | 326 +++++- .../sponsor-dd/dd-list/dd-list.component.html | 503 +++++---- .../sponsor-dd/dd-list/dd-list.component.ts | 672 ++++++++++-- .../gl-settings-dialog.component.ts | 61 +- .../sponsor-bank-list.component.ts | 149 ++- src/app/sponsor-dd/sponsor-dd.service.ts | 19 +- ...ition-request-detail-dialog.component.html | 54 +- ...ition-request-detail-dialog.component.scss | 90 ++ ...ddition-request-detail-dialog.component.ts | 26 + .../checker-inbox-and-tasks.component.html | 6 + src/app/tasks/tasks-routing.module.ts | 6 + src/main.scss | 152 ++- 123 files changed, 20687 insertions(+), 4156 deletions(-) create mode 100644 src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts create mode 100644 src/app/board-resolution/bor-report.component.html create mode 100644 src/app/board-resolution/bor-report.component.scss create mode 100644 src/app/board-resolution/bor-report.component.ts create mode 100644 src/app/board-resolution/bor-report.service.ts create mode 100644 src/app/cashier/my-work/voucher-detail-dialog.component.ts delete mode 100644 src/app/cashier/share-collection/share-collection.component.html delete mode 100644 src/app/cashier/share-collection/share-collection.component.scss delete mode 100644 src/app/cashier/share-collection/share-collection.component.ts create mode 100644 src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts create mode 100644 src/app/risk-review/risk-change-approvals.component.ts create mode 100644 src/app/risk-review/risk-change-dialog.component.ts create mode 100644 src/app/shares/reports/share-account-statement/share-account-statement.component.html create mode 100644 src/app/shares/reports/share-account-statement/share-account-statement.component.ts create mode 100644 src/app/shares/reports/share-area-wise/share-area-wise.component.html create mode 100644 src/app/shares/reports/share-area-wise/share-area-wise.component.ts create mode 100644 src/app/shares/reports/share-detail-report/share-detail-report.component.html create mode 100644 src/app/shares/reports/share-detail-report/share-detail-report.component.ts create mode 100644 src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.html create mode 100644 src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.ts create mode 100644 src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.html create mode 100644 src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts create mode 100644 src/app/shares/reports/share-enquiry/share-enquiry.component.html create mode 100644 src/app/shares/reports/share-enquiry/share-enquiry.component.ts create mode 100644 src/app/shares/reports/share-final-balance/share-final-balance.component.html create mode 100644 src/app/shares/reports/share-final-balance/share-final-balance.component.ts create mode 100644 src/app/shares/reports/share-folio-statement/share-folio-statement.component.html create mode 100644 src/app/shares/reports/share-folio-statement/share-folio-statement.component.ts create mode 100644 src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.html create mode 100644 src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.ts create mode 100644 src/app/shares/reports/share-issued-report/share-issued-report.component.html create mode 100644 src/app/shares/reports/share-issued-report/share-issued-report.component.ts create mode 100644 src/app/shares/reports/share-label/share-label.component.html create mode 100644 src/app/shares/reports/share-label/share-label.component.ts create mode 100644 src/app/shares/reports/share-letter/share-letter.component.html create mode 100644 src/app/shares/reports/share-letter/share-letter.component.ts create mode 100644 src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.html create mode 100644 src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts create mode 100644 src/app/shares/reports/share-nominal-report/share-nominal-report.component.html create mode 100644 src/app/shares/reports/share-nominal-report/share-nominal-report.component.ts create mode 100644 src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.html create mode 100644 src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.ts create mode 100644 src/app/shares/reports/share-product-summary/share-product-summary.component.html create mode 100644 src/app/shares/reports/share-product-summary/share-product-summary.component.ts create mode 100644 src/app/shares/reports/share-report.shared.scss create mode 100644 src/app/shares/reports/share-report.util.ts create mode 100644 src/app/shares/reports/share-status-report/share-status-report.component.html create mode 100644 src/app/shares/reports/share-status-report/share-status-report.component.ts create mode 100644 src/app/shares/reports/share-transfer/share-transfer.component.html create mode 100644 src/app/shares/reports/share-transfer/share-transfer.component.ts create mode 100644 src/app/shares/reports/share-wise-report/share-wise-report.component.html create mode 100644 src/app/shares/reports/share-wise-report/share-wise-report.component.ts create mode 100644 src/app/shares/share-dialog.shared.scss create mode 100644 src/app/shares/share-receipt-option.ts diff --git a/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts b/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts index 2aa1f0481..379940db2 100644 --- a/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts +++ b/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts @@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service'; import { GLAccount } from 'app/shared/models/general.model'; import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { accountOptionsForActivity } from '../financial-activity-account-options'; /** * Create financial activity mapping component. @@ -87,21 +88,11 @@ export class CreateFinancialActivityMappingComponent implements OnInit { */ setGlAccountData() { this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => { - switch (financialActivityId) { - case 100: - case 101: - case 102: - case 103: - this.glAccountData = this.glAccountOptions.assetAccountOptions; - break; - case 200: - case 201: - this.glAccountData = this.glAccountOptions.liabilityAccountOptions; - break; - case 300: - this.glAccountData = this.glAccountOptions.equityAccountOptions; - break; - } + this.glAccountData = accountOptionsForActivity( + this.financialActivityData, + financialActivityId, + this.glAccountOptions + ); }); } diff --git a/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts b/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts index db1f71ca2..b8c6160b7 100644 --- a/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts +++ b/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts @@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service'; import { GLAccount } from 'app/shared/models/general.model'; import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { accountOptionsForActivity } from '../financial-activity-account-options'; /** * Edit financial activity mapping component. @@ -98,21 +99,11 @@ export class EditFinancialActivityMappingComponent implements OnInit { */ setGLAccountData() { this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => { - switch (financialActivityId) { - case 100: - case 101: - case 102: - case 103: - this.glAccountData = this.glAccountOptions.assetAccountOptions; - break; - case 200: - case 201: - this.glAccountData = this.glAccountOptions.liabilityAccountOptions; - break; - case 300: - this.glAccountData = this.glAccountOptions.equityAccountOptions; - break; - } + this.glAccountData = accountOptionsForActivity( + this.financialActivityData, + financialActivityId, + this.glAccountOptions + ); }); } diff --git a/src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts b/src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts new file mode 100644 index 000000000..57a076f89 --- /dev/null +++ b/src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts @@ -0,0 +1,38 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * The GL accounts a financial activity may be mapped to. + * + * The activity itself says which kind of account it needs — the template returns `mappedGLAccountType` on every + * option — so the answer is read from the activity rather than worked out from its id. The screens used to switch on + * the id instead, listing the ones they knew: + * + * case 100, 101, 102, 103 → asset + * case 200, 201 → liability + * case 300 → equity + * + * Every activity added since fell through that switch and left the account dropdown empty, which is how dividendPaid + * (202), reserveForDividend (301), distributableProfit (302), retainedProfit (303) and dividendRoundOff (304) became + * unmappable — the screen offered no accounts and gave no reason. A list keyed on ids has to be extended by hand for + * each new activity, and is silently wrong until someone notices; asking the activity what it needs cannot fall out + * of step with the backend. + * + * @param activities the template's financialActivityOptions + * @param activityId the activity the user picked + * @param glAccountOptions the template's glAccountOptions, keyed assetAccountOptions / liabilityAccountOptions / … + */ +export function accountOptionsForActivity(activities: any[], activityId: number, glAccountOptions: any): any[] { + const activity = (activities || []).find((a) => a?.id === activityId); + const accountType = activity?.mappedGLAccountType; + if (!accountType || !glAccountOptions) { + return []; + } + // ASSET → assetAccountOptions, and so on for LIABILITY, EQUITY, INCOME and EXPENSE. + return glAccountOptions[`${accountType.toLowerCase()}AccountOptions`] ?? []; +} diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts index 76bbc1905..047fed18c 100644 --- a/src/app/aml/aml.service.ts +++ b/src/app/aml/aml.service.ts @@ -34,6 +34,17 @@ export class AmlService { return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes }); } + /** + * Decides named rules within one alert, leaving its other rules open. + * + *

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

+
+
+ +
+

Board of Resolution Register — Share Capital

+

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

+
+
+ +
+ + @if (loading) { +
Loading the register…
+ } @else if (loadError) { +
{{ loadError }}
+ } + + @if (!loading && !loadError) { + + @if (missingResolutionCount > 0) { +
+ + {{ missingResolutionCount }} share account{{ missingResolutionCount === 1 ? '' : 's' }} carr{{ + missingResolutionCount === 1 ? 'ies' : 'y' + }} + no resolution number. Share capital allotted without a board resolution cannot be evidenced to the Registrar. +
+ } + +
+
+
{{ filtered.length | number }}
+
Share Accounts
+
+
+
{{ distinctResolutions | number }}
+
Resolutions
+
+
+
{{ totalShares | number }}
+
Shares Allotted
+
+
+
₹{{ totalValue | number: '1.0-0' }}
+
Share Value
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + @for (r of filtered; track r.shareAccountId) { + + + + + + + + + + + + + + + } @empty { + + + + } + +
Resolution No.Resolution DateShare A/c No.CIF No.Member NameBranchProductSharesShare ValueTranchesStatusActivated On
+ @if (r.resolutionNumber) { + {{ r.resolutionNumber }} + } @else { + Not recorded + } + {{ r.resolutionDate || '—' }}{{ r.accountNo }}{{ r.cifNumber || '—' }}{{ r.clientName }}{{ r.officeName || '—' }}{{ r.productName || '—' }}{{ r.approvedShares | number }}{{ r.shareValue | number: '1.2-2' }}{{ r.trancheCount }}{{ statusText(r.statusEnum) }}{{ r.activatedDate || '—' }}
No share account matches the filters.
+
+ } +
diff --git a/src/app/board-resolution/bor-report.component.scss b/src/app/board-resolution/bor-report.component.scss new file mode 100644 index 000000000..a21ca4608 --- /dev/null +++ b/src/app/board-resolution/bor-report.component.scss @@ -0,0 +1,313 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.bor-root { + padding: 20px 24px 40px; + background: #f8fafc; + min-height: 100%; +} + +.bor-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.bor-head-left { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.bor-head-ic { + font-size: 26px; + color: #0f4f3c; + margin-top: 2px; +} + +.bor-head-t { + margin: 0 0 3px; + font-size: 20px; + font-weight: 700; + color: #1e293b; +} + +.bor-head-s { + margin: 0; + max-width: 780px; + font-size: 13px; + line-height: 1.5; + color: #64748b; +} + +.bor-note { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 11px 14px; + margin-bottom: 14px; + border: 1px solid #bfdbfe; + border-radius: 9px; + background: #eff6ff; + color: #1e3a8a; + font-size: 12.5px; + line-height: 1.55; + + fa-icon { + flex: 0 0 auto; + margin-top: 1px; + } + + /* An allotment with no resolution behind it is the finding, not a detail. */ + &--warn { + border-color: #fde68a; + background: #fffbeb; + color: #92400e; + + fa-icon { + color: #d97706; + } + } +} + +.bor-kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.bor-kpi { + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #0f4f3c; + border-radius: 11px; + padding: 12px 16px; + + &--blue { + border-left-color: #2563eb; + } + + &--green { + border-left-color: #15803d; + } + + &--amber { + border-left-color: #d97706; + } +} + +.bor-kpi-n { + font-size: 22px; + font-weight: 700; + color: #1e293b; + line-height: 1.2; +} + +.bor-kpi-l { + font-size: 12px; + color: #64748b; + margin-top: 2px; +} + +.bor-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + padding: 14px 16px; + margin-bottom: 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 11px; +} + +.bor-filter { + display: flex; + flex-direction: column; + gap: 4px; + + label { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + } + + &--grow { + flex: 1 1 240px; + } +} + +.bor-inp { + height: 34px; + min-width: 170px; + padding: 0 10px; + border: 1px solid #cbd5e1; + border-radius: 7px; + font: inherit; + font-size: 13px; + color: #1e293b; + background: #fff; + + &:focus { + outline: 2px solid #2563eb; + outline-offset: -1px; + } +} + +.bor-check { + display: flex; + align-items: center; + gap: 6px; + height: 34px; + font-size: 12.5px; + color: #334155; + cursor: pointer; +} + +.bor-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 14px; + border: 1px solid #cbd5e1; + border-radius: 7px; + background: #fff; + font: inherit; + font-size: 12.5px; + font-weight: 600; + color: #334155; + cursor: pointer; + + &:hover:not(:disabled) { + background: #f1f5f9; + } + + &:disabled { + opacity: 0.55; + cursor: default; + } + + &--xls { + border-color: #15803d; + background: #16a34a; + color: #fff; + + &:hover:not(:disabled) { + background: #15803d; + } + } +} + +.bor-table-wrap { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 11px; + overflow-x: auto; +} + +.bor-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + padding: 10px; + background: #f1f5f9; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #475569; + white-space: nowrap; + border-bottom: 1px solid #e2e8f0; + } + + td { + padding: 11px 10px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + color: #334155; + } + + .num { + text-align: right; + font-family: 'DM Mono', monospace; + } + + .mono { + font-family: 'DM Mono', monospace; + } + + .strong { + font-weight: 700; + } + + tbody tr:hover { + background: #f8fafc; + } +} + +/* The row an auditor is looking for, marked in the table as well as counted above it. */ +.bor-row--missing { + background: #fffbeb; +} + +.bor-res { + color: #0f4f3c; + font-family: 'DM Mono', monospace; +} + +.bor-missing { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + background: #fef3c7; + color: #92400e; + font-size: 11px; + font-weight: 700; +} + +.bor-status { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: #f1f5f9; + color: #475569; + + &--active { + background: #dcfce7; + color: #15803d; + } + + &--submitted, + &--approved { + background: #fef3c7; + color: #b45309; + } + + &--rejected, + &--closed { + background: #fee2e2; + color: #b91c1c; + } +} + +.bor-empty { + padding: 30px; + text-align: center; + color: #94a3b8; +} diff --git a/src/app/board-resolution/bor-report.component.ts b/src/app/board-resolution/bor-report.component.ts new file mode 100644 index 000000000..776a4ad40 --- /dev/null +++ b/src/app/board-resolution/bor-report.component.ts @@ -0,0 +1,221 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { exportWorkbook } from '../cashier/cashier-export'; +import { BorRegisterRow, BorReportService } from './bor-report.service'; + +/** m_share_account.status_enum, as the register labels it. */ +const STATUS_LABEL: Record = { + 100: 'Submitted', + 200: 'Approved', + 300: 'Active', + 400: 'Rejected', + 500: 'Closed', + 600: 'Inactive' +}; + +/** + * The Board of Resolution register for share capital. + * + * A co-operative allots shares only under a board resolution, and this is the list the Registrar or + * an auditor asks for: every member's holding against the resolution that authorised it. The + * account with no resolution recorded is the point of the register, so it sorts to the top and is + * counted on its own rather than being left to be noticed. + */ +@Component({ + selector: 'mifosx-bor-report', + templateUrl: './bor-report.component.html', + styleUrls: ['./bor-report.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + FormsModule + ] +}) +export class BorReportComponent implements OnInit { + private borService = inject(BorReportService); + + readonly statusLabel = STATUS_LABEL; + + rows: BorRegisterRow[] = []; + loading = false; + loadError = ''; + + /** '' = every status. */ + filterStatus: number | '' = ''; + filterBranch = ''; + filterResolution = ''; + search = ''; + /** Only the accounts with no resolution on file — the ones that need chasing. */ + missingOnly = false; + + exporting = false; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.borService.getRegister().subscribe({ + next: (rows) => { + this.loading = false; + this.rows = rows; + }, + error: (err: any) => { + this.loading = false; + this.rows = []; + this.loadError = err?.error?.defaultUserMessage || 'Could not load the BOR register.'; + } + }); + } + + /** Branches present in the data, so the filter offers only what can actually be selected. */ + get branches(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get statuses(): number[] { + return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b); + } + + get filtered(): BorRegisterRow[] { + const needle = this.search.trim().toLowerCase(); + const resolutionNeedle = this.filterResolution.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.filterStatus !== '' && r.statusEnum !== this.filterStatus) { + return false; + } + if (this.filterBranch && r.officeName !== this.filterBranch) { + return false; + } + if (this.missingOnly && r.resolutionNumber) { + return false; + } + if (resolutionNeedle && !r.resolutionNumber.toLowerCase().includes(resolutionNeedle)) { + return false; + } + if ( + needle && + !( + r.clientName.toLowerCase().includes(needle) || + r.accountNo.toLowerCase().includes(needle) || + r.cifNumber.toLowerCase().includes(needle) + ) + ) { + return false; + } + return true; + }); + } + + get totalShares(): number { + return this.filtered.reduce((s, r) => s + r.approvedShares, 0); + } + + get totalValue(): number { + return this.filtered.reduce((s, r) => s + r.shareValue, 0); + } + + /** How many resolutions the filtered holdings were authorised under. */ + get distinctResolutions(): number { + return new Set(this.filtered.map((r) => r.resolutionNumber).filter(Boolean)).size; + } + + get missingResolutionCount(): number { + return this.rows.filter((r) => !r.resolutionNumber).length; + } + + statusText(statusEnum: number): string { + return STATUS_LABEL[statusEnum] || '—'; + } + + statusClass(statusEnum: number): string { + return 'bor-status bor-status--' + (STATUS_LABEL[statusEnum] || 'unknown').toLowerCase(); + } + + resetFilters(): void { + this.filterStatus = ''; + this.filterBranch = ''; + this.filterResolution = ''; + this.search = ''; + this.missingOnly = false; + } + + async exportExcel(): Promise { + this.exporting = true; + try { + const rows = this.filtered; + await exportWorkbook( + [ + { + sheetName: 'BOR Register', + title: 'Board of Resolution Register — Share Capital', + subtitle: `${rows.length} share account(s) · ${this.distinctResolutions} resolution(s) · ${this.totalShares} shares`, + headers: [ + 'Resolution No.', + 'Resolution Date', + 'Share A/c No.', + 'CIF No.', + 'Member Name', + 'Branch', + 'Product', + 'Shares', + 'Unit Price', + 'Share Value', + 'Tranches', + 'Status', + 'Activated On' + ], + widths: [ + 20, + 16, + 18, + 16, + 30, + 18, + 18, + 10, + 12, + 14, + 10, + 12, + 14 + ], + rows: rows.map((r) => [ + // Named rather than left blank, because an empty cell in a filing reads as an + // oversight in the export rather than a gap in the register. + r.resolutionNumber || 'NOT RECORDED', + r.resolutionDate || '—', + r.accountNo, + r.cifNumber, + r.clientName, + r.officeName, + r.productName, + r.approvedShares, + r.unitPrice, + r.shareValue, + r.trancheCount, + this.statusText(r.statusEnum), + r.activatedDate || '—' + ]) + } + ], + 'BOR-Register' + ); + } finally { + this.exporting = false; + } + } +} diff --git a/src/app/board-resolution/bor-report.service.ts b/src/app/board-resolution/bor-report.service.ts new file mode 100644 index 000000000..b75370e8c --- /dev/null +++ b/src/app/board-resolution/bor-report.service.ts @@ -0,0 +1,94 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; + +/** Custom Helpers */ +import { toIsoDate } from '../core/utils/api-date'; + +/** One share account and the board resolution that authorised it. */ +export interface BorRegisterRow { + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeId: number | null; + officeName: string; + productName: string; + currencyCode: string; + statusEnum: number; + approvedShares: number; + pendingShares: number; + unitPrice: number; + shareValue: number; + resolutionNumber: string; + /** ISO date, or '' where no meeting date was ever recorded against the allotment. */ + resolutionDate: string; + /** How many purchase tranches make up the holding. */ + trancheCount: number; + submittedDate: string; + approvedDate: string; + activatedDate: string; + closedDate: string; +} + +/** + * The BOR register. + * + * One call returns every account with its resolution. The share allotment report next door fetches + * each account separately and stops at two hundred, which is a limit on a register rather than on a + * page of one. + */ +@Injectable({ providedIn: 'root' }) +export class BorReportService { + private http = inject(HttpClient); + + getRegister(status?: number, officeId?: number): Observable { + let params = new HttpParams(); + if (status != null) { + params = params.set('status', String(status)); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http + .get('/shareaccounts/bor-register', { params }) + .pipe(map((rows) => (rows || []).map((r) => this.toRow(r)))); + } + + /** Dates arrive as [y, M, d] arrays; flattened to ISO so they sort and filter as strings. */ + private toRow(r: any): BorRegisterRow { + return { + shareAccountId: r.shareAccountId, + accountNo: r.accountNo || '', + clientId: r.clientId, + cifNumber: r.cifNumber || '', + clientName: r.clientName || '', + officeId: r.officeId ?? null, + officeName: r.officeName || '', + productName: r.productName || '', + currencyCode: r.currencyCode || 'INR', + statusEnum: Number(r.statusEnum) || 0, + approvedShares: Number(r.approvedShares) || 0, + pendingShares: Number(r.pendingShares) || 0, + unitPrice: Number(r.unitPrice) || 0, + shareValue: Number(r.shareValue) || 0, + resolutionNumber: (r.resolutionNumber || '').trim(), + resolutionDate: toIsoDate(r.resolutionDate), + trancheCount: Number(r.trancheCount) || 0, + submittedDate: toIsoDate(r.submittedDate), + approvedDate: toIsoDate(r.approvedDate), + activatedDate: toIsoDate(r.activatedDate), + closedDate: toIsoDate(r.closedDate) + }; + } +} diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 32f24de9b..045252339 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -26,7 +26,6 @@ import { VaultPositionComponent } from './vault-position/vault-position.componen import { AccountRegisterComponent } from './account-register/account-register.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; -import { ShareCollectionComponent } from './share-collection/share-collection.component'; const routes: Routes = [ Route.withShell([ @@ -345,11 +344,6 @@ const routes: Routes = [ component: ExcessTransactionReportComponent, data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' } }, - { - path: 'share-collection', - component: ShareCollectionComponent, - data: { title: 'Share Collection', breadcrumb: 'Share Collection' } - }, { path: 'transaction-entry', canActivate: [CashierOnlyGuard], diff --git a/src/app/cashier/cashier.module.ts b/src/app/cashier/cashier.module.ts index 2cf93e092..ec6dcfeeb 100644 --- a/src/app/cashier/cashier.module.ts +++ b/src/app/cashier/cashier.module.ts @@ -20,7 +20,6 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone import { CashHistoryComponent } from './cash-history/cash-history.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; -import { ShareCollectionComponent } from './share-collection/share-collection.component'; @NgModule({ imports: [ @@ -35,8 +34,7 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co ReconciliationComponent, CashHistoryComponent, ClosingCashReportComponent, - ExcessTransactionReportComponent, - ShareCollectionComponent + ExcessTransactionReportComponent ] }) export class CashierModule {} diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 9845ce8a4..3078701f8 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -178,6 +178,13 @@ export class CashierService { return this.http.get('/vouchers/my-work', { params }); } + /** One voucher and every debit/credit line it is made of — the full slip behind a summarised + * row (a "+2 more" badge in a register only ever shows the first side of each). */ + voucherDetail(voucherNo: string): Observable { + const params = new HttpParams().set('voucherNo', voucherNo); + return this.http.get('/vouchers/detail', { params }); + } + /** * Account register for one date: what opened and closed that day, each account's balance as it * stood then, and what it holds now. @@ -357,19 +364,6 @@ export class CashierService { return this.http.get('/paymenttypes'); } - // ── Share Collection ── - getShareAccountForCollection(accountId: string): Observable { - return this.http.get(`/accounts/share/${accountId}`); - } - - collectSharePayment(data: any): Observable { - return this.http.post('/cashier/sharecollection', data); - } - - getShareCollectionStatus(shareAccountId: number): Observable { - return this.http.get(`/cashier/sharecollection/${shareAccountId}`); - } - /** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */ getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable { // The voucher travels as a query parameter, not a path segment: numbers are formatted @@ -396,10 +390,18 @@ export class CashierService { return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } }); } - /** Receipts still sitting in Share Sundry, optionally for a single member. */ - getUnappliedShareAdvances(clientId?: number): Observable { - const suffix = clientId ? `?clientId=${clientId}` : ''; - return this.http.get(`/shareadvancecollection${suffix}`); + /** + * Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts, + * ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL + * deposits and receipts linked to another member — which attach by voucher instead. + * + * One request rather than two: both lists come from the same table under the same filters, and + * splitting them across endpoints only meant the same rows were fetched twice and, where they + * overlapped, drawn twice on the same screen. + */ + getShareCollectionReceipts(clientId?: number): Observable { + const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined; + return this.http.get('/shareadvancecollection/unattributed', { params }); } /** Moves a receipt out of Sundry into share capital for an activated account. */ @@ -407,6 +409,12 @@ export class CashierService { return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data); } + /** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box, + * which is unrelated screen state that can name a different account than this receipt sits in. */ + getShareAdvanceGlEntries(collectionId: number): Observable { + return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`); + } + /** Every additional-share tranche awaiting approval, across every share account. */ getShareAdditionRequests(): Observable { return this.http.get('/shareadditionrequests'); @@ -419,4 +427,14 @@ export class CashierService { getShareAdditionRequest(transactionId: number): Observable { return this.http.get(`/shareadditionrequests/${transactionId}`); } + + /** + * Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the + * "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later + * without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's + * options should still be usable for every other field. + */ + getEntryTypeOptions(): Observable { + return this.http.get('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([]))); + } } diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts index b5876e77e..929a2af27 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts @@ -174,19 +174,24 @@ export class ClosingCashReportComponent implements OnInit { return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0); } + /** + * Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount` + * already carries forward what a cashier was holding from before today (`openingCount`), for the + * force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/ + * withdrawal/settledOut dropped that carry-forward and put the Total row back into negative + * numbers a cashier had already reconciled. + */ get noteNetCount(): number { - return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount; + return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0); } /** - * What is left in the drawer in notes: everything that came in, less everything that went out. - * - * Was deposits minus withdrawals, which is the counter's movement rather than the drawer's - * contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net - * of zero and had nothing to count against. + * What is left in the drawer in notes: everything that came in, less everything that went out — + * including cash carried forward from before today. See `noteNetCount` for why this sums each + * row's own `netAmount` rather than re-deriving it from today's legs. */ get noteNetAmount(): number { - return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount; + return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0); } get coinRows(): any[] { return this.rows.filter((r) => r.kind === 'coin'); diff --git a/src/app/cashier/my-work/my-work.component.html b/src/app/cashier/my-work/my-work.component.html index 50139959d..e4c96e7db 100644 --- a/src/app/cashier/my-work/my-work.component.html +++ b/src/app/cashier/my-work/my-work.component.html @@ -152,9 +152,21 @@ @for (r of g.rows; track r.voucherNo) { - - {{ r.voucherNo }} + is when the heading has scrolled away. Clicking anywhere on the row opens the full + slip -- every line, not just the first debit and first credit this table has room + to show. --> + + + {{ r.voucherNo }} + @if (loadingDetailFor === r.voucherNo) { + hourglass_empty + } + {{ sideLabel(r.side) }} @@ -171,7 +183,7 @@ {{ r.creditAccount }} } @if (r.extraLines > 0) { - +{{ r.extraLines }} more + +{{ r.extraLines }} more } ₹{{ r.amount | number: '1.2-2' }} diff --git a/src/app/cashier/my-work/my-work.component.scss b/src/app/cashier/my-work/my-work.component.scss index 674708b5e..4e8d33e25 100644 --- a/src/app/cashier/my-work/my-work.component.scss +++ b/src/app/cashier/my-work/my-work.component.scss @@ -178,6 +178,21 @@ } } +.mw-row--clickable { + cursor: pointer; + + &:hover { + background: #f8fafc; + } +} + +.mw-vno__spinner { + margin-left: 4px; + font-size: 14px; + vertical-align: middle; + color: #94a3b8; +} + /* One section per part played. The heading carries the section's own count and total, so a maker's tally and a checker's are each readable without adding rows up. */ .mw-group { diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts index 9173fc55f..4dbee818f 100644 --- a/src/app/cashier/my-work/my-work.component.ts +++ b/src/app/cashier/my-work/my-work.component.ts @@ -10,8 +10,10 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; -import { catchError, of } from 'rxjs'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { catchError, finalize, of } from 'rxjs'; import { CashierService } from '../cashier.service'; +import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component'; /** What the signed-in user's part in a voucher was. */ export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED'; @@ -72,11 +74,17 @@ interface MyWorkReport { imports: [ CommonModule, FormsModule, - MatIconModule + MatIconModule, + MatDialogModule ] }) export class MyWorkComponent implements OnInit { private service = inject(CashierService); + private dialog = inject(MatDialog); + + /** Which voucher's detail is currently being fetched, so the row that was clicked can show its + * own spinner instead of a page-wide one for what is a small, single-row lookup. */ + loadingDetailFor: string | null = null; /** Day is the working default; a month is what a close or an audit asks for. */ period: 'DAY' | 'MONTH' = 'DAY'; @@ -323,6 +331,31 @@ export class MyWorkComponent implements OnInit { return { from: first, to: lastIso }; } + /** Opens the full slip for one voucher — every debit and credit line, not just the first side of + * each a table row has room to show. */ + openDetail(voucherNo: string): void { + if (this.loadingDetailFor) { + return; + } + this.loadingDetailFor = voucherNo; + this.service + .voucherDetail(voucherNo) + .pipe( + catchError(() => of(null)), + finalize(() => (this.loadingDetailFor = null)) + ) + .subscribe((detail: any) => { + if (!detail) { + return; + } + this.dialog.open(VoucherDetailDialogComponent, { + data: { detail }, + width: '600px', + autoFocus: false + }); + }); + } + private todayIso(): string { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; diff --git a/src/app/cashier/my-work/voucher-detail-dialog.component.ts b/src/app/cashier/my-work/voucher-detail-dialog.component.ts new file mode 100644 index 000000000..93137c3c7 --- /dev/null +++ b/src/app/cashier/my-work/voucher-detail-dialog.component.ts @@ -0,0 +1,320 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; + +export interface VoucherDetailDialogData { + /** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog + * only renders, so one loading state lives in the row's own click handler rather than being + * duplicated inside every dialog that could ever show a voucher. */ + detail: { + voucherNo: string; + voucherType: string | null; + amount: number | null; + branchName: string | null; + createdBy: string | null; + createdAt: string | null; + totalDebit: number; + totalCredit: number; + balanced: boolean; + lines: { + lineNo: number; + entryType: 'DR' | 'CR'; + partyName: string | null; + accountNo: string | null; + amount: number; + description: string | null; + }[]; + /** GL entries a native platform command posted for the same underlying event, outside this + * voucher entirely — a share redemption's Dr Share Capital / Cr Share Sundry pair, posted at + * manager approval, before this voucher's own Dr Sundry / Cr Cash at Vault settlement leg even + * exists. Empty for every voucher that already is the whole transaction on its own. */ + relatedLines: { + stage: string; + entryType: 'DR' | 'CR'; + partyName: string | null; + accountNo: string | null; + amount: number; + description: string | null; + }[]; + }; +} + +/** The full slip behind a summarised register row — every debit and credit line a voucher is + * made of, not just the first side of each that a table row has room for. */ +@Component({ + selector: 'mifosx-voucher-detail-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule + ], + template: ` +
+
+
+ receipt_long +
+

{{ detail.voucherNo }}

+

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

+
+
+ +
+ +
+ Raised by {{ detail.createdBy || '—' }} + @if (detail.createdAt) { + {{ detail.createdAt | date: 'dd MMM yyyy, h:mm a' }} + } + @if (!detail.balanced) { + + warning + Debit and credit do not balance + + } +
+ + + + + + + + + + + + @for (line of detail.lines; track line.lineNo) { + + + + + + + @if (line.description) { + + + + + } + } @empty { + + + + } + + @if (detail.lines.length > 0) { + + + + + + + } +
SidePartyAccount No.Amount
+ + {{ line.entryType === 'DR' ? 'Debit' : 'Credit' }} + + {{ line.partyName || '—' }}{{ line.accountNo || '—' }}₹{{ line.amount | number: '1.2-2' }}
{{ line.description }}
This voucher's individual lines were not recorded.
Total + ₹{{ detail.totalDebit | number: '1.2-2' }} / ₹{{ detail.totalCredit | number: '1.2-2' }} +
+ + @if (detail.relatedLines.length > 0) { + + } + +
+ +
+
+ `, + styles: [ + ` + .vd-shell { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px 24px 16px; + min-width: 420px; + max-width: 560px; + } + .vd-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + } + .vd-header__title { + display: flex; + align-items: center; + gap: 12px; + } + .vd-header__title h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + } + .vd-header__title p { + margin: 2px 0 0; + font-size: 13px; + color: rgba(0, 0, 0, 0.6); + } + .vd-icon { + color: #1976d2; + } + .vd-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + font-size: 13px; + color: rgba(0, 0, 0, 0.65); + } + .vd-unbalanced { + display: inline-flex; + align-items: center; + gap: 4px; + color: #b91c1c; + font-weight: 600; + } + .vd-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + } + .vd-table th { + text-align: left; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.03em; + color: rgba(0, 0, 0, 0.5); + padding: 6px 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + } + .vd-table td { + padding: 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + } + .vd-table .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + .vd-acct { + color: rgba(0, 0, 0, 0.55); + } + .vd-side { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + } + .vd-side--dr { + background: #fee2e2; + color: #b91c1c; + } + .vd-side--cr { + background: #dcfce7; + color: #15803d; + } + .vd-desc-row td { + padding-top: 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + } + .vd-desc { + font-size: 12px; + color: rgba(0, 0, 0, 0.5); + font-style: italic; + } + .vd-empty { + text-align: center; + color: rgba(0, 0, 0, 0.5); + padding: 16px 8px; + } + .vd-table tfoot td { + font-weight: 600; + border-bottom: none; + border-top: 1px solid rgba(0, 0, 0, 0.1); + } + .vd-related { + border-top: 1px dashed rgba(0, 0, 0, 0.15); + padding-top: 12px; + } + .vd-related__title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #6d28d9; + margin-bottom: 8px; + } + .vd-table--related td { + background: #faf5ff; + } + .vd-related__hint { + margin: 6px 0 0; + font-size: 12px; + color: rgba(0, 0, 0, 0.5); + } + .vd-actions { + display: flex; + justify-content: flex-end; + } + ` + ] +}) +export class VoucherDetailDialogComponent { + dialogRef = inject(MatDialogRef); + private data = inject(MAT_DIALOG_DATA); + + detail = this.data.detail; +} diff --git a/src/app/cashier/share-collection/share-collection.component.html b/src/app/cashier/share-collection/share-collection.component.html deleted file mode 100644 index a17ed89f7..000000000 --- a/src/app/cashier/share-collection/share-collection.component.html +++ /dev/null @@ -1,375 +0,0 @@ - - -
- -
-
-
CASHIER · SHARE COLLECTION
-

Share Collection

-

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

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

Looking up account…

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

Collection Posted Successfully

-

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

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

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

- - -
- } -
diff --git a/src/app/cashier/share-collection/share-collection.component.scss b/src/app/cashier/share-collection/share-collection.component.scss deleted file mode 100644 index c3a6606a2..000000000 --- a/src/app/cashier/share-collection/share-collection.component.scss +++ /dev/null @@ -1,701 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -// ── Search Bar ─────────────────────────────────────────────────────────────── - -.sc-search-bar { - display: flex; - align-items: center; - gap: 10px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - padding: 10px 14px; - - &__input-wrap { - display: flex; - align-items: center; - gap: 8px; - flex: 1 1 auto; - } - - &__icon { - color: #94a3b8; - font-size: 20px; - width: 20px; - height: 20px; - flex-shrink: 0; - } - - &__input { - flex: 1 1 auto; - border: none; - outline: none; - font-size: 14px; - font-family: inherit; - color: #0d1b2a; - background: transparent; - - &::placeholder { - color: #94a3b8; - } - } -} - -// ── Alert ──────────────────────────────────────────────────────────────────── - -.sc-alert { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - border-radius: 10px; - font-size: 13px; - font-weight: 500; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - flex-shrink: 0; - } - - &--error { - background: #fef2f2; - border: 1px solid #fecaca; - color: #dc2626; - } -} - -// ── Spinner ────────────────────────────────────────────────────────────────── - -.sc-spin { - animation: sc-spin 1.2s linear infinite; - display: block; - margin: 0 auto 8px; - font-size: 32px !important; - width: 32px !important; - height: 32px !important; - color: #94a3b8; -} - -@keyframes sc-spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -// ── Member Card ────────────────────────────────────────────────────────────── - -.sc-member-card { - display: flex; - align-items: center; - gap: 16px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - padding: 16px 20px; - - &__avatar { - display: flex; - align-items: center; - justify-content: center; - width: 48px; - height: 48px; - border-radius: 50%; - background: #eff6ff; - flex-shrink: 0; - - mat-icon { - color: #2563eb; - font-size: 24px; - width: 24px; - height: 24px; - } - } - - &__info { - flex: 1 1 auto; - min-width: 0; - } - - &__name { - font-size: 16px; - font-weight: 700; - color: #1e293b; - margin-bottom: 6px; - } - - &__meta { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - - &__shares { - flex-shrink: 0; - display: flex; - align-items: center; - } -} - -// ── Badges ─────────────────────────────────────────────────────────────────── - -.sc-badge { - display: inline-flex; - align-items: center; - height: 22px; - padding: 0 8px; - border-radius: 5px; - font-size: 11px; - font-weight: 700; - background: #f1f5f9; - color: #475569; - border: 1px solid #e2e8f0; - - &--account { - background: #eff6ff; - color: #1d4ed8; - border-color: #bfdbfe; - font-family: 'Roboto Mono', monospace; - } - - &--product { - background: #f0fdf4; - color: #15803d; - border-color: #bbf7d0; - } - - &--pending { - background: #fffbeb; - color: #d97706; - border-color: #fde68a; - } - - &--active { - background: #f0fdf4; - color: #15803d; - border-color: #bbf7d0; - } -} - -// ── Editable Share Inputs ──────────────────────────────────────────────────── - -.sc-shares-inputs { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.sc-shares-field { - display: flex; - flex-direction: column; - gap: 3px; -} - -.sc-shares-label { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: #94a3b8; -} - -.sc-shares-input { - width: 110px; - height: 38px; - border: 1.5px solid #e2e8f0; - border-radius: 8px; - padding: 0 10px; - font-size: 14px; - font-weight: 700; - font-family: inherit; - color: #1353a0; - text-align: right; - outline: none; - background: #f8fafc; - transition: border-color 0.15s; - - &:focus { - border-color: #1353a0; - background: #fff; - } - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - } -} - -.sc-shares-sep { - font-size: 20px; - font-weight: 300; - color: #94a3b8; - padding-top: 16px; -} - -.sc-shares-result { - text-align: right; -} - -.sc-shares-total { - font-size: 20px; - font-weight: 800; - color: #0f4f3c; - font-variant-numeric: tabular-nums; -} - -// ── Editable Charge Amount ─────────────────────────────────────────────────── - -.sc-charge-amount { - padding: 4px 14px !important; -} - -.sc-charge-input { - width: 100px; - height: 32px; - border: 1.5px solid #e2e8f0; - border-radius: 6px; - padding: 0 8px; - font-size: 13px; - font-weight: 700; - font-family: inherit; - color: #1353a0; - text-align: right; - outline: none; - background: #f8fafc; - transition: border-color 0.15s; - - &:focus { - border-color: #1353a0; - background: #fff; - } - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - } -} - -// ── Split Layout ───────────────────────────────────────────────────────────── - -.sc-split { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; - - @media (width <= 900px) { - grid-template-columns: 1fr; - } -} - -// ── Breakdown Card ─────────────────────────────────────────────────────────── - -.sc-breakdown { - &__header { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; - font-size: 13px; - font-weight: 700; - color: #334155; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: #0f4f3c; - } - } -} - -.sc-gl-preview { - .sc-breakdown__header { - mat-icon { - color: #1d4ed8; - } - } -} - -.sc-gl-note { - display: flex; - align-items: flex-start; - gap: 6px; - padding: 10px 14px; - font-size: 12px; - color: #64748b; - background: #f8fafc; - border-top: 1px solid #e2e8f0; - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - flex-shrink: 0; - margin-top: 1px; - color: #94a3b8; - } -} - -// ── Table helpers ──────────────────────────────────────────────────────────── - -.sc-dim { - color: #94a3b8; - font-size: 12px; -} - -.sc-total-row td { - background: #f8fafc; - border-top: 2px solid #e2e8f0; - font-size: 14px; -} - -.sc-total-amount { - color: #0f4f3c; -} - -// ── Dr / Cr badges ─────────────────────────────────────────────────────────── - -.sc-dr-badge, -.sc-cr-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 20px; - border-radius: 4px; - font-size: 11px; - font-weight: 800; - letter-spacing: 0.04em; -} - -.sc-dr-badge { - background: #fef2f2; - color: #dc2626; - border: 1px solid #fecaca; -} - -.sc-cr-badge { - background: #f0fdf4; - color: #15803d; - border: 1px solid #bbf7d0; -} - -// ── Footer ─────────────────────────────────────────────────────────────────── - -.sc-footer { - justify-content: space-between !important; - - &__summary { - display: flex; - flex-direction: column; - gap: 2px; - } - - &__label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: #64748b; - } - - &__amount { - font-size: 22px; - font-weight: 800; - color: #0f4f3c; - font-variant-numeric: tabular-nums; - } - - &__actions { - display: flex; - align-items: center; - gap: 10px; - } -} - -// ── Denomination Panel ──────────────────────────────────────────────────────── - -.sc-denom { - padding: 0; - overflow: hidden; -} - -.sc-denom-body { - padding: 14px 16px; -} - -.sc-denom-table { - width: 100%; - - th, - td { - padding: 7px 10px; - font-size: 13px; - } - - tfoot td { - border-top: 2px solid #e2e8f0; - background: #f8fafc; - font-size: 14px; - } -} - -.sc-denom-chip { - display: inline-flex; - align-items: center; - height: 24px; - padding: 0 10px; - border-radius: 5px; - font-size: 12px; - font-weight: 700; - background: #eff6ff; - color: #1d4ed8; - border: 1px solid #bfdbfe; - - &--coin { - background: #fffbeb; - color: #d97706; - border-color: #fde68a; - } -} - -.sc-denom-stepper { - display: flex; - align-items: center; - gap: 4px; - justify-content: flex-end; - - button { - width: 26px; - height: 26px; - border: 1.5px solid #e2e8f0; - border-radius: 5px; - background: #f8fafc; - color: #475569; - font-size: 16px; - line-height: 1; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - - &:hover:not(:disabled) { - background: #e0f2fe; - border-color: #7dd3fc; - color: #0369a1; - } - - &:disabled { - opacity: 0.35; - cursor: not-allowed; - } - } - - input { - width: 62px; - height: 28px; - border: 1.5px solid #e2e8f0; - border-radius: 6px; - padding: 0 6px; - font-size: 13px; - font-weight: 600; - font-family: inherit; - text-align: right; - outline: none; - background: #fff; - - &:focus { - border-color: #1353a0; - } - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - } - } -} - -.sc-denom-badge { - margin-left: auto; - display: inline-flex; - align-items: center; - height: 22px; - padding: 0 10px; - border-radius: 5px; - font-size: 12px; - font-weight: 700; - - &--match { - background: #f0fdf4; - color: #15803d; - border: 1px solid #bbf7d0; - } - - &--mismatch { - background: #fef2f2; - color: #dc2626; - border: 1px solid #fecaca; - } -} - -.sc-denom-required { - margin-left: auto; - font-size: 12px; - font-weight: 700; - color: #d97706; -} - -.sc-denom-mismatch { - display: flex; - align-items: center; - gap: 6px; - margin-top: 10px; - padding: 9px 12px; - background: #fef2f2; - border: 1px solid #fecaca; - border-radius: 8px; - font-size: 12px; - font-weight: 600; - color: #dc2626; - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - flex-shrink: 0; - } -} - -.sc-denom-actions { - display: flex; - justify-content: flex-end; - margin-top: 10px; -} - -// ── Success ────────────────────────────────────────────────────────────────── - -.sc-success { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding: 40px 24px; - gap: 16px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 16px; - - &__icon-wrap { - width: 72px; - height: 72px; - border-radius: 50%; - background: #f0fdf4; - display: flex; - align-items: center; - justify-content: center; - - mat-icon { - font-size: 40px; - width: 40px; - height: 40px; - color: #16a34a; - } - } - - &__title { - font-size: 22px; - font-weight: 700; - color: #1e293b; - margin: 0; - } - - &__sub { - font-size: 14px; - color: #64748b; - max-width: 480px; - margin: 0; - } - - &__ref { - display: flex; - align-items: center; - gap: 10px; - background: #f1f5f9; - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 8px 16px; - } - - &__ref-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: #94a3b8; - white-space: nowrap; - } - - &__ref-val { - font-family: 'Roboto Mono', 'Courier New', monospace; - font-size: 13px; - font-weight: 600; - color: #1353a0; - } - - &__note { - font-size: 12px; - color: #94a3b8; - max-width: 420px; - margin: 0; - text-align: center; - } - - &__gl { - display: flex; - flex-direction: column; - gap: 6px; - width: 100%; - max-width: 400px; - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 10px; - padding: 14px 16px; - } - - &__gl-row { - display: flex; - justify-content: space-between; - font-size: 13px; - font-weight: 600; - padding: 4px 0; - border-bottom: 1px dotted #e2e8f0; - - &:last-child { - border-bottom: none; - } - - &--dr { - color: #dc2626; - } - - &--cr { - color: #15803d; - } - } -} diff --git a/src/app/cashier/share-collection/share-collection.component.ts b/src/app/cashier/share-collection/share-collection.component.ts deleted file mode 100644 index e9d2119f3..000000000 --- a/src/app/cashier/share-collection/share-collection.component.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { RouterModule } from '@angular/router'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { MatInputModule } from '@angular/material/input'; -import { catchError } from 'rxjs/operators'; -import { of } from 'rxjs'; -import { CashierService } from '../cashier.service'; - -type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success'; - -interface Denomination { - id: number; - value: number; - kind: 'note' | 'coin'; - label: string; -} - -interface ShareCharge { - id: number; - name: string; - amount: number; - currencySymbol: string; -} - -@Component({ - selector: 'mifosx-share-collection', - templateUrl: './share-collection.component.html', - styleUrls: [ - '../cashier-shared.scss', - './share-collection.component.scss' - ], - standalone: true, - imports: [ - CommonModule, - FormsModule, - RouterModule, - MatIconModule, - MatSnackBarModule, - MatFormFieldModule, - MatSelectModule, - MatInputModule - ] -}) -export class ShareCollectionComponent implements OnInit { - private service = inject(CashierService); - private snackBar = inject(MatSnackBar); - - pageState: PageState = 'idle'; - accountInput = ''; - transactionDate = ''; - paymentTypeId: number | null = null; - paymentTypes: any[] = []; - note = ''; - errorMsg = ''; - saving = false; - - account: any = null; - charges: ShareCharge[] = []; - collectionResult: any = null; // filled after successful POST - - // Editable fields — cashier can override if API returns 0 - requestedSharesInput = 0; - pricePerShare = 0; - - // ── Denomination ──────────────────────────────────────────────────────────── - notes: Denomination[] = []; - coins: Denomination[] = []; - denominationCounts: Record = {}; - - get shareAmount(): number { - return this.requestedSharesInput * this.pricePerShare; - } - - get totalChargeAmount(): number { - return this.charges.reduce((s, c) => s + c.amount, 0); - } - - get totalAmount(): number { - return this.shareAmount + this.totalChargeAmount; - } - - get currencySymbol(): string { - return this.account?.currency?.displaySymbol ?? '₹'; - } - - get isCashPayment(): boolean { - if (this.paymentTypeId === null) return true; - const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId); - return !!pt?.isCashPayment; - } - - get allDenominations(): Denomination[] { - const desc = (a: Denomination, b: Denomination) => b.value - a.value; - return [ - ...[...this.notes].sort(desc), - ...[...this.coins].sort(desc) - ]; - } - - get denominationTotal(): number { - return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0); - } - - get denomMatchesTotal(): boolean { - return this.denominationTotal === this.totalAmount; - } - - get isValid(): boolean { - if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false; - if (this.isCashPayment && this.allDenominations.length > 0) { - return this.denomMatchesTotal; - } - return true; - } - - ngOnInit(): void { - const d = new Date(); - this.transactionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - this.service - .getPaymentTypes() - .pipe(catchError(() => of([]))) - .subscribe((types: any[]) => (this.paymentTypes = types || [])); - this.loadDenominations(); - } - - private loadDenominations(): void { - this.service - .getActiveDenominations() - .pipe(catchError(() => of([]))) - .subscribe((list: any[]) => { - const rows: Denomination[] = (list || []).map((d: any) => ({ - id: d.id, - value: d.denomination ?? d.value, - kind: d.type === 'coin' ? 'coin' : 'note', - label: '₹' + (d.denomination ?? d.value) - })); - const order = (a: Denomination, b: Denomination) => b.value - a.value; - this.notes = rows.filter((r) => r.kind === 'note').sort(order); - this.coins = rows.filter((r) => r.kind === 'coin').sort(order); - }); - } - - // ── Denomination helpers ────────────────────────────────────────────────── - - denomKey(d: Denomination): string { - return `${d.kind}:${d.value}`; - } - - countFor(d: Denomination): number { - return this.denominationCounts[this.denomKey(d)] || 0; - } - - subtotalFor(d: Denomination): number { - return this.countFor(d) * d.value; - } - - onDenomChange(d: Denomination, raw: any): void { - this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0)); - } - - bumpDenom(d: Denomination, delta: number): void { - this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta); - } - - clearDenominations(): void { - this.denominationCounts = {}; - } - - searchAccount(): void { - const query = this.accountInput.trim(); - if (!query) return; - this.pageState = 'searching'; - this.account = null; - this.charges = []; - this.errorMsg = ''; - - this.service - .getShareAccountForCollection(query) - .pipe( - catchError((err) => { - this.errorMsg = - err?.error?.errors?.[0]?.defaultUserMessage || - err?.error?.developerMessage || - 'Account not found. Please check the account ID and try again.'; - this.pageState = 'idle'; - return of(null); - }) - ) - .subscribe((acc: any) => { - if (!acc) return; - this.account = acc; - - // Auto-detect shares from summary or first pending purchasedShares entry - const pendingTx = (acc.purchasedShares || []).find( - (t: any) => - t.status?.code === 'purchasedSharesStatusType.applied' && t.type?.code === 'purchasedSharesType.purchased' - ); - this.requestedSharesInput = Number( - acc.summary?.totalPendingForApprovalShares || pendingTx?.numberOfShares || acc.requestedShares || 0 - ); - this.pricePerShare = Number(pendingTx?.purchasedPrice || acc.currentMarketPrice || acc.unitPrice || 0); - - // Show ALL charges on the account — no type filtering - this.charges = (acc.charges || []).map((c: any) => ({ - id: c.chargeId ?? c.id, - name: c.name, - amount: Number(c.amount ?? c.amountOutstanding ?? 0), - currencySymbol: c.currency?.displaySymbol ?? '₹' - })); - - this.pageState = 'loaded'; - }); - } - - recalc(): void { - // triggered by input changes — nothing extra needed, getters auto-recalculate - } - - submit(): void { - if (!this.isValid || this.saving) return; - this.saving = true; - const payload = { - shareAccountId: this.account.id, - accountNo: this.account.accountNo, - clientName: this.account.clientName, - transactionDate: this.transactionDate, - requestedShares: this.requestedSharesInput, - pricePerShare: this.pricePerShare, - shareAmount: this.shareAmount, - chargeAmount: this.totalChargeAmount, - totalAmount: this.totalAmount, - paymentTypeId: this.paymentTypeId || null, - note: this.note || null - }; - - this.service.collectSharePayment(payload).subscribe({ - next: (result: any) => { - this.collectionResult = result; - this.snackBar.open('Share collection posted successfully.', 'Close', { duration: 3000 }); - this.pageState = 'success'; - this.saving = false; - }, - error: (err: any) => { - const msg = - err?.error?.errors?.[0]?.defaultUserMessage || - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - 'Collection failed. Please try again.'; - this.snackBar.open(msg, 'Close', { duration: 8000 }); - this.saving = false; - } - }); - } - - reset(): void { - this.pageState = 'idle'; - this.accountInput = ''; - this.account = null; - this.charges = []; - this.errorMsg = ''; - this.note = ''; - this.paymentTypeId = null; - this.requestedSharesInput = 0; - this.pricePerShare = 0; - this.collectionResult = null; - this.saving = false; - this.clearDenominations(); - } -} diff --git a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts index 89206900c..0c232ba68 100644 --- a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts +++ b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts @@ -8,7 +8,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -35,6 +35,35 @@ export interface CashierIssueDdDialogData { accountNo?: string; /** The account holder, for the same reason. */ accountHolderName?: string; + /** + * How many drafts this slip raises. Absent or 1 is the ordinary single draft and the dialog is + * unchanged; 2 or more turns it into a slip, asking for a face value, charge and beneficiary per + * draft and reporting the combined total the account will be debited for. + */ + ddCount?: number; + /** + * Whether the funding account is a GL account rather than a member's. Only affects wording: a GL + * account has no passbook, so it is not promised one. + */ + glFunded?: boolean; + /** + * The leaflets provisionally shown against each line of a slip. + * + * Only ever indicative. The server allocates the real numbers inside the locked transaction that + * submits the slip, so what is shown here is the next-available run at the moment the dialog was + * opened — accurate unless another clerk submits first, which is precisely why the numbers cannot + * be promised until submit returns them. + */ + previewDdNumbers?: string[]; +} + +/** One captured draft on a multi-DD slip. */ +export interface DdSlipLineResult { + beneficiaryName: string; + payableAtBranch: string; + payableAtCity: string; + faceValue: number; + chargesAmount: number; } @Component({ @@ -56,10 +85,20 @@ export interface CashierIssueDdDialogData {
receipt_long
-

{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}

+

+ @if (isSlip) { + Multi-DD Slip · {{ lineCount }} Demand Drafts + } @else { + {{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }} + } +

- Next available DD leaflet auto-selected · - {{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }} + @if (isSlip) { + One debit funds all {{ lineCount }} drafts · Enter each draft's beneficiary and amount + } @else { + Next available DD leaflet auto-selected · + {{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }} + }

@@ -69,41 +108,91 @@ export interface CashierIssueDdDialogData {
- -
- + @if (isSlip) { +
+
+ Drafts On This Slip + {{ lineCount }} + {{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }} +
+
+
+ Total Face Value + ₹{{ slipFaceValue | number: '1.2-2' }} + + Charges ₹{{ slipCharges | number: '1.2-2' }} +
+
+
+ GST ({{ data.gstPercentage }}%) + ₹{{ slipGst | number: '1.2-2' }} + on charges only +
+
+
+ Total To Debit + ₹{{ slipTotal | number: '1.2-2' }} + {{ data.accountNo || 'Account' }} · one debit +
+
+ + +
+ info +
+ DD numbers below are provisional. The final numbers are allocated when the slip is + submitted and will be confirmed on the receipt. +
+
+ } @else { + +
+ -
- DD Number Being Issued - {{ data.leaflet.ddNumber }} - {{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }} +
+ DD Number Being Issued + {{ data.leaflet.ddNumber }} + {{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }} +
+
+
+ Face Value + ₹{{ data.faceValue | number: '1.2-2' }} + + Charges ₹{{ data.chargesAmount | number: '1.2-2' }} +
+
+
+ GST ({{ data.gstPercentage }}%) + ₹{{ data.gstAmount | number: '1.2-2' }} + on charges only +
+
+
+ {{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }} + ₹{{ data.totalCashPayable | number: '1.2-2' }} + + @if (isAccountDebit) { + {{ data.accountNo || 'Account' }} + } @else { + Denominations verified + } + +
-
-
- Face Value - ₹{{ data.faceValue | number: '1.2-2' }} - + Charges ₹{{ data.chargesAmount | number: '1.2-2' }} -
-
-
- GST ({{ data.gstPercentage }}%) - ₹{{ data.gstAmount | number: '1.2-2' }} - on charges only -
-
-
- {{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }} - ₹{{ data.totalCashPayable | number: '1.2-2' }} - - @if (isAccountDebit) { - {{ data.accountNo || 'Account' }} - } @else { - Denominations verified - } - -
-
+ } + @if (isGlFunded) { + one journal entry against this GL account, not {{ lineCount }}. + } @else { + the member's passbook shows one entry, not {{ lineCount }}. + } + } @else { + ₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker + approves. No cash is taken at the counter. + }
} -
- + @if (isSlip) { + +
+ @for (lineCtrl of slipLines.controls; track $index) { +
+
+ DD {{ $index + 1 }} of {{ lineCount }} + @if (previewNumberAt($index)) { + No. {{ previewNumberAt($index) }} (provisional) + } + Debit ₹{{ lineTotalAt($index) | number: '1.2-2' }} +
+ + + Beneficiary Name (In Favour Of) * + + Beneficiary name is required + Max 120 characters + + +
+ + Payable At - Branch * + + Payable Branch is required + + + + Payable At - City * + + Payable City is required + +
+ +
+ + Face Value * + + ₹  + Face value is required + Face value must be greater than zero + + + + Charges + + ₹  + GST ₹{{ lineGstAt($index) | number: '1.2-2' }} at {{ data.gstPercentage }}% + Charges cannot be negative + +
+
+ } +
+
+ } @else { +
+ - @if (!isAccountDebit) { -
Walk-in Purchaser Details
+ @if (!isAccountDebit) { +
Walk-in Purchaser Details
+ +
+ + Walk-in Purchaser Name * + + Purchaser name is required + Must be at least 3 characters + + + + 12-Digit Aadhaar Number * + + Masked to last 4 digits before storage + Aadhaar number is required + Must be exactly 12 numeric digits + +
+ + + Purchaser Address / Location * + + Address is required + + } + + +
Beneficiary & Payment Details
+ + + Beneficiary Name (In Favour Of) * + + Beneficiary name is required + Max 120 characters +
- Walk-in Purchaser Name * + Payable At - Branch * - Purchaser name is required - Must be at least 3 charactersPayable Branch is required - 12-Digit Aadhaar Number * + Payable At - City * - Masked to last 4 digits before storage - Aadhaar number is required - Must be exactly 12 numeric digits + Payable City is required
- - - Purchaser Address / Location * - - Address is required - - } - - -
Beneficiary & Payment Details
- - - Beneficiary Name (In Favour Of) * - - Beneficiary name is required - Max 120 characters - - -
- - Payable At - Branch * - - Payable Branch is required - - - - Payable At - City * - - Payable City is required - -
-
+ + }
+ @if (isSlip) { + + {{ lineCount }} drafts · ₹{{ slipTotal | number: '1.2-2' }} to debit + + } - + `, @@ -393,6 +598,81 @@ export interface CashierIssueDdDialogData { line-height: 1.5; color: #64748b; } + /* One card per draft on a slip — the boundary is what stops a clerk typing line 2's + beneficiary against line 1's amount. */ + .dd-line-card { + border: 1px solid #e2e8f0; + border-left: 3px solid #0369a1; + border-radius: 8px; + padding: 14px 16px 4px; + margin-bottom: 14px; + background: #fcfdff; + } + .dd-line-head { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 12px; + } + .dd-line-badge { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #ffffff; + background: #0369a1; + padding: 3px 9px; + border-radius: 999px; + } + .dd-line-number { + font-family: monospace; + font-size: 12px; + color: #475569; + } + .dd-line-number em { + font-style: normal; + color: #94a3b8; + font-size: 11px; + } + .dd-line-total { + margin-left: auto; + font-size: 12px; + font-weight: 600; + color: #065f46; + background: #ecfdf5; + border: 1px solid #a7f3d0; + padding: 3px 10px; + border-radius: 6px; + } + .dd-preview-note { + display: flex; + gap: 8px; + align-items: flex-start; + margin: 0 0 16px; + padding: 9px 12px; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 6px; + font-size: 12px; + line-height: 1.5; + color: #78350f; + } + .dd-preview-note mat-icon { + color: #b45309; + font-size: 18px; + width: 18px; + height: 18px; + flex-shrink: 0; + } + .dd-actions-total { + margin-right: auto; + font-size: 13px; + color: #475569; + } + .dd-actions-total strong { + color: #065f46; + } ` ] }) @@ -402,13 +682,127 @@ export class CashierIssueDdDialogComponent implements OnInit { public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA); form!: FormGroup; + /** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */ + slipForm!: FormGroup; /** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */ get isAccountDebit(): boolean { return this.data.paymentMethod === 'ACCOUNT_DEBIT'; } + /** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */ + get lineCount(): number { + const n = Number(this.data.ddCount) || 1; + return n > 1 ? n : 1; + } + + /** Whether this dialog is capturing a multi-DD slip rather than a single draft. */ + get isSlip(): boolean { + return this.lineCount > 1; + } + + /** Whether the debit lands on a GL account rather than a member's — wording only. */ + get isGlFunded(): boolean { + return !!this.data.glFunded; + } + + /** The form the Save button validates against — whichever of the two was built. */ + get activeForm(): FormGroup { + return this.isSlip ? this.slipForm : this.form; + } + + get slipLines(): FormArray { + return this.slipForm.get('lines') as FormArray; + } + + /** The provisional leaflet number shown against a line, if the caller supplied a run of them. */ + previewNumberAt(index: number): string | null { + return this.data.previewDdNumbers?.[index] ?? null; + } + + private lineValueAt(index: number, field: string): number { + return Number(this.slipLines.at(index)?.get(field)?.value) || 0; + } + + /** + * GST on one line's charge. + * + * Rounded per line, matching the server, which computes and stores GST against each draft rather + * than once for the slip. Summing rounded lines and rounding a summed charge can differ by a + * paisa, and it is the per-line figure that ends up on the draft. + */ + lineGstAt(index: number): number { + const charges = this.lineValueAt(index, 'chargesAmount'); + if (charges <= 0) return 0; + return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100; + } + + /** What one line adds to the debit — its face value, its charge and the GST on that charge. */ + lineTotalAt(index: number): number { + return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index); + } + + get slipFaceValue(): number { + return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0); + } + + get slipCharges(): number { + return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0); + } + + get slipGst(): number { + return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0); + } + + /** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */ + get slipTotal(): number { + return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9); + } + ngOnInit(): void { + if (this.isSlip) { + this.slipForm = this.fb.group({ + lines: this.fb.array( + Array.from({ length: this.lineCount }, () => + this.fb.group({ + beneficiaryName: [ + '', + [ + Validators.required, + Validators.maxLength(120) + ] + ], + payableAtBranch: [ + '', + [Validators.required] + ], + payableAtCity: [ + '', + [Validators.required] + ], + // Zero is rejected rather than merely warned about: the server refuses a line with a + // face value at or below zero, and a slip that fails there has already cost the clerk + // every other line they typed. + faceValue: [ + null as + | number + | null, + [ + Validators.required, + Validators.min(0.01) + ] + ], + chargesAmount: [ + 0, + [Validators.min(0)] + ] + }) + ) + ) + }); + return; + } + // Walk-in identity is required for cash and rejected outright for an account debit — the server // refuses a walk-in name alongside an account — so the controls are only built when they apply. // Registering them unconditionally and merely hiding them would hold the form invalid on a @@ -475,7 +869,25 @@ export class CashierIssueDdDialogComponent implements OnInit { } onSubmit(): void { - if (this.form.invalid) return; + if (this.activeForm.invalid) return; + + // A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on + // a member-account debit, where the purchaser is the account holder already on record. + if (this.isSlip) { + const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => { + const v = c.value; + return { + beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(), + payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(), + payableAtCity: (v.payableAtCity || '').trim().toUpperCase(), + faceValue: Number(v.faceValue) || 0, + chargesAmount: Number(v.chargesAmount) || 0 + }; + }); + this.dialogRef.close({ lines }); + return; + } + const val = this.form.value; const beneficiary = { beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(), diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index de55bc0f0..586117016 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -166,23 +166,50 @@
- + @if (acTypLockedByToken && row === selectedRow) { + {{ acTypLockedLabel }} + } @else { + + }
+ @if (showsEntryType(row)) { +
+ + +
+ } @@ -206,14 +233,20 @@ ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number' " - [readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)" - [class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)" + [readonly]=" + (row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow) + " + [class.ws-f__ctl--readonly]=" + (row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow) + " [title]=" - isDdAccountDebitRow(row) - ? 'Member account the draft is funded from' - : row.typ === 'DD' - ? 'Configured Sponsor DD Payable GL (Auto-selected)' - : 'Account number' + acTypLockedByToken && row === selectedRow + ? 'Locked — filled from a token-driven request, not a normal account entry' + : isDdAccountDebitRow(row) + ? 'Member account the draft is funded from' + : row.typ === 'DD' + ? 'Configured Sponsor DD Payable GL (Auto-selected)' + : 'Account number' " /> @@ -286,9 +319,13 @@ [value]="row.amount ?? ''" (input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()" (click)="$event.stopPropagation()" - [placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'" - [readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)" - [class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)" + [placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'" + [readonly]=" + isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)) + " + [class.ws-amt-input--locked]=" + isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)) + " /> @if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) { @@ -298,6 +335,19 @@ }}/share } + + @if (isMultiDdRow(row)) { + + {{ + row.ddLines?.length + ? 'Total of ' + row.ddLines?.length + ' drafts' + : 'Set per draft in Fill DD Details' + }} + + } @if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
@@ -377,9 +427,36 @@
DD Charges & GST — Admin Rate {{ ddGstPercentage }}%
+ + @if (canRowBeMultiDd(row)) { +
+ +
+ +
+ @if (isMultiDdRow(row)) { + One debit · {{ ddCountFor(row) }} drafts + } +
+ }
-
+
₹
+ + @if (isMultiDdRow(row)) { + Set per draft + }
@@ -414,7 +498,7 @@
@@ -427,18 +511,27 @@ [disabled]="saving" (click)="openIssueWalkinDd(row); $event.stopPropagation()" [title]=" - isDdAccountDebitRow(row) - ? 'Auto-selects next available leaflet and opens the beneficiary form' - : 'Auto-selects next available leaflet and opens walk-in purchaser form' + isMultiDdRow(row) + ? 'Opens one beneficiary form per draft; DD numbers are allocated on submission' + : isDdAccountDebitRow(row) + ? 'Auto-selects next available leaflet and opens the beneficiary form' + : 'Auto-selects next available leaflet and opens walk-in purchaser form' " > {{ - row.ddDetails ? 'edit_note' : 'post_add' + row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add' }} + glance which rows are ready before pressing Save. A slip names the count + instead: its numbers are not allocated until it is submitted. --> {{ - row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details' + row.ddLines?.length + ? row.ddLines?.length + ' Drafts Captured · Edit Slip' + : row.ddDetails + ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' + : isMultiDdRow(row) + ? 'Fill ' + ddCountFor(row) + ' DD Details' + : 'Fill DD Details' }}
@@ -1523,23 +1616,50 @@
- + @if (acTypLockedByToken && row === selectedRow) { + {{ acTypLockedLabel }} + } @else { + + }
+ @if (showsEntryType(row)) { +
+ + +
+ }
@@ -1556,10 +1676,16 @@ [placeholder]=" row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number' " - [readonly]="row.typ === 'DD'" - [class.ws-f__ctl--readonly]="row.typ === 'DD'" + [readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)" + [class.ws-f__ctl--readonly]=" + row.typ === 'DD' || (acTypLockedByToken && row === selectedRow) + " [title]=" - row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number' + acTypLockedByToken && row === selectedRow + ? 'Locked — filled from a token-driven request, not a normal account entry' + : row.typ === 'DD' + ? 'Configured Sponsor DD Payable GL (Auto-selected)' + : 'Account number' " /> @@ -1628,10 +1754,12 @@ [value]="row.amount ?? ''" (input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()" (click)="$event.stopPropagation()" - [placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'" - [readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)" + [placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'" + [readonly]=" + isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)) + " [class.ws-amt-input--locked]=" - !isClerk && !!(activeWithdrawalRequest || activeClosureRequest) + isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)) " /> @@ -1642,6 +1770,19 @@ }}/share } + + @if (isMultiDdRow(row)) { + + {{ + row.ddLines?.length + ? 'Total of ' + row.ddLines?.length + ' drafts' + : 'Set per draft in Fill DD Details' + }} + + } @if ( row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer') @@ -1706,9 +1847,38 @@
DD Charges & GST — Admin Rate {{ ddGstPercentage }}%
+ + @if (canRowBeMultiDd(row)) { +
+ +
+ +
+ @if (isMultiDdRow(row)) { + One debit · {{ ddCountFor(row) }} drafts + } +
+ }
-
+
₹
+ + @if (isMultiDdRow(row)) { + Set per draft + }
@@ -1743,7 +1920,7 @@
@@ -1756,13 +1933,28 @@ [disabled]="saving" (click)="openIssueWalkinDd(row); $event.stopPropagation()" [title]=" - isDdAccountDebitRow(row) - ? 'Auto-selects next available leaflet and opens the beneficiary form' - : 'Auto-selects next available leaflet and opens walk-in purchaser form' + isMultiDdRow(row) + ? 'Opens one beneficiary form per draft; DD numbers are allocated on submission' + : isDdAccountDebitRow(row) + ? 'Auto-selects next available leaflet and opens the beneficiary form' + : 'Auto-selects next available leaflet and opens walk-in purchaser form' " > - post_add - Fill DD Details + {{ + row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add' + }} + + {{ + row.ddLines?.length + ? row.ddLines?.length + ' Drafts Captured · Edit Slip' + : row.ddDetails + ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' + : isMultiDdRow(row) + ? 'Fill ' + ddCountFor(row) + ' DD Details' + : 'Fill DD Details' + }}
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss index 36cd56460..06858c598 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.scss +++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss @@ -916,6 +916,20 @@ input.ws-f__ctl--readonly { font-size: 11px; } +/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled + from a token-driven request whose real account type row.acTyp cannot show (see + acTypLockedByToken in the component). */ +span.ws-f__ctl--locked { + display: inline-flex; + align-items: center; + background: #f1f5f9; + color: #475569; + cursor: not-allowed; + user-select: none; + font-size: 11px; + font-weight: 600; +} + input.ws-amt-input--locked { background: #fef9c3; color: #713f12; diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 2ea622557..aa4c8ca6f 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -53,6 +53,19 @@ interface TxnRow { /** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */ ddCharges: number | null; sponsorBankId?: number | null; + /** + * What a GL credit is actually for — the id of a code value under "GLEntryType" (General, Share + * Collection, DD Issue, or whatever an admin has added under Admin > System > Manage Codes). + * + * A cashier Receipt (or a clerk Collection) into a GL account is the counter picking, by hand, + * any account to post cash into — deliberately, so a broken product mapping can never block a + * genuine voucher. That freedom is also the hole: two unrelated flows can land in the same GL + * account with nothing on the row itself saying which one the money was collected for, and a + * manager applying a share request can pick up a voucher that was really meant to fund a DD. This + * tag is what a day-end reconciliation groups by, and what Share Advance Collection / Sponsor DD + * issuance check before adopting a row — only shown/sent for AC.TP=GL, DR./CR.=Cr rows. + */ + entryTypeCvId?: number | null; /** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings * account — governs the share redemption (see submitShareRedemptionTransfer()). */ resolutionNumber?: string; @@ -78,6 +91,40 @@ interface TxnRow { fundingAccountId?: number; fundingClientId?: number; }; + /** + * How many drafts this one row raises. 1 (or unset) is the ordinary single draft; 2 or more make + * the row a multi-DD slip — one debit funding several drafts, each to its own beneficiary. + * + * Typed by the clerk BEFORE opening the dialog, so the dialog knows how many beneficiary forms to + * present. Only a member-account debit can be a slip (see canRowBeMultiDd()). + */ + ddCount?: number | null; + /** + * The per-draft lines of a multi-DD slip, captured in the dialog and held here until Save — the + * same "fill now, commit on Save" contract as ddDetails above. + * + * Each line carries its OWN face value and charges: a slip's drafts are routinely for different + * amounts to different beneficiaries, and the charge is per draft rather than per slip. That is + * why the row's own AMOUNT and CHARGES fields are locked and derived from these lines while a + * slip is being built — a clerk-typed row total could disagree with the lines, and the account + * would then be debited for a figure the drafts don't add up to. + */ + ddLines?: DdSlipLine[]; + /** The inventory the slip's leaflets are drawn from. The batch endpoint allocates by inventory + * rather than by leaflet, since the numbers are only picked inside its own locked transaction. */ + ddInventoryId?: number; + /** Resolved funding account for a slip, captured alongside the lines for the same reason. */ + ddFundingAccountId?: number; + ddFundingClientId?: number; +} + +/** One draft on a multi-DD slip — its own beneficiary, its own face value, its own charges. */ +interface DdSlipLine { + beneficiaryName: string; + payableAtBranch: string; + payableAtCity: string; + faceValue: number; + chargesAmount: number; } interface Denomination { @@ -1703,15 +1750,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { /** * Account types a clerk may fund a demand draft from. * - * A clerk raises a draft against a member's own balance, so the only sensible sources are the - * accounts a member actually holds money in. GL was offered here too, and picking it quietly - * switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a - * note-and-coin count, neither of which a clerk debiting an account has. The option is removed - * rather than validated afterwards, so the wrong path cannot be reached at all. + * A clerk raises a draft against a member's own balance (SD/CA) or, now, against a GL account + * directly for an institutional/holding-account draft (see isDdAccountDebitRow()) — both are + * governed, maker-checker submissions. GL used to be offered here too in a different sense: picking + * it quietly switched the row onto the CASHIER's walk-in cash path instead (the one that asks for a + * purchaser's name and a note-and-coin count), which isDdAccountDebitRow() now disambiguates by + * role — GL means "debit this GL account" for a clerk, and still means "cash/GL purchase" for a + * cashier, who never reaches this list at all (isDdAccountDebitRow() is clerk-only). */ private static readonly DD_CLERK_ACTYPS = [ 'SD', - 'CA' + 'CA', + 'GL' ]; /** @@ -1726,6 +1776,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } return this.acTypOptions; } + + /** True while the selected row is filled from a token-driven request whose row.acTyp is a + * technical stand-in rather than the account's real type — a dividend claim or an account closure + * both set row.acTyp = 'SD' purely to route past the SHR-specific guards elsewhere on this screen + * (see onTokNoBlur()), not because the counter is paying against a savings account. The AC.TP + * dropdown must not be left editable there: it already shows the wrong value, and a cashier + * changing it further would desync the row from the request it was auto-filled from. A plain + * withdrawal-slip token is deliberately excluded — its row.acTyp is resolved to the account's real + * type there, so the normal editable control already shows it correctly and there's nothing to + * lock. */ + get acTypLockedByToken(): boolean { + return !!(this.activeDividendClaimRequest || this.activeClosureRequest); + } + + /** What to show in place of the AC.TP dropdown while it's locked — the same label the account + * summary strip above already uses for each of these token pools, so the two never contradict + * each other on screen. */ + get acTypLockedLabel(): string { + if (this.activeClosureRequest) { + return 'Closure'; + } + if (this.activeDividendClaimRequest) { + return 'Dividend'; + } + return ''; + } + dbCrOptions = [ 'Dr', 'Cr' @@ -1834,6 +1911,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.loadCashVaultGl(); this.loadDdGstPercentage(); this.loadSponsorBanks(); + this.cashierService + .getEntryTypeOptions() + .pipe(catchError(() => of([]))) + .subscribe((options: any[]) => { + this.entryTypeOptions = options ?? []; + }); if (this.reviewMode) { this.loadRequestIntoForm(this.reviewRequestId!, 'review'); @@ -2321,14 +2404,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } /** - * TYPE=DD funded by debiting a member's savings/current account rather than by cash. + * TYPE=DD funded by debiting a member's savings/current account, or a GL account directly, rather + * than by cash. * - * Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit - * would let a draft be raised against an account with no cash to reconcile it against — and the - * clerk flow is the one that carries a maker-checker step over the member's balance. + * Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account/GL debit + * would let a draft be raised with no cash to reconcile it against — and the clerk flow is the one + * that carries a maker-checker step over the money moved. AC.TP = GL means something different for + * a cashier's own DD row (a walk-in cash/GL purchase, see isDdCashGlRow()) — this method is checked + * first there specifically so a clerk's GL row is claimed here instead. */ isDdAccountDebitRow(row: TxnRow): boolean { - return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB'); + return ( + this.isClerk && + row.typ === 'DD' && + (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL') + ); } /** @@ -2342,19 +2432,176 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row); } - /** GST on DD charges only (never on the DD face value), at the admin-configured rate. */ + /** + * Whether this row needs an Entry Type tag — a credit into a GL account, which is exactly the + * case where the money's real purpose isn't otherwise recorded anywhere (see TxnRow.entryTypeCvId). + * A debit out of a GL account, or any entry against a savings/current/share account, carries no + * such ambiguity — the account itself says what it's for. + */ + showsEntryType(row: TxnRow): boolean { + return row.acTyp === 'GL' && row.dbCr === 'Cr'; + } + + /** + * Entry Type choices for a GL credit — the "GLEntryType" code (Admin > System > Manage Codes), + * fetched once in ngOnInit(). A code value, not a free-text field, so the day-end "group by type" + * this exists for stays reliable; an admin extends the list there, not in this file. + */ + entryTypeOptions: { id: number; name: string }[] = []; + + /** + * Whether this row may raise more than one draft off a single debit. + * + * Any funding source the clerk debits: a member's savings/current account, or a GL account for an + * institutional slip. Cash is the exception the batch endpoint still refuses, and for a real + * reason rather than an arbitrary one — a slip is funded by one payment, and one counted pile of + * notes cannot be apportioned across drafts without the denomination total ceasing to prove what + * was actually taken at the counter. + */ + canRowBeMultiDd(row: TxnRow): boolean { + return this.isDdAccountDebitRow(row); + } + + /** Whether the clerk has asked this row for more than one draft. */ + isMultiDdRow(row: TxnRow): boolean { + return this.canRowBeMultiDd(row) && (Number(row.ddCount) || 1) > 1; + } + + /** How many drafts this row raises — always at least 1. */ + ddCountFor(row: TxnRow): number { + const n = Number(row.ddCount) || 1; + return n > 1 ? n : 1; + } + + /** + * Clamps a typed DD count into range and discards lines that no longer fit. + * + * Lowering the count after the dialog has been filled would otherwise leave lines beyond the new + * count still on the row: invisible to the clerk, but still sent at Save and still debited for. + */ + onDdCountChange(row: TxnRow, value: number): void { + const n = Math.floor(Number(value) || 1); + const previous = this.ddCountFor(row); + row.ddCount = n < 1 ? 1 : n > this.maxDdSlipLines ? this.maxDdSlipLines : n; + if (row.ddLines && row.ddLines.length !== row.ddCount) { + this.clearDdCapture(row); + } + // Moving onto a slip empties the row's own amount and charge, because on a slip they mean + // nothing: each draft carries its own face value and its own charge, entered in the dialog, and + // these two fields become the total of those. Leaving a previously typed figure sitting here + // would read as the slip's total while describing only one draft — which is exactly what showed + // ₹1,012 against four drafts of ₹1,000. + if (row.ddCount > 1 && previous <= 1) { + row.amount = null; + row.ddCharges = null; + } + } + + /** Mirrors MAX_SLIP_LINES on the server, so an over-long slip is refused here with a clear message. */ + readonly maxDdSlipLines = 20; + + /** + * The run of leaflet numbers a slip will probably draw, for display only. + * + * Counts up from the next-available number, preserving its width so 000123 reads as 000124 rather + * than 124. Indicative by nature — the binding allocation happens inside the server's submit + * transaction, and a slip filed after another clerk's gets the numbers following theirs — which is + * why the dialog labels these provisional and the confirmed numbers come back on submission. + * + * A number that is not a plain run of digits (a prefixed or hyphenated series) cannot be counted + * on safely, so nothing is shown rather than something wrong. + */ + private previewDdNumbersFrom(firstDdNumber: string, count: number): string[] | undefined { + const first = (firstDdNumber || '').trim(); + if (!/^\d+$/.test(first)) return undefined; + const width = first.length; + const start = Number(first); + if (!Number.isSafeInteger(start)) return undefined; + return Array.from({ length: count }, (_v, i) => String(start + i).padStart(width, '0')); + } + + /** GST on a single charge figure, at the admin-configured rate. */ + private ddGstOn(charges: number): number { + const c = Number(charges) || 0; + if (c <= 0) return 0; + return Math.round(c * this.ddGstPercentage) / 100; + } + + /** + * GST on DD charges only (never on the DD face value), at the admin-configured rate. + * + * On a slip this is the sum of each line's own GST rather than GST on the summed charges. The two + * can differ by a paisa once rounding is applied per line, and it is the per-line figure that is + * stored against each draft and posted by the server — so the row must show that same total. + */ ddGstAmountFor(row: TxnRow): number { - const charges = Number(row.ddCharges) || 0; - if (charges <= 0) return 0; - return Math.round(charges * this.ddGstPercentage) / 100; + if (row.ddLines?.length) { + return row.ddLines.reduce((sum, l) => sum + this.ddGstOn(l.chargesAmount), 0); + } + return this.ddGstOn(row.ddCharges ?? 0); + } + + /** Face value of the row — the sum of every line once a slip has been captured. */ + ddFaceValueFor(row: TxnRow): number { + if (row.ddLines?.length) { + return row.ddLines.reduce((sum, l) => sum + (Number(l.faceValue) || 0), 0); + } + return Number(row.amount) || 0; + } + + /** Charges on the row — the sum of every line's own charge once a slip has been captured. */ + ddChargesFor(row: TxnRow): number { + if (row.ddLines?.length) { + return row.ddLines.reduce((sum, l) => sum + (Number(l.chargesAmount) || 0), 0); + } + return Number(row.ddCharges) || 0; } /** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */ ddTotalPayableFor(row: TxnRow): number { - const amount = Number(row.amount) || 0; - const charges = Number(row.ddCharges) || 0; - const gst = this.ddGstAmountFor(row); - return Math.ceil(amount + charges + gst - 1e-9); + return Math.ceil(this.ddFaceValueFor(row) + this.ddChargesFor(row) + this.ddGstAmountFor(row) - 1e-9); + } + + /** + * What the TOTAL field shows — a figure, or a dash while a slip has nothing to total yet. + * + * A slip whose drafts have not been entered has no total: its face values and charges live on the + * lines, and there are no lines. Showing ₹0 there would read as a real total of zero rather than + * as "not yet known", so the field says so instead. + */ + ddTotalDisplayFor(row: TxnRow): string { + if (this.isSlipAwaitingDetails(row)) { + return '—'; + } + return String(this.ddTotalPayableFor(row)); + } + + /** GST field's text, dashed for the same reason as the total while a slip is still empty. */ + ddGstDisplayFor(row: TxnRow): string { + if (this.isSlipAwaitingDetails(row)) { + return '—'; + } + return this.ddGstAmountFor(row).toFixed(2); + } + + /** A slip the clerk has asked for but not yet filled in — nothing to total from. */ + isSlipAwaitingDetails(row: TxnRow): boolean { + return this.isMultiDdRow(row) && !row.ddLines?.length; + } + + /** + * Forgets a row's captured draft details, leaving the row itself intact. + * + * Both shapes are cleared together on purpose: a row carrying a stale ddDetails alongside fresh + * ddLines (or the reverse) would be read by whichever of the two Save paths it reached first, and + * the clerk would have no way of seeing which one went. + */ + private clearDdCapture(row: TxnRow): void { + row.ddDetails = undefined; + row.ddLines = undefined; + row.ddInventoryId = undefined; + row.ddFundingAccountId = undefined; + row.ddFundingClientId = undefined; } get isSelectedRowDdCashGl(): boolean { @@ -2395,11 +2642,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ]); return; } + // On a slip the face value lives on each line, typed in the dialog that is about to open — the + // row's own AMOUNT is the sum of those lines and cannot exist yet. Demanding it here would make + // a slip impossible to start. + const isSlip = this.isMultiDdRow(row); const faceValue = Number(row.amount); - if (!faceValue || faceValue <= 0) { + if (!isSlip && (!faceValue || faceValue <= 0)) { this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']); return; } + if (isSlip && !this.isDdAccountDebitRow(row)) { + this.showAlert('error', 'Multiple DDs Not Available', [ + 'More than one draft can only be raised against a member savings or current account.', + 'Set NO. OF DD back to 1 for this row.' + ]); + return; + } const chargesAmount = Number(row.ddCharges) || 0; const gstAmount = this.ddGstAmountFor(row); const totalCashPayable = this.ddTotalPayableFor(row); @@ -2408,19 +2666,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // An account-funded draft needs the account resolved to a Fineract id before it can be sent: // the server takes a savings/current account id and a client id, not the typed number. Both are // filled in by the A/C NO. lookup, so an unresolved account means the number was never found. + // A GL-funded draft is the exception: a GL account is never tied to a client at all (see + // fetchAllDetails() — GL rows never populate rowClientIds), so only the account id is required. + const isGlDebit = accountDebit && row.acTyp === 'GL'; let fundingAccountId: number | null = null; let fundingClientId: number | null = null; if (accountDebit) { const acNo = (row.acNo || '').trim(); if (!acNo) { this.showAlert('error', 'Account Required', [ - 'Enter the account number the Demand Draft is funded from.' + isGlDebit + ? 'Enter the GL account the Demand Draft is funded from.' + : 'Enter the account number the Demand Draft is funded from.' ]); return; } fundingAccountId = this.rowAccountIds[acNo] ?? null; - fundingClientId = this.rowClientIds[acNo] ?? null; - if (!fundingAccountId || !fundingClientId) { + fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null); + if (!fundingAccountId || (!isGlDebit && !fundingClientId)) { this.showAlert('error', 'Account Not Found', [ `Account ${acNo} could not be resolved.`, 'Check the account number and wait for its details to load before raising the draft.' @@ -2430,8 +2693,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } // Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a - // denomination count left over from an earlier row must not be checked against it. - if (!accountDebit && this.denominationTotal > 0 && Math.abs(this.denominationTotal - totalCashPayable) > 0.005) { + // denomination count left over from an earlier row must not be checked against it. A slip is + // always an account debit, and its total is not known until the dialog is filled in either. + if ( + !accountDebit && + !isSlip && + this.denominationTotal > 0 && + Math.abs(this.denominationTotal - totalCashPayable) > 0.005 + ) { this.showAlert('error', 'Denomination Mismatch', [ `Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`, 'Please verify and match the physical cash denomination count.' @@ -2454,8 +2723,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId); const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br; + // A slip is allocated by inventory, not leaflet: the server picks the whole run inside its + // own locked transaction. Without the inventory behind this branch's next-available leaflet + // there is nothing to allocate from, so the slip is refused here rather than at submit. + if (isSlip && !leaflet.inventoryId) { + this.showAlert('error', 'DD Inventory Not Identified', [ + 'The DD inventory behind this branch and sponsor bank could not be identified, so multiple drafts cannot be allocated together.', + 'Set NO. OF DD back to 1, or ask an administrator to check the DD inventory registration.' + ]); + return; + } + const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, { - width: '600px', + width: isSlip ? '760px' : '600px', disableClose: true, data: { leaflet, @@ -2468,7 +2748,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { totalCashPayable, paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const), accountNo: accountDebit ? row.acNo : undefined, - accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined + accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined, + ddCount: isSlip ? this.ddCountFor(row) : undefined, + glFunded: isGlDebit, + previewDdNumbers: isSlip ? this.previewDdNumbersFrom(leaflet.ddNumber, this.ddCountFor(row)) : undefined } }); @@ -2477,6 +2760,31 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // review the entered details, and no way to abandon them either. dialogRef.afterClosed().subscribe((result) => { if (!result) return; + + // A slip records its lines instead of a single beneficiary, and takes the row's AMOUNT and + // CHARGES with it: from here on those two are the sum of the lines, shown read-only, so a + // clerk cannot leave a row total behind that disagrees with what the drafts add up to. + if (result.lines) { + this.clearDdCapture(row); + row.ddLines = result.lines; + row.ddInventoryId = leaflet.inventoryId; + row.ddFundingAccountId = fundingAccountId!; + // Null on a GL slip, which is raised against no member at all. + row.ddFundingClientId = fundingClientId ?? undefined; + row.amount = this.ddFaceValueFor(row); + row.ddCharges = this.ddChargesFor(row); + this.showAlert('success', 'Slip Details Captured', [ + `${result.lines.length} demand drafts captured in favour of ${result.lines + .map((l: DdSlipLine) => l.beneficiaryName) + .join(', ')}.`, + `₹${this.ddTotalPayableFor(row)} will be debited from ${isGlDebit ? 'GL account ' : ''}${row.acNo} as a single ${ + isGlDebit ? 'journal entry' : 'transaction' + } once a checker approves.`, + 'DD numbers are allocated on submission. Press Save to send this slip for approval.' + ]); + return; + } + row.ddDetails = { leafletId: leaflet.id, ddNumber: leaflet.ddNumber, @@ -2510,29 +2818,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } /** - * Raises a Demand Draft funded by debiting a member's savings or current account. + * Raises a Demand Draft funded by debiting a member's savings/current account, or a GL account + * directly. * - * Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's - * account is only touched when a checker approves, which is also when the balance is re-checked — - * so a draft raised against a sufficient balance can still be refused if the balance has since - * gone. No denominations are sent, because no cash is taken. + * Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The + * account/GL is only touched when a checker approves, which for a member account is also when the + * balance is re-checked — so a draft raised against a sufficient balance can still be refused if + * the balance has since gone (a GL account carries no such balance check). No denominations are + * sent, because no cash is taken. * - * The account goes in savingsAccountId or currentAccountId, never both: the server insists on - * exactly one, since which table the account lives in decides which control GL is debited. + * The funding account goes in exactly one of savingsAccountId, currentAccountId or glAccountId: the + * server insists on exactly one, since which one decides what gets debited on approval. */ private submitAccountDebitDd( row: TxnRow, leaflet: any, beneficiaryData: any, fundingAccountId: number, - fundingClientId: number + fundingClientId: number | null ): void { const d = new Date(); const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB'; + const isGl = row.acTyp === 'GL'; const payload = { - clientId: fundingClientId, + clientId: isGl ? undefined : (fundingClientId ?? undefined), beneficiaryName: beneficiaryData.beneficiaryName, payableAtBranch: beneficiaryData.payableAtBranch, payableAtCity: beneficiaryData.payableAtCity, @@ -2540,9 +2851,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { chargesAmount: Number(row.ddCharges) || 0, issuingBranchId: row.br, issuedDate, - paymentMethod: 'ACCOUNT_DEBIT' as const, - savingsAccountId: isCurrent ? undefined : fundingAccountId, - currentAccountId: isCurrent ? fundingAccountId : undefined + paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT', + savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId, + currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId, + glAccountId: isGl ? fundingAccountId : undefined }; this.saving = true; @@ -2576,7 +2888,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { * got none has no way of telling which row was missing. */ private saveDdRows(ddRows: TxnRow[]): void { - const unfilled = ddRows.filter((r) => !r.ddDetails); + const unfilled = ddRows.filter((r) => !r.ddDetails && !r.ddLines?.length); if (unfilled.length > 0) { this.showAlert('error', 'DD Details Not Entered', [ unfilled.length === 1 @@ -2588,6 +2900,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } for (const row of ddRows) { + // A slip goes to the batch endpoint, which allocates its leaflets and posts one debit for the + // whole run. Sending its lines one at a time through the single-DD endpoint would debit the + // member once per draft — the very thing a slip exists to avoid. + if (row.ddLines?.length) { + this.submitDdSlip(row); + continue; + } + const details = row.ddDetails!; const leaflet = { id: details.leafletId, ddNumber: details.ddNumber }; if (this.isDdAccountDebitRow(row)) { @@ -2615,6 +2935,99 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } } + /** + * Submits a multi-DD slip: several drafts raised together against one debit of the member's + * account. + * + * Maker-checker like the single-draft flow, and nothing is debited here. On approval the server + * withdraws the combined face value, charges and GST of every line as ONE transaction and posts + * one journal entry against it, then issues each draft — so the member's passbook carries a single + * entry naming all the DD numbers rather than one entry per draft. The checker approves or rejects + * the slip whole; there is no approving four drafts out of five. + * + * The leaflets are not chosen here. The server allocates the run inside its own locked transaction + * and returns which number went to which line, which is the only point at which the numbers are + * real — two clerks submitting at the same moment can therefore never be handed the same one. + */ + private submitDdSlip(row: TxnRow): void { + const lines = row.ddLines ?? []; + const inventoryId = row.ddInventoryId; + const fundingAccountId = row.ddFundingAccountId; + const fundingClientId = row.ddFundingClientId; + + const isGl = row.acTyp === 'GL'; + + // Re-checked at Save, not just when the details were captured: the row's TYPE, AC.TP or account + // number can all be edited afterwards, and a slip that has drifted onto a cash row is one the + // batch endpoint refuses outright. A GL slip is raised against no member, so it carries no + // client id and none is required of it — the same asymmetry as the single-draft GL path. + if (!inventoryId || !fundingAccountId || (!isGl && !fundingClientId) || !this.canRowBeMultiDd(row)) { + this.showAlert('error', 'Slip Cannot Be Submitted', [ + 'This row has changed since its drafts were captured, so the slip no longer matches it.', + 'Open "Fill DD Details" again and re-enter the drafts.' + ]); + return; + } + + const d = new Date(); + const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB'; + + // The funding source goes in exactly one of savingsAccountId, currentAccountId or glAccountId, + // as the single-draft path does: which one is set is what decides the debit leg on approval. + const payload = { + clientId: isGl ? undefined : fundingClientId, + issuingBranchId: row.br, + issuedDate, + paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT', + savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId, + currentAccountId: !isGl && isCurrent ? fundingAccountId : undefined, + glAccountId: isGl ? fundingAccountId : undefined, + lines: lines.map((l) => ({ + beneficiaryName: l.beneficiaryName, + payableAtBranch: l.payableAtBranch, + payableAtCity: l.payableAtCity, + faceValue: Number(l.faceValue), + chargesAmount: Number(l.chargesAmount) || 0 + })) + }; + + const totalDebited = this.ddTotalPayableFor(row); + const accountNo = row.acNo; + + this.saving = true; + this.sponsorDDService.submitIssueRequestBatch(inventoryId, payload).subscribe({ + next: (result: any) => { + this.saving = false; + // The allocated numbers, named back to the clerk. Until this response they were provisional, + // and these are what the customer's drafts will actually carry. + const allocated = (result?.lines ?? []) + .map((l: any) => `${l.ddNumber} · ${l.beneficiaryName} · ₹${l.faceValue}`) + .join('\n'); + this.showAlert('success', 'DD Slip Submitted', [ + `Slip of ${lines.length} demand drafts submitted successfully.`, + allocated || `${lines.length} drafts allocated.`, + `₹${totalDebited} will be debited from ${accountNo} as a single transaction on approval.`, + 'Sent to Manager / Head Cashier for maker-checker approval of the whole slip.' + ]); + row.amount = null; + row.ddCharges = null; + row.description = ''; + row.ddCount = 1; + // Cleared with the row, as the single-draft paths do: leaving the lines behind would let a + // second Save raise the same slip again against leaflets already allocated. + this.clearDdCapture(row); + }, + error: (err) => { + this.saving = false; + this.showAlert('error', 'DD Slip Submission Failed', [ + err.error?.message || err.message, + 'No drafts were issued and nothing has been debited — the whole slip was refused together.' + ]); + } + }); + } + private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void { // Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked // right but matched nothing, so every cash DD went in with no breakdown at all and its money @@ -2802,7 +3215,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Without this the backend defaults every request from this screen to DISBURSEMENT — a // Collection or Fund Transfer submitted here was being saved (and shown in the checker's // inbox) as a plain Disbursement. - requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT' + requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT', + // Same ambiguity as a cashier Receipt — a Collection credits a GL account picked by hand. + // See TxnRow.entryTypeCvId. + ...(isCollectionMode ? { entryTypeCvId: crRows[0]?.entryTypeCvId || null } : {}) }; this.saving = true; @@ -3044,6 +3460,168 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ); } + /** + * Share redemption settled to a GL account the clerk picks on the CR row, instead of the + * member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the + * credit side is any GL account (e.g. a "DD Payable" holding account), not a fixed linked + * account, so there is no "must match the linked account" check here. + */ + private submitShareGlSettlementTransfer(shareRow: TxnRow, glRow: TxnRow): void { + // Belt-and-braces: the TXN TP. dropdown already hides cash payment types for AC.TP = SHR (see + // paymentTypesForRow()), but re-check here too in case the payment type was set before that + // filter applied. + const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === glRow.paymentTypeId); + if (!glRow.paymentTypeId) { + this.showAlert('error', 'Settlement Payment Type Required', [ + 'Select a Payment Type on the CR (GL account) row — it governs how the settlement credit posts.' + ]); + return; + } + if (settlementPaymentType?.isCashPayment) { + this.showAlert('error', 'Cash Not Allowed for Share Transactions', [ + 'Share redemption settlement is always a book-transfer, never cash.', + 'Select Transfer as the Payment Type on the CR row.' + ]); + return; + } + + const shareAccountId = this.rowAccountIds[shareRow.acNo]; + if (!shareAccountId) { + this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]); + return; + } + const settlementGlAccountId = this.rowAccountIds[glRow.acNo]; + if (!settlementGlAccountId) { + this.showAlert('error', 'GL Account Not Loaded', [`Please load account details for: ${glRow.acNo}`]); + return; + } + + const shares = Number(shareRow.amount) || 0; + if (shares <= 0) { + this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']); + return; + } + if (!shareRow.resolutionNumber?.trim()) { + this.showAlert('error', 'Board Resolution Number Required', [ + 'A share withdrawal must be authorised by a Board Resolution.' + ]); + return; + } + if (!shareRow.meetingDate) { + this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']); + return; + } + + const today = new Date(); + const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + // Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim + // for audit purposes, same as the linked-savings path above. + const settlementRowSnapshot = { + branch: glRow.br, + accountType: glRow.acTyp, + accountNo: glRow.acNo, + ecsAccountNo: glRow.ecsAcNo || null, + dbCr: glRow.dbCr, + amount: glRow.amount ?? null, + chequeNo: glRow.chequeNo || null, + chequeDate: glRow.cheqDate || null, + tokenNo: glRow.tokNo || null, + description: glRow.description || null, + bankName: glRow.bankName || null, + routingCode: glRow.routingCode || null, + paymentTypeId: glRow.paymentTypeId, + paymentTypeName: settlementPaymentType?.name ?? null + }; + + const payload = { + shareAccountId, + accountNo: shareRow.acNo, + clientId: this.rowClientIds[shareRow.acNo] ?? null, + clientName: this.rowAccountNames[shareRow.acNo] ?? null, + requestedShares: shares, + requestedDate, + resolutionNumber: shareRow.resolutionNumber.trim(), + meetingDate: shareRow.meetingDate, + description: shareRow.description || null, + settlementPaymentTypeId: glRow.paymentTypeId, + settlementRowSnapshot, + settlementMode: 'GL', + settlementGlAccountId + }; + + const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0; + const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0; + const remainingShares = currentHolding - shares; + const willCloseAccount = remainingShares <= 0; + const confirmLines = [ + `Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`, + `Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, + `Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`, + ...(willCloseAccount + ? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.'] + : []), + `Board Resolution No.: ${payload.resolutionNumber}`, + `Date of Meeting: ${payload.meetingDate}`, + `Settlement Credit To (GL): ${glRow.acNo}`, + `Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`, + ...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []), + '', + willCloseAccount + ? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.' + : 'Pending Manager approval — redemption and settlement both post together on approval.' + ]; + this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () => + this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo) + ); + } + + private doSubmitShareGlSettlementTransfer( + payload: Record, + shareRow: TxnRow, + shares: number, + glAcNo: string + ): void { + this.saving = true; + this.clerkShareWithdrawalService + .submit(payload) + .pipe( + catchError((err: any) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + err?.error?.defaultUserMessage ?? + 'Share withdrawal submission failed. Please try again.'; + this.showAlert('error', 'Submission Failed', [msg]); + this.saving = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (res) { + const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0; + this.showAlert('success', 'Share Withdrawal Submitted', [ + `${shares} share(s) from ${shareRow.acNo} → settlement to GL ${glAcNo} on approval.`, + willCloseAccount + ? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".' + : 'Pending Manager approval. Track status in "My Share Requests".' + ]); + this.rows = []; + this.rowAccountIds = {}; + this.rowAccountNames = {}; + this.rowSharePrices = {}; + this.rowClientIds = {}; + this.rowLinkedSavingsAcNo = {}; + this.rowShareHoldings = {}; + this.addRow(); + this.selectedRow = this.rows[0]; + this.accountDetails = null; + this.clientDetails = null; + } + }); + } + private doSubmitShareRedemptionTransfer( payload: Record, shareRow: TxnRow, @@ -3103,37 +3681,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { .pipe( catchError((err: any) => { // Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED, - // which are legitimate matches with their own specific error) — try the dividend - // cash-claim pool, then the account-closure pool, before giving up: all three share - // the same TOK. NO. field at this counter. + // which are legitimate matches with their own specific error) — try the account-closure + // pool before giving up: both share the same TOK. NO. field at this counter. A dividend + // claim was once a third pool here, but a dividend never actually pays out in cash over + // this counter — it only ever settles through a Demand Draft, which is its own DD + // issuance request and its own token, so looking one up here could only ever 404. if (err?.status === 404) { - return this.dividendClaimRequestService.getByToken(token).pipe( - catchError((divErr: any) => { - if (divErr?.status === 404) { - return this.closureRequestService.getByToken(token).pipe( - catchError((closeErr: any) => { - this.loadingWithdrawal = false; - const msg: string = - closeErr?.error?.message ?? - closeErr?.error?.errors?.[0]?.defaultUserMessage ?? - `Token '${token}' could not be verified. Please try again.`; - this.showAlert('error', `Token '${token}'`, [msg]); - return of(null); - }), - map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null)) - ); - } + return this.closureRequestService.getByToken(token).pipe( + catchError((closeErr: any) => { this.loadingWithdrawal = false; const msg: string = - divErr?.error?.message ?? - divErr?.error?.errors?.[0]?.defaultUserMessage ?? + closeErr?.error?.message ?? + closeErr?.error?.errors?.[0]?.defaultUserMessage ?? `Token '${token}' could not be verified. Please try again.`; this.showAlert('error', `Token '${token}'`, [msg]); return of(null); }), - // Only tag a result that actually came from the dividend pool — a closure hit - // recovered by the inner fallback above is already tagged and must stay that way. - map((divReq: any) => (divReq && !divReq.__closureRequest ? { __dividendClaim: true, ...divReq } : divReq)) + map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null)) ); } this.loadingWithdrawal = false; @@ -3159,7 +3723,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.clientDetails = null; this.accountError = ''; row.acNo = req.client_name ?? 'Dividend Claim #' + req.id; + // Marks this acNo as already resolved, the same way loadAccountDetails() itself would — + // without it, the next click into this row (onRowSelect) sees an acNo that was never + // "loaded" and re-searches for a real account matching this stub string, which can land + // on an unrelated real account whose number happens to share a suffix with it. + this.lastLoadedAcNo = row.acNo; row.acTyp = 'SD'; + // Every token-driven row is cash leaving the branch to a member, which is what the + // Payment register is for — not what this row's TYPE happened to default to before the + // token was entered. + row.typ = 'Payment'; row.amount = Number(req.amount) || null; row.dbCr = 'Dr'; this.selectedRow = row; @@ -3181,7 +3754,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.clientDetails = null; this.accountError = ''; row.acNo = req.account_no ?? ''; + // See the same line in the dividend-claim branch above for why this is required. + this.lastLoadedAcNo = row.acNo; row.acTyp = 'SD'; + row.typ = 'Payment'; row.amount = Number(req.cashier_disbursed_amount) || null; row.dbCr = 'Dr'; // A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the @@ -3194,15 +3770,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } this.activeWithdrawalRequest = req; + // A plain withdrawal-slip request carries its own real account_type (SAVINGS, CURRENT, + // FIXED_DEPOSIT or RECURRING_DEPOSIT — see ClerkWithdrawalRequestApiResource.submit()), unlike + // the closure/dividend pools above, which are never anything but a savings-shaped stub. + // Hardcoding 'SD' here searched every non-savings account on the wrong endpoint (Current + // accounts live under /currentaccounts, not /savingsaccounts) and showed it mislabelled even + // when the lookup happened to succeed. + const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD'; row.acNo = req.account_no ?? ''; - row.acTyp = 'SD'; + row.acTyp = acTyp; + row.typ = 'Payment'; row.amount = Number(req.amount) || null; row.dbCr = 'Dr'; row.paymentTypeId = req.payment_type_id ?? null; row.description = req.description ?? ''; this.selectedRow = row; if (req.account_no) { - this.loadAccountDetails(req.account_no.trim(), 'SD'); + this.loadAccountDetails(req.account_no.trim(), acTyp); } }); } @@ -3229,10 +3813,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { onAcNoBlur(row: TxnRow, index: number): void { if (!row.acNo.trim()) return; this.selectedRow = row; + // A token-driven row's account fields are already authoritative (see onTokNoBlur) -- merely + // clicking into and out of the A/C NO. field here must not re-search for them. Doing so used + // row.acTyp's internal stand-in value (e.g. 'SD' for a dividend claim, which is never the + // account's real type) against the account's real number, either finding nothing ("Account + // not found") or, worse, matching a completely unrelated real account that happened to share a + // number suffix. + if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) { + return; + } this.loadAccountDetails(row.acNo.trim(), row.acTyp); } onAcNoInput(row: TxnRow, index: number, value: string): void { + // Same reasoning as the guard at the top of onAcNoBlur() -- a token-driven row's account + // fields must not be edited or re-searched via this field at all. + if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) { + return; + } row.acNo = value; this.lastLoadedAcNo = ''; this.acNoInput$.next({ row, index, value }); @@ -3341,8 +3939,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { const match = exactMatch ?? partialMatch ?? idMatch ?? null; if (match?.id) { const matchedGlCode = match[f] ?? acNo; - if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) { - this.selectedRow.acNo = match.glCode; + // Any row resolved via the partial/endsWith/id fallback above (not an exact code match) + // loads and displays the right account, but the row itself still carries whatever shorter + // string the cashier actually typed to search with -- and that's what gets submitted and + // shown everywhere downstream (vouchers, My Work, credit_entries), not the account's real + // full number. Correct the row to the account's actual code whenever the match wasn't + // already exact, for every account type -- not just the DD/GL cases this was previously + // limited to. + if (this.selectedRow && !exactMatch && matchedGlCode) { + this.selectedRow.acNo = matchedGlCode; } const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null; this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch); @@ -4519,6 +5124,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this // branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund // Transfer' and at least one of them is GL. + // Share redemption settled to a GL account instead of the linked savings account: DR=Share + // account, CR=any GL account the clerk picks (e.g. a "DD Payable" holding account, ahead of a + // second, independent manual entry that later moves the value out to an actual draft). Checked + // ahead of both branches below — the Enter Bank Transfer/Fund Transfer branch would otherwise + // treat a Fund Transfer carrying a GL leg as the Disbursement/Collection shape (see + // fundTransferRowsHaveGl below), and a Share account can never be debited by that path at all — + // no such Fineract command exists — so it would either be silently mis-posted or rejected late. + if ( + this.isClerk && + this.rows.length === 2 && + this.rows.every((r) => r.typ === 'Fund Transfer' || r.typ === 'Enter Bank Transfer') && + this.rows.some((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR') && + this.rows.some((r) => r.dbCr === 'Cr' && r.acTyp === 'GL') + ) { + const shareRow = this.rows.find((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR')!; + const glRow = this.rows.find((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')!; + this.submitShareGlSettlementTransfer(shareRow, glRow); + return; + } + const fundTransferRowsHaveGl = this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL'); if ( @@ -5179,13 +5804,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - // Cashier deposit (savings/current/GL) → route through maker-checker (create PENDING request, no direct credit) + // Cashier deposit (savings/current/FD/RD/GL) → route through maker-checker (create PENDING request, + // no direct credit). FD and RD are their own account-type codes (see creditEntryAccountType()) and + // belong here just as much as a plain savings deposit -- a cashier crediting an FD/RD must not be + // able to move money in without a manager's approval either. // Use the dialog-confirmed amount — for cash that is the denomination total if ( !this.isClerk && !this.reviewMode && isDeposit && - (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL') + (row.acTyp === 'SD' || + row.acTyp === 'CA' || + row.acTyp === 'CB' || + row.acTyp === 'FD' || + row.acTyp === 'RD' || + row.acTyp === 'GL') ) { this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount); return; @@ -5650,6 +6283,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { chequeNo: row.chequeNo || null, bankName: row.bankName || null, denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null, + // What this GL credit is actually for — see TxnRow.entryTypeCvId. Sent for every GL deposit + // (the backend defaults to "General" when the counter didn't pick one), so Share Advance + // Collection and Sponsor DD issuance can tell whose voucher this really is before adopting it. + ...(isGl ? { entryTypeCvId: row.entryTypeCvId || null } : {}), // DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face // value/charges/GST back once this request is approved, instead of retyping them. ...(this.isDdCashGlRow(row) diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index b4ae05100..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 @@ -1142,6 +1142,46 @@ + +
+
+

Risk Profile

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

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

- - - LIVE - -
- - - -
-
+ } @@ -713,6 +671,9 @@ +
Share Operations
- - - - -
Share Documents
- - - - -
Share Reports
- - - - +
Share Admin
- - - - + + -
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts index beff3f51d..a8ebfa8de 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts @@ -334,14 +334,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit); if (this.principalAmount && this.principalAmount > 0) { - // Convert tenure to months for backend API - let tenureInMonths = 0; + // Convert tenure to months for backend API. A Days tenure under 30 days rounds down to 0 + // months, which the backend rejects -- send it as tenureInDays instead in that case. + let tenureInMonths: number | undefined; + let tenureInDays: number | undefined; if (this.tenurePeriodUnit === 'Years') { tenureInMonths = this.tenureValue * 12; } else if (this.tenurePeriodUnit === 'Months') { tenureInMonths = this.tenureValue; - } else { + } else if (enteredDays >= 30) { tenureInMonths = Math.floor(enteredDays / 30); + } else { + tenureInDays = enteredDays; } // Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual) @@ -395,6 +399,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan principalAmount: this.principalAmount, annualInterestRate: rate, tenureInMonths, + tenureInDays, interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend interestPostingPeriodInMonths: postMonths, submittedOnDate: submittedOnDateStr, @@ -417,7 +422,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100; } else { const n = 12 / compMonths; - const totalCompoundings = Math.floor(tenureInMonths / compMonths); + const effectiveMonths = tenureInMonths ?? enteredDays / 30; + const totalCompoundings = Math.floor(effectiveMonths / compMonths); const rPerCompounding = rate / (n * 100); result.maturityAmount = Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100; diff --git a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts index dd82ede92..b0257e1bc 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts @@ -133,7 +133,8 @@ export class FixedDepositsService { calculateFixedDepositInterest(params: { principalAmount: number; annualInterestRate: number; - tenureInMonths: number; + tenureInMonths?: number; + tenureInDays?: number; // alternative to tenureInMonths, for sub-month FD Single tenures interestCompoundingPeriodInMonths: number; interestPostingPeriodInMonths: number; submittedOnDate?: string; // yyyy-MM-dd — enables exact calendar calculation diff --git a/src/app/fiu/fiu.component.html b/src/app/fiu/fiu.component.html index 2f4e273aa..3c4946314 100644 --- a/src/app/fiu/fiu.component.html +++ b/src/app/fiu/fiu.component.html @@ -432,87 +432,17 @@ }
- -
-
-

Live AML Alerts (Awaiting Triage)

-

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

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

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

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

Filed with FIU-IND

+

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

+
+
+
+ + + + + + + + + + + + + @for (sc of filedStrCases; track sc.id) { + + + + + + + + + } + +
IDClientConclusionFiled OnFIU ReferenceAction
{{ sc.id }}{{ sc.clientDisplayName }}{{ sc.conclusion || '—' }}{{ asDate(sc.strFiledOn) | date: 'dd MMM yyyy' }}{{ sc.strReferenceNo || '—' }}
+
+ } + @if (selectedStrCase) {
diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts index 25f51ebcf..e918bce81 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -7,6 +7,7 @@ */ import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -25,8 +26,7 @@ import { KycIndividual, PoFinding, StrStatus, - StrTransaction, - STR_TXNS + StrTransaction } from './fiu-data'; /** A FINnet file rendered as a grid — the same rows that go into the CSV. */ @@ -130,6 +130,7 @@ export class FiuComponent implements OnInit { private amlService = inject(AmlService); private sanctionsLogsService = inject(SanctionsLogsService); private authenticationService = inject(AuthenticationService); + private route = inject(ActivatedRoute); /** Active tab */ activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard'; @@ -154,7 +155,16 @@ export class FiuComponent implements OnInit { * is honestly empty rather than populated with rows that cannot be filed. */ tc1Txns: CardTransaction[] = []; - strTxns: StrTransaction[] = STR_TXNS; + /** + * The STR transaction file. + * + * Empty, for the same reason TC1 and the customer cards are. The four rows that sat here named customers this bank + * has never onboarded, against account numbers it does not hold, for sums it never received — on the screen an + * officer files suspicious-transaction reports from. A filing built on invented rows is not a draft; it is a false + * report to FIU-IND. The live alerts the engine has actually escalated are listed on this tab already, and the + * file fills from those when the STR export is wired to them. + */ + strTxns: StrTransaction[] = []; /** * KC1 / KC2 — the customer cards that travel with the CTR bundle. * @@ -210,13 +220,29 @@ export class FiuComponent implements OnInit { */ sanctionsStrCases: any[] = []; sanctionsStrFilter = ''; - amlAlertStatusFilter = ''; - amlReviewNotes: Record = {}; amlCtrReports: any[] = []; ctrGenerateMonth = ''; amlStrCases: any[] = []; + + /** + * The cases still carrying work, which is what "Live" means here. + * + *

+ * A case that has been filed with FIU-IND is finished: the report is gone, the reference number is on it, and there + * is nothing left for the Principal Officer to do. Leaving it in the live queue is how a working list stops being + * one — every filed case stays on it for good, and an officer opening the tab has to tell the outstanding work from + * the closed by reading a status column. Filed cases are listed below, where they can still be looked up. + */ + get liveStrCases(): any[] { + return this.amlStrCases.filter((c) => c?.status !== 'FILED'); + } + + /** Cases already filed with FIU-IND, kept for the record rather than as work. */ + get filedStrCases(): any[] { + return this.amlStrCases.filter((c) => c?.status === 'FILED'); + } selectedStrCase: any = null; concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS'; concludeInvestigationNotes = ''; @@ -398,6 +424,14 @@ export class FiuComponent implements OnInit { this.isHeadOffice = this.authenticationService.isHeadOfficeUser(); this.currentOfficeName = creds?.officeName || 'Branch Office'; + // Opened on a particular tab when another screen sends the officer here for a reason - the Risk + // Review Report links a case that is with the Principal Officer straight to the STR queue, so + // landing on the dashboard would make them find it themselves. + const wantedTab = this.route.snapshot.queryParamMap.get('tab'); + if (wantedTab === 'str' || wantedTab === 'ctr' || wantedTab === 'customers' || wantedTab === 'files') { + this.activeTab = wantedTab; + } + // The header named a hard-coded officer and a hard-coded month — "Karthik Kamath" and // "January 2026" — on a screen whose whole purpose is to say which month is being filed and // by whom. Both now come from the session and the clock. @@ -615,10 +649,9 @@ export class FiuComponent implements OnInit { } /** - * Always fetches every status. The triage table and the cleared register are two views of the - * same set, so filtering server-side would empty one whenever the other was narrowed — and the - * CTR tab reads this list too, which a status filter chosen on the STR tab should not touch. - * amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead. + * Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of + * the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty + * another. */ loadAmlAlerts(): void { this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || [])); @@ -762,48 +795,6 @@ export class FiuComponent implements OnInit { return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`); } - /** - * Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of - * why a flagged transaction was disposed of, and an empty string leaves no trace of the decision. - */ - private reviewNotesOf(alert: any): string | null { - const notes = (this.amlReviewNotes[alert.id] || '').trim(); - if (!notes) { - this.toast(`Enter review notes before disposing of alert #${alert.id}`); - return null; - } - return notes; - } - - whitelistAlert(alert: any): void { - const notes = this.reviewNotesOf(alert); - if (!notes) { - return; - } - this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({ - next: () => { - this.toast(`Alert #${alert.id} whitelisted — no STR raised`); - this.loadAmlAlerts(); - }, - error: this.apiError(`Whitelisting alert #${alert.id}`) - }); - } - - escalateAlert(alert: any): void { - const notes = this.reviewNotesOf(alert); - if (!notes) { - return; - } - this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({ - next: () => { - this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`); - this.loadAmlAlerts(); - this.loadAmlStrCases(); - }, - error: this.apiError(`Escalating alert #${alert.id}`) - }); - } - generateCtrReport(): void { if (!this.ctrGenerateMonth) { this.toast('Pick a reporting month first'); @@ -991,9 +982,16 @@ export class FiuComponent implements OnInit { get liveStrFiled(): number { return this.amlStrCases.filter((c) => c.status === 'FILED').length; } - /** Still needs PO action: untouched, or concluded suspicious but not yet filed with FIU-IND. */ + /** + * Still needs PO action: anything neither filed with FIU-IND nor concluded clear. + * + * Counted by what is unfinished rather than by naming the statuses that are. Adding up OPEN and CONCLUDED_SUSPICIOUS + * gives the same figure today, but it is the shape that fails quietly: a case in any status those two do not name + * drops out of the KPI while still sitting in the queue below it, and the dashboard then under-reports the work. + * That is exactly how five undecided alerts hid behind a pending count of 1 on the flagged-transactions screen. + */ get liveStrPending(): number { - return this.liveStrOpen + this.liveStrAwaitingFiling; + return this.amlStrCases.filter((c) => c?.status !== 'FILED' && c?.status !== 'CONCLUDED_CLEAR').length; } get totalCustomers(): number { return this.kc1.length + this.kc2.length; @@ -1022,12 +1020,6 @@ export class FiuComponent implements OnInit { * The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed * with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}. */ - get amlSuspiciousAlerts(): any[] { - return this.behaviouralAlerts.filter( - (a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter) - ); - } - /** Cleared register: alerts closed as false positives, kept for audit with their evidence. */ get amlWhitelistedAlerts(): any[] { return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED'); diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss index 13b5f7636..dc84e930b 100644 --- a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss +++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss @@ -576,13 +576,11 @@ } /* ───────── Print — only the page, no toolbar / dialog chrome ───────── - This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the - overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip. - `position: absolute` still lives inside that clip and gets cut off — only the on-screen - scroll position prints. `position: fixed` escapes it: a fixed element's containing block is - the viewport itself, not the clipped ancestor (the classic "fixed child ignores an - overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a - transform/filter — the CDK overlay chrome doesn't. */ + The document-level clip (`html, body { overflow: hidden }`) is lifted globally in main.scss's + print block; what remains here is this dialog's own clip — it opens with `maxHeight: '92vh'`, + which Angular Material carries onto the overlay pane / .mat-mdc-dialog-container as an inline + max-height plus overflow:auto. Freeing those lets the whole certificate flow onto the page in + normal static order (a fixed/absolute hack would pin it to the first page instead). */ @media print { body > :not(.cdk-overlay-container) { display: none !important; @@ -611,9 +609,7 @@ } .cpd-root { - position: fixed; - left: 0; - top: 0; + position: static; width: 100% !important; height: auto !important; background: #fff; diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html index daccfbafa..945e5f5a8 100644 --- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html @@ -21,14 +21,31 @@

+
- +
-

Dividend declared for {{ result.financialYear }}

+

+ @if (hasSkipped) { + Declared for {{ result.financialYear }} — {{ result.skipped.length }} + {{ result.skipped.length === 1 ? 'product was' : 'products were' }} skipped + } @else { + Dividend declared for {{ result.financialYear }} + } +

{{ result.declarationsCreated }} {{ result.declarationsCreated === 1 ? 'declaration' : 'declarations' }} created. Each still needs approval before members can claim. + @if (hasSkipped) { + Members of the skipped {{ result.skipped.length === 1 ? 'product' : 'products' }} below will receive + nothing for this year — check the reason and declare separately if that is not intended. + }

@@ -128,7 +145,22 @@ − {{ preview.totalAmount | number: '1.2-2' }}
- @if (isUnderfunded) { + @if (exceedsMaximumRate) { + +
+ {{ declareForm.value.ratePercentage }}% is above the society's maximum of + {{ preview.maximumRatePercentage }}%. Lower the rate, or raise the limit in Configurations if the board + has resolved a higher one. +
+ } @else if (isPeriodNotFinished) { + +
+ {{ preview.financialYear }} has not finished yet, so its profit is not final. A dividend can only be + declared once the financial year is over. +
+ } @else if (isUnderfunded) {
Short by {{ preview.fundingShortfall | number: '1.2-2' }}. There is not enough in {{ preview.profitGlCode }} to fund this dividend — lower the rate, or close the year's profit first. @@ -146,16 +178,42 @@ {{ declareForm.value.ratePercentage || 0 }}% on each member’s share investment
+ @for (product of eligibleProducts; track product.productId) {
- {{ product.productName }} + + {{ product.productName }} + + {{ product.memberCount }} {{ product.memberCount === 1 ? 'member' : 'members' }} + @if (product.accountCount !== product.memberCount) { + · {{ product.accountCount }} accounts + } + + {{ product.amount | number: '1.2-2' }}
} @if (eligibleProducts.length > 0) {
- Total to declare + + Total to declare + @if (preview.totalMemberCount) { + + + {{ preview.totalMemberCount }} + {{ preview.totalMemberCount === 1 ? 'member' : 'members' }} + @if (preview.totalAccountCount !== preview.totalMemberCount) { + · {{ preview.totalAccountCount }} accounts + } + + } + {{ payableTotal | number: '1.2-2' }}
} diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss index 334f70aa4..c95fb17a2 100644 --- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss @@ -148,6 +148,14 @@ form { text-align: right; } +// Who a product's amount reaches, set quietly beside the product name so it reads as a +// qualifier on the amount rather than competing with it. +.dd-members { + margin-left: 8px; + font-size: 12px; + color: #94a3b8; +} + .dd-note { margin-top: 12px; padding: 10px 14px; @@ -194,6 +202,11 @@ form { font-size: 26px; color: #16a34a; margin-top: 2px; + + // A run that left a product out is amber, not green: it succeeded, but not for everyone. + &--warn { + color: #d97706; + } } .dd-table { diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts index 83f7d52e6..5ab5cbd9c 100644 --- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts @@ -73,6 +73,17 @@ export class DeclareDividendComponent implements OnInit { /** Set once the declaration has gone through, so the outcome per product can be shown. */ result: any = null; + /** + * Whether any product was left out of the declaration that just ran. + * + * Drives the outcome heading. A run that skipped a product still returns success — one product + * being ineligible is not a reason to lose the others — but those members receive nothing for the + * year, so the screen has to say so rather than report a plain "declared". + */ + get hasSkipped(): boolean { + return (this.result?.skipped?.length ?? 0) > 0; + } + ngOnInit(): void { this.declareForm = this.formBuilder.group({ financialYear: [ @@ -96,9 +107,13 @@ export class DeclareDividendComponent implements OnInit { /** * The financial years a dividend can be declared for: the current one and the five before it. * - * The current year is included because a society may declare an interim dividend part-way through, - * and five back covers a declaration delayed by an AGM that ran late. Built from the business date - * rather than the browser's clock, so the list matches the year the branch is operating in. + * The year in progress is listed but cannot be declared — the server rejects it, and the screen + * says so as soon as it is picked. It stays on the list so an officer looking for it finds it with + * the reason attached, rather than wondering why the year they are in is missing. Five back covers + * a declaration delayed by an AGM that ran late. + * + * Built from the business date rather than the browser's clock, so the list matches the year the + * branch is operating in. */ private buildFinancialYears(): void { const businessDate: Date = this.settingsService.businessDate ?? new Date(); @@ -171,8 +186,36 @@ export class DeclareDividendComponent implements OnInit { return !!this.preview?.exceedsDistributableProfit; } + /** + * Whether the chosen year is still running. + * + * A dividend is a share of the year's profit, so it cannot be declared until the year is over and + * that profit is final. The server enforces this; the flag simply lets the screen say so before the + * officer commits to a rate. + */ + get isPeriodNotFinished(): boolean { + return !!this.preview?.periodNotFinished; + } + + /** + * Whether the entered rate is above the society's configured maximum. + * + * The bye-laws cap what may be paid in a year, so a rate above it needs a board resolution rather + * than a typed figure. The server enforces the same limit. + */ + get exceedsMaximumRate(): boolean { + return !!this.preview?.exceedsMaximumRate; + } + get canSubmit(): boolean { - return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0 && !this.isUnderfunded; + return ( + this.declareForm.valid && + !this.submitting && + this.eligibleProducts.length > 0 && + !this.isUnderfunded && + !this.isPeriodNotFinished && + !this.exceedsMaximumRate + ); } submit(): void { diff --git a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts index 14693833d..4cb5e33d2 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts +++ b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts @@ -70,16 +70,9 @@ export class ShareProductAccountingStepComponent implements OnInit { shareProductAccountingForm: UntypedFormGroup; accountingSectionConfigs: ShareAccountingSectionConfig[] = []; - assetAccountData: GLAccount[] = []; liabilityAccountData: GLAccount[] = []; incomeAccountData: GLAccount[] = []; equityAccountData: GLAccount[] = []; - /** "Share Reference" is where a redemption's credit side lands pending settlement — some banks - * model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default), - * others as a payable-like Liability suspense account (e.g. an existing "Sundry" account - * already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts - * either instead of Asset-only. */ - referenceAccountData: GLAccount[] = []; constructor() { this.createShareProductAccountingForm(); @@ -87,18 +80,15 @@ export class ShareProductAccountingStepComponent implements OnInit { ngOnInit() { const opts = this.shareProductsTemplate.accountingMappingOptions || {}; - this.assetAccountData = opts.assetAccountOptions || []; this.liabilityAccountData = opts.liabilityAccountOptions || []; this.incomeAccountData = opts.incomeAccountOptions || []; this.equityAccountData = opts.equityAccountOptions || []; - this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData); this.buildAccountingSectionConfigs(); if (this.shareProductsTemplate.accountingMappings) { const m = this.shareProductsTemplate.accountingMappings; this.shareProductAccountingForm.patchValue({ - shareReferenceId: m.shareReferenceId?.id || '', shareSuspenseId: m.shareSuspenseId?.id || '', incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '', shareEquityId: m.shareEquityId?.id || '' @@ -109,10 +99,6 @@ export class ShareProductAccountingStepComponent implements OnInit { createShareProductAccountingForm() { this.shareProductAccountingForm = this.formBuilder.group({ accountingRule: [2], - shareReferenceId: [ - '', - Validators.required - ], shareSuspenseId: [ '', Validators.required @@ -125,7 +111,6 @@ export class ShareProductAccountingStepComponent implements OnInit { '', Validators.required ], - assetsOtherGlMappings: this.formBuilder.array([]), liabilitiesOtherGlMappings: this.formBuilder.array([]), incomeOtherGlMappings: this.formBuilder.array([]), equityOtherGlMappings: this.formBuilder.array([]) @@ -134,24 +119,6 @@ export class ShareProductAccountingStepComponent implements OnInit { buildAccountingSectionConfigs() { this.accountingSectionConfigs = [ - { - key: 'assets', - title: 'Assets', - pillLabel: 'Asset', - pillClass: 'green', - rows: [ - { - key: 'shareReferenceId', - label: 'Share Reference (Cash Vault or Suspense)', - inputLabel: 'Share reference', - controlName: 'shareReferenceId', - options: this.referenceAccountData, - required: true - } - ], - otherMappingsControlName: 'assetsOtherGlMappings', - otherAccountOptions: this.assetAccountData - }, { key: 'liabilities', title: 'Liabilities', @@ -239,7 +206,7 @@ export class ShareProductAccountingStepComponent implements OnInit { } get shareProductAccounting() { - const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } = + const { liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } = this.shareProductAccountingForm.value; return rest; } diff --git a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html index 4bc274f94..4f1064ae8 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html +++ b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html @@ -213,14 +213,6 @@
@if (shareProduct.accountingRule === 2) { -
{{ 'labels.heading.Assets' | translate }}
-
- {{ 'labels.inputs.Share reference' | translate }}: - {{ - shareProduct.shareReferenceId - | find: shareProductsTemplate.accountingMappingOptions.assetAccountOptions : 'id' : 'name' - }} -
{{ 'labels.heading.Liabilities' | translate }}
{{ 'labels.inputs.Share suspense control' | translate }}: diff --git a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html index 4538cc62a..2cd01ff86 100644 --- a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html +++ b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html @@ -208,16 +208,6 @@ @if (shareProduct.accountingRule.id === 2) {
-

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

-
- - -

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

+ * Low and medium-severity alerts arrive as runs of the same thing - a customer whose ordinary cash + * deposits each cross a threshold - and clearing them one dialog at a time is what keeps an officer + * from reaching the alert that matters. The reason and evidence are asked once and written against + * every alert in the batch, so each still carries its own record of why it was disposed of. + * + *

+ * High-severity alerts never reach here: the caller will not tick them. An alert in the high band is + * a judgement about that particular transaction and gets its own decision. + */ +@Component({ + selector: 'mifosx-aml-bulk-review-dialog', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatInputModule + ], + template: ` +

+ {{ isWhitelist ? 'Whitelist' : 'Escalate' }} {{ data.alerts.length }} Alert{{ + data.alerts.length === 1 ? '' : 's' + }} +

+ +

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

+ +
+
+ {{ a.transactionType }} #{{ a.transactionId }} + {{ a.clientDisplayName }} + {{ a.ruleCode }} + ₹{{ a.amount | number: '1.0-0' }} +
+
+ +
+ +
+
Evidence relied on
+
+ {{ t }} +
+

Tick at least one piece of evidence

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

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

+ +

{{ errorMessage }}

+
+ + + + + `, + styles: [ + ` + .bk-intro { + font-size: 13px; + color: #64748b; + margin: 0 0 12px; + line-height: 1.5; + } + .bk-list { + max-height: 190px; + overflow-y: auto; + border: 1px solid #e2e8f0; + border-radius: 8px; + margin-bottom: 16px; + background: #f8fafc; + } + .bk-row { + display: grid; + grid-template-columns: 1.1fr 1.4fr 1.6fr 0.9fr; + gap: 10px; + padding: 8px 12px; + font-size: 12px; + border-bottom: 1px solid #eef2f7; + } + .bk-row:last-child { + border-bottom: none; + } + .bk-txn { + font-family: 'DM Mono', monospace; + color: #334155; + } + .bk-client { + color: #1e293b; + } + .bk-rule { + color: #7c3aed; + font-size: 11px; + } + .bk-amt { + text-align: right; + font-family: 'DM Mono', monospace; + color: #1e293b; + } + .bk-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #64748b; + margin-bottom: 6px; + } + .bk-checks { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 10px; + } + .bk-full { + width: 100%; + } + .bk-effect { + font-size: 12.5px; + line-height: 1.5; + color: #15803d; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 6px; + padding: 10px 12px; + margin: 4px 0 0; + } + .bk-effect--warn { + color: #b91c1c; + background: #fef2f2; + border-color: #fecaca; + } + .bk-err { + margin: 10px 0 0; + color: #b91c1c; + font-size: 12.5px; + } + ` + ] +}) +export class AmlBulkReviewDialogComponent { + private fb = inject(FormBuilder); + private amlService = inject(AmlService); + dialogRef = inject(MatDialogRef); + + readonly evidenceTypes = [ + 'Customer explanation obtained', + 'Source of funds documented', + 'KYC / identity re-verified', + 'Consistent with declared income or turnover', + 'Established business or salary pattern', + 'Duplicate or system-generated alert', + 'Other' + ]; + + form: FormGroup; + submitting = false; + errorMessage = ''; + + data: AmlBulkReviewData = inject(MAT_DIALOG_DATA); + + constructor() { + this.form = this.fb.group({ + evidence: this.fb.array(this.evidenceTypes.map(() => false)), + evidenceRef: [ + '', + Validators.maxLength(100) + ], + notes: [ + '', + [ + Validators.required, + Validators.maxLength(800) + ] + ] + }); + } + + get isWhitelist(): boolean { + return this.data.disposition === 'WHITELIST'; + } + + get evidenceArray(): FormArray { + return this.form.get('evidence') as FormArray; + } + + get selectedEvidence(): string[] { + return this.evidenceTypes.filter((_, i) => this.evidenceArray.at(i)?.value === true); + } + + get evidenceMissing(): boolean { + return this.isWhitelist && this.evidenceArray.touched && this.selectedEvidence.length === 0; + } + + /** Exactly what is written to each alert, so the record reads the same as a single review. */ + private composedNotes(): string { + const v = this.form.value; + const detail = (v.notes || '').trim(); + if (!this.isWhitelist) { + return detail ? `Grounds: ${detail}` : ''; + } + const parts: string[] = []; + if (this.selectedEvidence.length) { + parts.push(`Evidence: ${this.selectedEvidence.join('; ')}`); + } + if (v.evidenceRef) { + parts.push(`Ref: ${(v.evidenceRef || '').trim()}`); + } + const head = parts.join(' | '); + const composed = head && detail ? `${head} — ${detail}` : head || detail; + // Batch note, so each alert says it was disposed of alongside others rather than on its own. + return `${composed} [batch of ${this.data.alerts.length}]`.slice(0, 1000); + } + + submit(): void { + this.evidenceArray.markAllAsTouched(); + if (this.form.invalid || (this.isWhitelist && this.selectedEvidence.length === 0)) { + this.form.markAllAsTouched(); + return; + } + + this.submitting = true; + this.errorMessage = ''; + const notes = this.composedNotes(); + + // Each alert is reviewed on its own call: one that fails - already disposed of by someone else, + // say - must not take the rest of the batch with it. Failures are counted and reported. + forkJoin( + this.data.alerts.map((a) => + this.amlService + .reviewAlert(a.id, this.data.disposition, notes) + .pipe(catchError(() => of({ __failed: true, id: a.id }))) + ) + ).subscribe((results: any[]) => { + this.submitting = false; + const failed = results.filter((r) => r?.__failed).length; + if (failed === results.length) { + this.errorMessage = 'None of the alerts could be updated. They may already have been reviewed.'; + return; + } + if (failed > 0) { + this.errorMessage = `${results.length - failed} updated, ${failed} could not be — those may already have been reviewed.`; + // Still closes as done: most of the batch landed, and the list behind must refresh. + setTimeout(() => this.dialogRef.close(true), 2200); + return; + } + this.dialogRef.close(true); + }); + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html index 4470f0f0d..ab2aaf069 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html @@ -36,6 +36,17 @@ + +
@@ -44,6 +55,52 @@ Could not reach the alert feed. Retrying automatically.
+ +
+ Period + @for (m of periodModes; track m.key) { + + } + + + @if (periodMode === 'day') { + + } + @if (periodMode === 'week') { + + } + @if (periodMode === 'month') { + + } + @if (periodMode === 'year') { + + } + @if (periodLabel) { + {{ periodLabel }} + } +
+
Status @@ -60,23 +117,143 @@
- -
-
- {{ tier.count }} - {{ tier.label }} -
-
- {{ unclassifiedCount }} - Unclassified -
-
- {{ summaryTotal }} - Total Flagged -
+ +
+ Top rules + @for (n of topRuleOptions; track n) { + + } + @if (topRules !== null) { + {{ distinctRuleCount }} rule{{ distinctRuleCount === 1 ? '' : 's' }} in view + }
- + +@if (ruleFrequency.length > 1) { +
+ @for (r of ruleFrequency; track r.code) { + + {{ r.code }} + {{ r.count }} + + } +
+} + + +
+ + + +
+ + +@if (selectedCount > 0) { +
+ {{ selectedCount }} selected + Low / medium only — a transaction that tripped a high-severity rule is decided on its own. + + + + +
+} + + +
+ + + + + + @@ -113,6 +290,162 @@
{{ alert.ruleCode }} {{ alert.ruleDescription }} + + @if (matchedRuleCount(alert) > 1) { + + } +
+ + + + + + + @@ -132,7 +465,7 @@ - - + + +
+ + @if (selectableRows.length) { + + } + + + @if (isSelectable(alert)) { + + } @else { + — + } + Raised On + +
+ @if (isExpanded(alert)) { +
+
+ Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} — + {{ amount(alert) }} + @if (selectableRulesOf(alert).length > 1) { + + } +
+ + @for (rule of matchedRules(alert); track rule.ruleCode) { +
+ + @if (isRuleSelectable(rule)) { + + } @else if (!isGradedRule(rule)) { + + + gavel + + } @else { + + {{ + !rule.directlyDecided + ? 'remove_circle_outline' + : rule.disposition === 'ESCALATE' + ? 'report' + : 'verified' + }} + + } + {{ + severityLabel(rule.riskCategory) + }} +
+ + {{ rule.ruleCode }} + @if (rule.governing) { + governing + } + + @if (!isGradedRule(rule)) { + + → CTR filing + + } + + @if (rule.disposition) { + + {{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }} + + } + + {{ rule.ruleDescription }} +
+
+ } + + + @if (selectedRuleCount > 0) { +
+ {{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected + + + + +
+ } +
+ @if (isHighSeverity(alert)) { + A high-severity rule fired, so this transaction is decided on its own rather than in a batch. + } @else if (!alert.highestSeverity) { + A statutory return rather than a graded suspicion — decided on its own. + } @else { + Every rule here is low or medium, so this transaction can be decided together with others. + } +
+
+ }
Action + @@ -158,37 +491,73 @@ -
- - {{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }} - - - - description - - - → STR - -
+ + @if (heldByAnother(alert)) { +
+ + lock + + with {{ heldBy(alert) }} +
+ } @else { +
+ + {{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }} + + + + description + + + → STR + +
+ }
- + +
- {{ activeStatus ? 'filter_alt_off' : 'verified_user' }} - {{ activeStatus || activeSeverity !== null ? 'filter_alt_off' : 'verified_user' }} + No transactions flagged. The feed updates automatically as the engine raises alerts. - No alerts in this status. + No alerts match the filters in force.
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss index 67a98fce5..a11f5bc7c 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss @@ -196,15 +196,43 @@ padding: 0 0 16px; } +/* A button, not a div: each tile narrows the table to its own severity band. */ .risk-tile { display: flex; flex-direction: column; + align-items: flex-start; gap: 2px; padding: 12px 14px; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; background: #fff; + font-family: inherit; + text-align: left; + cursor: pointer; + transition: + box-shadow 0.15s, + transform 0.1s; + + &:hover:not(:disabled) { + box-shadow: 0 2px 8px rgb(15 23 42 / 12%); + } + + &:active:not(:disabled) { + transform: translateY(1px); + } + + /* A band with nothing in it would filter to an empty table. */ + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +/* The band currently narrowing the table. The ring reads on every tile colour, where a + background change would fight the severity tint each tile already carries. */ +.risk-tile.tile-active { + box-shadow: 0 0 0 2px #1a237e; } .risk-tile-n { @@ -293,8 +321,21 @@ } } -.alert-row:hover { +.alert-row { + cursor: pointer; + + &:hover { + background: #f8fafc; + } +} + +/* The open row and its pane read as one block. */ +.alert-row.row-expanded { background: #f8fafc; + + td { + border-bottom-color: transparent; + } } /* A newly arrived alert stays tinted until acknowledged. */ @@ -380,6 +421,285 @@ font-size: 12px; } +/* Quieter than the governing rule: it is context on one case, not a second case. */ +.rule-also { + margin-top: 3px; + color: #92400e; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 4px; + padding: 1px 6px; + font-size: 11px; + align-self: flex-start; +} + +/* Says how many rules fired and opens the detail. Replaces the block of codes that used to sit in + this cell: the codes belong in the pane, where each can carry its own severity. */ +.rule-more { + display: inline-flex; + align-items: center; + gap: 3px; + margin-top: 4px; + padding: 2px 8px 2px 4px; + align-self: flex-start; + border: 1px solid #fde68a; + border-radius: 999px; + background: #fffbeb; + color: #92400e; + font-family: inherit; + font-size: 11px; + font-weight: 700; + cursor: pointer; + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + } + + &:hover { + background: #fef3c7; + } +} + +/* ── Expanded detail ─────────────────────────────────────────────────────────────────────── */ + +.detail-row { + height: 0; +} + +.detail-cell { + padding: 0 !important; + border-bottom: none !important; +} + +/* Animating max-height rather than height: the pane's height depends on how many rules fired, and + a transition needs a figure to move between. */ +.detail-pane { + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease; + background: #f8fafc; + + /* Generous enough for a transaction that tripped every rule it could, plus the action bar. Too low + silently clips the last rule, which is the one an officer most needs to see. */ + &.detail-open { + max-height: 1200px; + } +} + +.detail-inner { + padding: 14px 16px 16px 60px; + border-bottom: 1px solid #e2e8f0; +} + +.detail-head { + display: flex; + align-items: center; + gap: 12px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + margin-bottom: 10px; +} + +.rule-select-all { + border: none; + background: none; + padding: 0; + font-family: inherit; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #1a237e; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +/* A rule that already carries a verdict is context, not a choice. */ +.detail-rule-decided { + opacity: 0.75; +} + +.rule-decided-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: #15803d; + cursor: help; + + &.decided-escalated { + color: #b91c1c; + } + + /* Closed alongside another rule rather than judged on its own — stated, but not as a verdict. */ + &.decided-carried { + color: #94a3b8; + } + + /* A statutory return: not the officer's decision at all. */ + &.decided-statutory { + color: #475569; + } +} + +/* Where a statutory rule goes, said next to the rule rather than left to be inferred from a + missing checkbox. */ +.ctr-tag { + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + background: #e2e8f0; + color: #334155; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + white-space: nowrap; +} + +.rule-verdict { + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + background: #dcfce7; + color: #15803d; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + + &.verdict-escalated { + background: #fee2e2; + color: #b91c1c; + } + + /* Wins over the disposition colours above: this rule was not itself the finding, and colouring it + like one would claim the officer condemned it. */ + &.verdict-carried { + background: #f1f5f9; + color: #64748b; + } +} + +/* Acts on the ticked rules only, and sits with them rather than at the top of the table. */ +.rule-action-bar { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + padding: 10px 12px; + border: 1px solid #bfdbfe; + border-radius: 8px; + background: #eff6ff; +} + +.rule-action-count { + font-size: 12.5px; + font-weight: 700; + color: #1e3a8a; + white-space: nowrap; +} + +.detail-rule { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 7px 10px; + border-radius: 6px; + background: #fff; + border: 1px solid #e2e8f0; + + & + & { + margin-top: 6px; + } +} + +/* The rule the alert was raised under, and the one the disposition is recorded against. */ +.detail-rule-governing { + border-color: #cbd5e1; + box-shadow: inset 3px 0 0 #1a237e; +} + +.detail-rule-text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.detail-rule-code { + font-size: 12.5px; + font-weight: 700; + color: #1e293b; +} + +.detail-rule-desc { + font-size: 12px; + color: #64748b; +} + +.governing-tag { + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + background: #e0e7ff; + color: #3730a3; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +/* Severity of one rule inside the pane. Same colours as the summary tiles, so a band reads the + same wherever it appears. */ +.sev-chip { + flex: none; + min-width: 62px; + text-align: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + + &.sev-high { + background: #fee2e2; + color: #b91c1c; + } + + &.sev-medium { + background: #fef3c7; + color: #b45309; + } + + &.sev-low { + background: #dcfce7; + color: #15803d; + } + + &.sev-none { + background: #e2e8f0; + color: #475569; + } +} + +/* Why this transaction can or cannot be decided in a batch, said where the officer is deciding. */ +.detail-foot { + margin-top: 10px; + font-size: 12px; + color: #475569; + font-style: italic; +} + .rule-code { font-weight: 600; } @@ -537,3 +857,157 @@ background: #fff; border-top: 1px solid #e2e8f0; } + +/* Batch disposition bar — only rendered while rows are ticked. */ +.bulk-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + margin-bottom: 10px; + border: 1px solid #bfdbfe; + border-radius: 10px; + background: #eff6ff; +} + +.bulk-count { + font-size: 13px; + font-weight: 700; + color: #1e3a8a; + white-space: nowrap; +} + +/* Says why some rows cannot be ticked, where the officer is looking rather than in a tooltip. */ +.bulk-note { + font-size: 12px; + color: #475569; +} + +.bulk-spacer { + flex: 1; +} + +.select-cell { + width: 44px; + padding-right: 0; +} + +/* Stands in for the checkbox on a row that cannot be batched, so the column still reads as a + column rather than leaving a hole the eye has to account for. */ +.select-na { + display: inline-block; + width: 18px; + text-align: center; + color: #cbd5e1; + cursor: help; + user-select: none; +} + +/* Day picker. Sized and bordered to sit level with the status chips beside it, so the two rows read + as one control strip rather than a form field dropped above a set of buttons. */ +.day-input { + padding: 5px 11px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + font-family: inherit; + font-size: 12.5px; + font-weight: 600; + color: #334155; + cursor: pointer; + + &:hover { + border-color: #1a237e; + } + + &:focus { + outline: none; + border-color: #1a237e; + } +} + +/* What the chosen mode resolved to. A week picked by naming one day inside it is otherwise invisible — the officer + has no way to tell which Monday-to-Sunday span they are looking at. */ +.period-resolved { + font-size: 12px; + color: #64748b; + font-variant-numeric: tabular-nums; +} + +/* Sits with the pause and refresh controls, green because it produces something the officer takes away rather + than changing what is on screen. */ +.export-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 13px; + border: 1px solid #bbf7d0; + border-radius: 999px; + background: #f0fdf4; + color: #15803d; + font-family: inherit; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover:not(:disabled) { + background: #dcfce7; + border-color: #86efac; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +/* Held by another officer. Grey, not red: nobody has done anything wrong, the case is simply somebody else's. */ +.held-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #94a3b8; + cursor: help; +} + +.held-by { + color: #64748b; + font-size: 12px; + font-style: italic; +} + +/* Rule frequency strip. Sits under the filters as a reading aid, not a control — clicking a rule would be a fourth + filter competing with the three above it. */ +.rule-freq { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 0 14px; +} + +.rule-freq-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border: 1px solid #e2e8f0; + border-radius: 999px; + background: #f8fafc; + font-size: 11px; + font-weight: 600; + color: #475569; +} + +.rule-freq-n { + padding: 0 6px; + border-radius: 999px; + background: #e2e8f0; + color: #1e293b; + font-variant-numeric: tabular-nums; +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts index 87cec7b0b..85b49edf4 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, OnInit, OnDestroy, inject, ViewChild } from '@angular/core'; +import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core'; import { MatTableModule, MatTableDataSource } from '@angular/material/table'; import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; import { MatIconModule } from '@angular/material/icon'; @@ -15,15 +15,23 @@ import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { Subject, timer, of } from 'rxjs'; import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators'; /** Custom Services */ import { AmlService } from '../../../aml/aml.service'; import { AlertService } from '../../../core/alert/alert.service'; +import { AuthenticationService } from '../../../core/authentication/authentication.service'; import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive'; import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component'; +import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component'; +import { exportWorkbook } from '../../../cashier/cashier-export'; + +/** How the feed is narrowed by time. */ +type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year'; /** * Live feed of transactions the AML rule engine has flagged. @@ -41,6 +49,7 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone standalone: true, imports: [ CommonModule, + FormsModule, MatTableModule, MatPaginatorModule, MatIconModule, @@ -48,13 +57,18 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone MatTooltipModule, MatProgressSpinnerModule, MatDialogModule, + MatCheckboxModule, HasPermissionDirective ] }) -export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { +export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy { private amlService = inject(AmlService); private dialog = inject(MatDialog); private alertService = inject(AlertService); + private auth = inject(AuthenticationService); + + /** Who is signed in, so the screen can tell an alert this officer holds from one somebody else does. */ + private readonly currentUsername = this.auth.getCredentials()?.username ?? ''; private destroy$ = new Subject(); /** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */ @@ -62,6 +76,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { alerts = new MatTableDataSource([]); displayedColumns: string[] = [ + 'select', 'raisedOn', 'transaction', 'client', @@ -71,25 +86,532 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { 'actions' ]; + /** + * Alerts ticked for a single disposition across several transactions. + * + *

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

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

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

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

+ * Expanding a row used to mark the alert UNDER_REVIEW, on the reasoning that opening the pane is where the work + * starts. It is not: most expands are an officer glancing at what a transaction tripped, and a status that changes + * on a single click describes the click rather than any review. The queue filled with alerts nobody was working, + * and the one state the flag existed to make visible — this one is actually being worked — became meaningless. + */ + toggleExpanded(alert: any): void { + this.expandedId = this.expandedId === alert.id ? null : alert.id; + // Ticks belong to the alert they were made on, not to whichever pane is open next. + this.selectedRuleCodes.clear(); + } + + isExpanded(alert: any): boolean { + return this.expandedId === alert.id; + } + + /** + * Every rule the transaction matched, governing one first. + * + *

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

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

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

+ * The feed refreshes itself every few seconds while the officer works. Without this, an alert + * another officer disposed of in the meantime — or one the status filter has since hidden — stays + * in the set, and the bar goes on offering to decide it: the count reads higher than the ticks on + * screen, and the batch carries alerts nobody chose. + */ + private pruneSelection(): void { + const stillSelectable = new Set(this.selectableRows.map((a) => a.id)); + for (const id of Array.from(this.selectedIds)) { + if (!stillSelectable.has(id)) { + this.selectedIds.delete(id); + } + } + // Same for the open detail pane: an alert filtered away or disposed of elsewhere should not + // leave the table holding a pane for a row that is no longer on it. + if (this.expandedId !== null && !this.alerts.data.some((a) => a.id === this.expandedId)) { + this.expandedId = null; + } + } + /** Every alert as returned, before the status filter narrows the table. */ private allAlerts: any[] = []; readonly statusFilters: { key: string; label: string }[] = [ { key: '', label: 'All alerts' }, { key: 'RAISED', label: 'Raised' }, - { key: 'UNDER_REVIEW', label: 'Under review' }, + // No "Under review" chip: nothing sets that status now that reading a row no longer records anything, so the + // chip would sit at zero for good. The status itself is kept — alerts predating this change still carry it, and + // canReview still treats them as open — but it is not offered as a filter nobody can populate. { key: 'ESCALATED', label: 'Escalated' }, { key: 'WHITELISTED', label: 'Whitelisted' } ]; activeStatus = ''; private statusCounts: Record = {}; + /** + * The period being worked. + * + * A monitoring feed accumulates: last week's alerts sit under today's, and an officer asked "what did we decide + * today" has to read past them. The period filter is what turns the feed into a day's, a week's or a month's work + * — and it composes with the status chips, so Today + Escalated answers "what did we mark suspicious today". + */ + readonly periodModes: { key: PeriodMode; label: string }[] = [ + { key: 'all', label: 'All time' }, + { key: 'day', label: 'Day wise' }, + { key: 'week', label: 'Week wise' }, + { key: 'month', label: 'Month wise' }, + { key: 'year', label: 'Year wise' } + ]; + periodMode: PeriodMode = 'all'; + + /** The chosen day / any day inside the chosen week / the chosen month / the chosen year. */ + filterDay = ''; + filterWeek = ''; + filterMonth = ''; + filterYear = ''; + + /** + * Narrows the table to the rules firing most often in the chosen period. + * + *

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

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

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

+ * The point of ranking rules is to see which ones are driving the queue, and a count per rule is that answer. + * Without it "Top 10" narrows the table and leaves the officer to work out what it kept. + */ + get ruleFrequency(): { code: string; count: number }[] { + const counts = new Map(); + for (const a of this.alerts.data) { + if (a?.ruleCode) { + counts.set(a.ruleCode, (counts.get(a.ruleCode) ?? 0) + 1); + } + } + return Array.from(counts.entries()) + .map( + ([ + code, + count + ]) => ({ code, count }) + ) + .sort((a, b) => b.count - a.count || a.code.localeCompare(b.code)); + } + + setPeriodMode(mode: PeriodMode): void { + this.periodMode = mode; + // Seeded with today the first time a mode is picked, so choosing "Month wise" shows this month rather than an + // empty field the officer has to fill before anything happens. + const now = new Date(); + const iso = this.isoOf(now); + if (mode === 'day' && !this.filterDay) { + this.filterDay = iso; + } + if (mode === 'week' && !this.filterWeek) { + this.filterWeek = iso; + } + if (mode === 'month' && !this.filterMonth) { + this.filterMonth = iso.slice(0, 7); + } + if (mode === 'year' && !this.filterYear) { + this.filterYear = String(now.getFullYear()); + } + this.applyStatusFilter(true); + } + + onPeriodChange(): void { + this.applyStatusFilter(true); + } + + /** The years the feed actually covers, newest first, so the picker offers nothing empty. */ + get availableYears(): string[] { + const years = new Set(); + for (const a of this.allAlerts) { + if (a.raisedOnDate) { + years.add(String(a.raisedOnDate.getFullYear())); + } + } + const list = Array.from(years).sort().reverse(); + return list.length ? list : [String(new Date().getFullYear())]; + } + + /** Reads back the resolved period, so the officer can see what "week wise" actually resolved to. */ + get periodLabel(): string { + const range = this.periodRange(); + if (!range) { + return ''; + } + return range.from === range.to ? range.from : `${range.from} to ${range.to}`; + } + + private isoOf(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + /** + * The chosen period as an inclusive from/to, or null for all time. + * + * Week runs Monday to Sunday. Built from local date parts rather than by parsing to UTC: an alert raised late in + * the evening is on that evening's date for the officer looking at it, and a UTC round-trip would move it. + */ + private periodRange(): { from: string; to: string } | null { + switch (this.periodMode) { + case 'day': + return this.filterDay ? { from: this.filterDay, to: this.filterDay } : null; + case 'week': { + if (!this.filterWeek) { + return null; + } + const [ + y, + m, + d + ] = this.filterWeek.split('-').map(Number); + const picked = new Date(y, m - 1, d); + const backToMonday = picked.getDay() === 0 ? 6 : picked.getDay() - 1; + const monday = new Date(y, m - 1, d - backToMonday); + const sunday = new Date(y, m - 1, d - backToMonday + 6); + return { from: this.isoOf(monday), to: this.isoOf(sunday) }; + } + case 'month': { + if (!this.filterMonth) { + return null; + } + const [ + y, + m + ] = this.filterMonth.split('-').map(Number); + // Day 0 of the next month is the last day of this one, which handles February and leap years without a table. + return { from: `${this.filterMonth}-01`, to: this.isoOf(new Date(y, m, 0)) }; + } + case 'year': + return this.filterYear ? { from: `${this.filterYear}-01-01`, to: `${this.filterYear}-12-31` } : null; + default: + return null; + } + } + + /** Whether an alert was raised inside the chosen period. */ + private inActivePeriod(alert: any): boolean { + const range = this.periodRange(); + if (!range) { + return true; + } + const d: Date | null = alert?.raisedOnDate; + if (!d) { + return false; + } + const iso = this.isoOf(d); + return iso >= range.from && iso <= range.to; + } + + /** + * The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or + * null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for + * the high-severity alerts that are still open. + */ + activeSeverity: number | null = null; + /** * Severity mix of the flagged transactions currently in the table. riskCategory is the severity * of the rule that fired, resolved server-side from the originating catalog entry — the same * field the Rules Master tab shows in its Risk Category column. */ - riskSummary: { label: string; count: number; cls: string }[] = []; + riskSummary: { key: number; label: string; count: number; cls: string }[] = []; /** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */ unclassifiedCount = 0; @@ -106,6 +628,18 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { @ViewChild(MatPaginator) paginator!: MatPaginator; + /** + * Binds the paginator as soon as the view exists. + * + *

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

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

+ * The filters are written into the subtitle and the file name for the same reason — a sheet that says "10 alerts" + * with nothing about which month it covers cannot be checked later by anyone, including the person who made it. + */ + async exportExcel(): Promise { + const rows = this.alerts.data; + if (!rows.length || this.exporting) { + return; + } + this.exporting = true; + try { + const scope = [ + this.periodLabel ? `Period: ${this.periodLabel}` : 'Period: all time', + `Status: ${this.activeStatus ? this.statusLabel(this.activeStatus) : 'all'}`, + `Severity: ${this.activeSeverity === null ? 'all' : this.severityLabel(this.activeSeverity)}`, + `Rules: ${this.topRules === null ? 'all' : `top ${this.topRules} by frequency`}` + ].join(' · '); + + await exportWorkbook( + [ + { + sheetName: 'Flagged Transactions', + title: 'AML — Flagged Transactions', + subtitle: `${scope} · ${rows.length} alert${rows.length === 1 ? '' : 's'} · generated ${new Date().toLocaleString('en-IN')}`, + headers: [ + 'Alert ID', + 'Raised On', + 'Customer', + 'Customer ID', + 'Transaction', + 'Txn ID', + 'Governing Rule', + 'Rule Description', + 'All Rules Matched', + 'Severity', + 'Amount', + 'Status', + 'Reviewed By', + 'Review Notes' + ], + rows: rows.map((a) => [ + a.id ?? null, + a.raisedOnDate ? a.raisedOnDate.toLocaleString('en-IN') : '', + a.clientDisplayName || '', + a.clientId ?? null, + a.transactionType || '', + a.transactionId ?? null, + a.ruleCode || '', + a.ruleDescription || '', + // Every rule the transaction tripped, not only the one it reports under: the governing rule alone can + // hide a high-severity finding that a reader of the sheet would need. + this.matchedRules(a) + .map((r) => r.ruleCode) + .join(', '), + this.severityLabel(this.severityBand(a)), + Number(a.amount) || 0, + a.status || '', + a.reviewedByUsername || '', + a.reviewNotes || '' + ]), + widths: [ + 9, + 20, + 24, + 12, + 12, + 10, + 30, + 38, + 44, + 11, + 15, + 14, + 14, + 50 + ] + } + ], + `AML-Flagged-${this.periodLabel || 'all-time'}` + ); + } finally { + this.exporting = false; + } + } + + private statusLabel(key: string): string { + return this.statusFilters.find((s) => s.key === key)?.label ?? key; } get summaryTotal(): number { - return this.alerts.data.length; + const pool = this.allAlerts.filter((a) => this.inActivePeriod(a)); + return this.activeStatus ? pool.filter((a) => this.matchesStatus(a, this.activeStatus)).length : pool.length; } private recomputeStatusCounts(): void { - this.statusCounts = { '': this.allAlerts.length }; + // Counted within the chosen day, so "Escalated 1" means one escalated that day rather than one in the whole feed. + // A chip whose figure describes a different set from the table under it is worse than no figure. + const pool = this.allAlerts.filter((a) => this.inActivePeriod(a)); + this.statusCounts = { '': pool.length }; for (const s of this.statusFilters) { if (s.key) { - this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length; + this.statusCounts[s.key] = pool.filter((a) => this.matchesStatus(a, s.key)).length; } } } + /** + * Whether an alert belongs under a status chip. + * + * Not an equality test, deliberately. Matching the chip's key exactly meant any status the chip list did not name + * belonged to no chip at all while still counting toward "All alerts" — so an alert could sit in the feed, need a + * decision, and appear nowhere an officer looks for work. That is what UNDER_REVIEW did here: five undecided + * alerts, the largest ₹18,00,000, behind a pending figure that read 1. + * + * Pending is therefore defined by what has not been decided rather than by one status name. Any status that is + * neither whitelisted nor escalated surfaces under Raised on its own, which is the safe direction to fail — an + * alert shown as work that turns out to be finished costs a glance; one hidden costs a filing. + */ + private matchesStatus(alert: any, key: string): boolean { + if (key === 'RAISED') { + return alert?.status !== 'WHITELISTED' && alert?.status !== 'ESCALATED'; + } + return alert?.status === key; + } + statusClass(status: string): string { return 'alert-status status-' + (status || 'raised').toLowerCase(); } @@ -302,7 +995,26 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { * opening a second STR case — this gate is what prevents it. */ canReview(alert: any): boolean { - return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW'; + if (alert?.status !== 'RAISED' && alert?.status !== 'UNDER_REVIEW') { + return false; + } + // An alert another officer has open is theirs until they decide it or hand it back. The endpoint refuses it + // anyway; hiding the buttons is what stops an officer working a case they will not be allowed to close. + return !this.heldByAnother(alert); + } + + /** True when this alert is under review by somebody other than the signed-in officer. */ + heldByAnother(alert: any): boolean { + return ( + alert?.status === 'UNDER_REVIEW' && + !!alert?.reviewedByUsername && + alert.reviewedByUsername !== this.currentUsername + ); + } + + /** Who has the alert, for the message shown in place of the action buttons. */ + heldBy(alert: any): string { + return alert?.reviewedByUsername || 'another officer'; } review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void { @@ -330,4 +1042,73 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { this.fetch(); }); } + + /** + * Applies one disposition to every ticked alert. + * + *

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

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

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

+
+
+

Risk Category Approvals

+

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

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

Reject Risk Category Change

+ +

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

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

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

Change Risk Category

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

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

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

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

+ +

{{ errorMessage }}

+
+ + + + + `, + styles: [ + ` + .rc-summary { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 14px; + background: #f8fafc; + } + .rc-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 3px 0; + } + .rc-label { + color: #64748b; + font-size: 12.5px; + } + .rc-value { + color: #1e293b; + font-size: 13.5px; + } + .rc-reason { + font-size: 12.5px; + line-height: 1.5; + color: #334155; + margin: 0 0 14px; + } + .rc-form { + display: flex; + flex-direction: column; + } + .rc-full { + width: 100%; + } + .rc-effect { + font-size: 12.5px; + line-height: 1.55; + color: #1e3a8a; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + padding: 10px 12px; + margin: 4px 0 0; + } + .rc-error { + margin: 12px 0 0; + padding: 10px 12px; + border: 1px solid #fecaca; + border-radius: 6px; + background: #fef2f2; + color: #b91c1c; + font-size: 12.5px; + } + ` + ] +}) +export class RiskChangeDialogComponent { + private fb = inject(FormBuilder); + private amlService = inject(AmlService); + private snackBar = inject(MatSnackBar); + dialogRef = inject(MatDialogRef); + + form: FormGroup; + submitting = false; + errorMessage = ''; + + constructor(@Inject(MAT_DIALOG_DATA) public data: RiskChangeDialogData) { + this.form = this.fb.group({ + // Defaulted to what the report recommends, but left editable: the reviewer has seen the + // customer and the computation has not. + requestedCategory: [ + data.recommendedCategory ?? null, + Validators.required + ], + reason: [ + '', + Validators.required + ] + }); + } + + labelOf(category: number | null): string { + return category === 1 ? 'Low' : category === 2 ? 'Medium' : category === 3 ? 'High' : 'Unrated'; + } + + submit(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + this.submitting = true; + this.errorMessage = ''; + const v = this.form.value; + this.amlService.requestRiskChange(this.data.clientId, v.requestedCategory, (v.reason || '').trim()).subscribe({ + next: () => { + this.submitting = false; + this.snackBar.open( + `Risk change to ${this.labelOf(v.requestedCategory)} sent for approval — ${this.data.customerName}`, + 'Close', + { duration: 5000 } + ); + this.dialogRef.close(true); + }, + error: (err: any) => { + this.submitting = false; + this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send the request.'; + } + }); + } +} diff --git a/src/app/risk-review/risk-review-data.ts b/src/app/risk-review/risk-review-data.ts index 633de824d..fea25ea07 100644 --- a/src/app/risk-review/risk-review-data.ts +++ b/src/app/risk-review/risk-review-data.ts @@ -372,13 +372,29 @@ export const MATERIALITY = { * Rules whose seriousness does not depend on the amount. A sanctions match or a * deliberate structuring pattern is grave at any value, so activity being small * is not a reason to bring the rating down. + * + * Matched as prefixes, not as whole codes. The rule set names these by variant — + * STRUCTURING_BELOW_50K, STRUCTURING_BELOW_10L, PEP_ACCOUNT_MONITORING — so an + * exact-match Set never matched any of them and the protection was dead: a + * customer caught structuring could be recommended for downgrade purely because + * the amounts involved were small, which is the one case structuring exists to + * catch. */ -export const NON_MITIGABLE_RULES = new Set([ - 'SANCTIONS_HIT', +export const NON_MITIGABLE_RULE_PREFIXES = [ + 'SANCTIONS', 'PEP', 'STRUCTURING', - 'HIGH_RISK_COUNTRY' -]); + 'STRUCTURED', + 'HIGH_RISK_COUNTRY', + 'ADVERSE_MEDIA', + 'WILFUL_DEFAULTER' +]; + +/** Whether a rule code names activity that stays grave however small the amounts are. */ +export function isNonMitigableRule(ruleCode: string): boolean { + const code = (ruleCode || '').toUpperCase(); + return NON_MITIGABLE_RULE_PREFIXES.some((prefix) => code.startsWith(prefix)); +} /** Shape of one element of GET /aml/alerts. */ export interface LiveAmlAlert { diff --git a/src/app/risk-review/risk-review-report.component.html b/src/app/risk-review/risk-review-report.component.html index 503cb0dad..9bef269f2 100644 --- a/src/app/risk-review/risk-review-report.component.html +++ b/src/app/risk-review/risk-review-report.component.html @@ -304,7 +304,9 @@ reason sits under it rather than under the button, because it explains this conclusion and not the act of opening the KYC screen. --> - + + {{ directionText(c) }} @@ -323,15 +325,32 @@ - + + @if (canChangeRisk(c)) { + + } @else { + + + } diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss index c4f9b6066..db236d881 100644 --- a/src/app/risk-review/risk-review-report.component.scss +++ b/src/app/risk-review/risk-review-report.component.scss @@ -937,6 +937,13 @@ background: var(--r-g100); color: var(--r-g500); } + + /* An unrated customer reads neither as settled nor as a movement: there is no rating to move. Amber says the row + is outstanding without claiming the customer is a concern — the gap is the bank's, not theirs. */ + &--unrated { + background: var(--r-amber-l, #fef3c7); + color: var(--r-amber, #b45309); + } } .rr-move-why { diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts index efa8d1880..ee9d5f2b9 100644 --- a/src/app/risk-review/risk-review-report.component.ts +++ b/src/app/risk-review/risk-review-report.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; import { forkJoin, of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; @@ -38,12 +39,14 @@ import { RiskLevel, channelLabel, mapLiveAlert, - riskFromCategory + riskFromCategory, + riskRank } from './risk-review-data'; import { DateRange, RatingDirection, RiskAlertView, + BehaviourWindowByClient, RiskCustomerRow, RiskReviewFilter, RiskReviewModel, @@ -53,6 +56,7 @@ import { monthRange, weekRange } from './risk-review.compute'; +import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component'; type PeriodMode = 'day' | 'week' | 'month' | 'range'; type ViewMode = 'customer' | 'alert' | 'txn'; @@ -109,6 +113,7 @@ export class RiskReviewReportComponent implements OnInit { private clients = inject(ClientsService); private scope = inject(BranchScopeService); private router = inject(Router); + private dialog = inject(MatDialog); readonly period = GL_PERIOD; readonly branches = GL_BRANCHES; @@ -181,21 +186,37 @@ export class RiskReviewReportComponent implements OnInit { // ── Loading ──────────────────────────────────────────────────────────────── + /** + * The nightly job's verdict per customer, keyed on client id. + * + * Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to + * its own per-alert reckoning rather than showing nothing. + */ + private behaviourWindows: BehaviourWindowByClient = {}; + load(): void { this.loading = true; - this.aml - .getAlerts() - .pipe(catchError(() => of([] as LiveAmlAlert[]))) - .subscribe((live) => { - const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert)); - this.liveAlerts = rows; - this.compose(); - if (!rows.length) { - this.loading = false; - return; + // Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a + // recommendation the screen then silently replaced. + forkJoin({ + alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))), + windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[]))) + }).subscribe(({ alerts: live, windows }) => { + this.behaviourWindows = {}; + for (const w of windows || []) { + if (w?.clientId != null) { + this.behaviourWindows[w.clientId] = w; } - this.enrichCurrentRisk(rows); - }); + } + const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert)); + this.liveAlerts = rows; + this.compose(); + if (!rows.length) { + this.loading = false; + return; + } + this.enrichCurrentRisk(rows); + }); } /** @@ -341,9 +362,11 @@ export class RiskReviewReportComponent implements OnInit { private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY }; private get model(): RiskReviewModel { - const key = JSON.stringify(this.filter) + `|${this.alerts.length}`; + // The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same + // load as the alerts. Keying on the alerts alone would serve the memo computed before they landed. + const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`; if (key !== this.memoKey) { - this.memo = buildRiskReview(this.alerts, this.filter); + this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows); this.memoKey = key; } return this.memo; @@ -369,10 +392,63 @@ export class RiskReviewReportComponent implements OnInit { * a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is * only a correction, and matching ratings need nothing at all. */ + /** + * A report row for a customer the behaviour review named but no alert brought onto the report. + * + *

+ * The alert-derived fields are empty because there are none in the window, and that is the point being shown: the + * case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on + * behaviourWindow, so the row can still explain itself. + */ + private rowFromWindow(w: any): RiskCustomerRow { + const RANK: Record = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' }; + const current = RANK[w.riskCategoryAtReview] ?? null; + const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW'; + return { + clientId: w.clientId, + customerName: w.clientName || `Client ${w.clientId}`, + accountNo: w.accountNo || '', + branchCode: w.officeName || '', + currentRisk: current, + alerts: [], + alertCount: 0, + totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0), + largestAmount: Number(w.largestTxn || 0), + proposedRisk: recommended, + recommendedRisk: recommended, + direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same', + directionReason: w.suggestionReason || '', + ruleLabels: [], + firstAlertDate: '', + lastAlertDate: '', + openCount: 0, + escalated: false, + needsUpgrade: w.suggestedDirection === 'UP', + needsDowngrade: w.suggestedDirection === 'DOWN', + behaviourWindow: w + }; + } + get customerGroups(): RiskCustomerGroup[] { const rows = this.customerRows; - const escalated = rows.filter((c) => c.escalated); - const rest = rows.filter((c) => !c.escalated); + // An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting + // on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change + // somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under + // the Principal Officer with nothing to do. + const escalated = rows.filter((c) => c.escalated && c.direction === 'same'); + const rest = rows.filter((c) => !(c.escalated && c.direction === 'same')); + + // Customers the nightly review says to move who raised no alert inside the filter window. + // + // The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the + // wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the + // customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they + // would sit in the behaviour table with a verdict nobody ever sees. + const onReport = new Set(rows.map((r) => r.clientId)); + const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows) + .filter((w: any) => w?.clientId != null && !onReport.has(w.clientId)) + .filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN') + .map((w: any) => this.rowFromWindow(w)); const groups: RiskCustomerGroup[] = [ { @@ -383,25 +459,42 @@ export class RiskReviewReportComponent implements OnInit { rows: escalated }, { - key: 'up', - title: 'Rating needs raising', - hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.', + // Raising and lowering are one section, not two. The heading answers "is there anything to do about this + // customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason. + // Splitting them put the same answer in two places and left an officer reading two lists to find one day's + // work. Raises sort first within the section, because an under-rated customer is the more urgent gap. + key: 'change', + title: 'Change needed', + hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.', tone: 'up', - rows: rest.filter((c) => c.direction === 'up') + rows: [ + ...rest.filter((c) => c.currentRisk && c.direction === 'up'), + ...offReport.filter((c) => c.direction === 'up'), + ...rest.filter((c) => c.currentRisk && c.direction === 'down'), + ...offReport.filter((c) => c.direction === 'down') + ] }, { - key: 'down', - title: 'Rating can be lowered', - hint: 'Activity no longer supports the rating on file.', - tone: 'down', - rows: rest.filter((c) => c.direction === 'down') + // Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose + // money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it + // is in keeping - the work is to give them one, and grouping them with the customers nothing need be done + // about buried the one action the report was asking for. + key: 'unrated', + title: 'Update risk', + hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.', + tone: 'up', + rows: rest.filter((c) => !c.currentRisk) }, { + // Everything an alert put on the report that the policy will not move yet. "No change needed" read as though + // these customers were fine, which is not what it means: a customer with four open alerts sits here, and the + // reason is that the period does not yet carry enough to act on - not that there is nothing to look at. + // "Watching" says what the section is for, and the reason on each row says which condition fell short. key: 'same', - title: 'No change needed', - hint: 'The rating on file already matches the behaviour. Listed for the record.', + title: 'Watching — not enough to act on', + hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.', tone: 'hold', - rows: rest.filter((c) => c.direction === 'same') + rows: rest.filter((c) => c.currentRisk && c.direction === 'same') } ]; @@ -580,6 +673,67 @@ export class RiskReviewReportComponent implements OnInit { ]); } + /** + * Sends a case that is with the Principal Officer to the STR queue. + * + * The rating cannot be moved while an STR is open, so the useful thing to offer is the case + * itself. Sending the reviewer to the customer's KYC record instead made them land on a screen + * where the one action they might take is the one thing they must not do yet. + */ + openStrCase(c: RiskCustomerRow, event: Event): void { + event.stopPropagation(); + this.router.navigate(['/fiu'], { queryParams: { tab: 'str' } }); + } + + /** + * Whether this row's rating can be moved from here. + * + * A case with the Principal Officer is deliberately excluded: the rating is held where it is + * until the STR concludes, which is what the section heading on that group says. Offering the + * button there would invite a change nobody is in a position to make yet. + */ + canChangeRisk(c: RiskCustomerRow): boolean { + return !c.escalated; + } + + /** + * Raises a rating change for approval, rather than sending the reviewer to the KYC screen to make + * it themselves. + * + * The button used to navigate away: the reviewer left the report, found the customer, edited the + * record, and the change took effect on their say-so alone with no second pair of eyes and no + * record of why. The rating governs re-KYC frequency and AML thresholds, so it goes through a + * checker like every other decision of that weight. + */ + changeRisk(c: RiskCustomerRow, event: Event): void { + event.stopPropagation(); + if (!this.canChangeRisk(c)) { + return; + } + this.dialog + .open(RiskChangeDialogComponent, { + width: '520px', + maxWidth: '95vw', + disableClose: true, + data: { + clientId: c.clientId, + customerName: c.customerName, + currentCategory: riskRank(c.currentRisk) || null, + recommendedCategory: riskRank(c.recommendedRisk) || null, + recommendationReason: c.directionReason + } as RiskChangeDialogData + }) + .afterClosed() + .subscribe((sent) => { + if (sent) { + // The rating has not moved yet - only a request exists - so the figures on screen are + // still correct. Reloaded anyway so a second click cannot raise a duplicate request the + // server would refuse. + this.load(); + } + }); + } + /** * What the reviewer is going to the KYC screen to do. * @@ -594,25 +748,41 @@ export class RiskReviewReportComponent implements OnInit { * change nobody is authorised to make yet. */ directionText(c: RiskCustomerRow): string { - if (c.escalated) { + // Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one + // filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports + // the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing + // needed doing. + if (c.escalated && c.direction === 'same') { return 'Held — with Principal Officer'; } - if (c.direction === 'up') { - return `Raise to ${RISK_LABEL[c.recommendedRisk]}`; + // Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the + // officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in + // one glance, and on an exported sheet it still says what happened without the column beside it. + if (c.direction === 'up' || c.direction === 'down') { + const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated'; + return `${from} → ${RISK_LABEL[c.recommendedRisk]}`; } - if (c.direction === 'down') { - return `Lower to ${RISK_LABEL[c.recommendedRisk]}`; - } - return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record'; + // An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops + // there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most. + return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer'; } directionIcon(c: RiskCustomerRow): string { - if (c.escalated) { + // Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the + // rating still has to be moved. + if (c.escalated && c.direction === 'same') { return 'lock'; } - // 'check' rather than an equals sign: faEquals is not in the shared library, and an - // unregistered icon throws at render and takes the whole row down with it. - return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check'; + if (c.direction === 'up') { + return 'arrow-up'; + } + if (c.direction === 'down') { + return 'arrow-down'; + } + // A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question + // rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered + // names resolve, and an unregistered icon throws at render and takes the whole row down with it. + return c.currentRisk ? 'check' : 'exclamation-circle'; } /** diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts index 67499a2bc..215fcac21 100644 --- a/src/app/risk-review/risk-review.compute.ts +++ b/src/app/risk-review/risk-review.compute.ts @@ -10,7 +10,7 @@ import { AmlRuleMeta, CTR_THRESHOLD, MATERIALITY, - NON_MITIGABLE_RULES, + isNonMitigableRule, REVIEW_STATUS_META, RISK_LABEL, RiskAlertRow, @@ -26,6 +26,17 @@ export type RatingDirection = 'up' | 'down' | 'same'; const RANK_TO_LEVEL: Record = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' }; +/** + * What has to have been seen before this screen's own reckoning will argue for lowering a rating. + * + * The nightly behaviour review is the real test — it reads a 270-day window, counts transactions rather than alerts, + * and applies the blocks. These are the floor for the fallback used until that job has run: deliberately modest, + * because the fallback sees only the alerts inside the report's date filter, but not absent, because judging on + * amounts alone let three small deposits in one month argue for taking a customer off Medium. + */ +const MIN_ALERTS_TO_LOWER = 5; +const MIN_MONTHS_TO_LOWER = 3; + export interface DateRange { from: string; to: string; @@ -109,6 +120,8 @@ export interface RiskCustomerRow { needsUpgrade: boolean; /** True when the rating on record is higher than the activity warrants. */ needsDowngrade: boolean; + /** The review period the recommendation was drawn from, where the nightly job has computed one. */ + behaviourWindow?: BehaviourWindowByClient[number] | null; } export interface RiskReviewSummary { @@ -155,17 +168,26 @@ function recommendRating( largestAmount: number, totalAmount: number, escalated: boolean, - ruleCodes: string[] + ruleCodes: string[], + alertCount: number, + distinctMonths: number ): { risk: RiskLevel; reason: string } { const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW'; - const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c)); + const grave = ruleCodes.find((c) => isNonMitigableRule(c)); const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal; - const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal; + + // Small amounts are not on their own a reason to lower a rating. A rating describes a period of behaviour, so + // bringing one down needs a period to have been observed: enough transactions to be a pattern, and spread over + // enough months that they are not one busy week. Judging on amounts alone let three small deposits in a single + // month argue for taking a customer off Medium — which is the same mistake as re-rating on one transaction, only + // three times over. + const enoughActivity = alertCount >= MIN_ALERTS_TO_LOWER && distinctMonths >= MIN_MONTHS_TO_LOWER; + const small = enoughActivity && largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal; // What the rating should be. Amounts only ever pull the rules' bound down, and // only where nothing weightier holds it up. let risk: RiskLevel; - let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate'; + let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate' | 'thin'; if (escalated) { risk = 'HIGH'; basis = 'escalated'; @@ -175,6 +197,12 @@ function recommendRating( } else if (material) { risk = implied; basis = 'material'; + } else if (!enoughActivity) { + // Too little observed to say anything about the rating either way, so it stands. Stating this rather than + // quietly leaving the rating alone is the point: an officer reading the row learns the period was too thin, + // not that the customer was examined and found unremarkable. + risk = currentRisk ?? implied; + basis = 'thin'; } else { risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW'; basis = small ? 'small' : 'moderate'; @@ -218,6 +246,27 @@ function recommendRating( }; } + if (basis === 'thin') { + // A customer with no rating is not waiting for more evidence — there is no rating for evidence to move. Telling + // them the period is too thin, under a verdict that says "Rate this customer", asks for the work and argues + // against it in the same row. The thin-period test is about lowering a rating that exists. + if (!currentRisk) { + return { + risk, + reason: `Transacting with no rating on file — ${inr(totalAmount)} across ${alertCount} alert${ + alertCount === 1 ? '' : 's' + }. Rate the customer; behaviour can only be judged against a rating.` + }; + } + // Says which of the two tests fell short, because "not enough activity" leaves an officer unable to tell whether + // the customer needs watching for another month or another five. + const shortOn = + alertCount < MIN_ALERTS_TO_LOWER + ? `only ${alertCount} alert${alertCount === 1 ? '' : 's'} in the period, ${MIN_ALERTS_TO_LOWER} needed` + : `activity falls in ${distinctMonths} month${distinctMonths === 1 ? '' : 's'}, ${MIN_MONTHS_TO_LOWER} separate months needed`; + return { risk, reason: `Too little to re-rate on — ${shortOn}. ${rated}; it stands until more is seen.` }; + } + // The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank // read as "not assessed yet" on screen, which is the opposite of what the review concluded. return { @@ -271,7 +320,36 @@ export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter) * escalated, then rated below what the rules point at, then most alerts — * come to the top of the worklist. */ -export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] { +/** + * The behaviour window the nightly job computed for one customer, where there is one. + * + * Keyed on client id. A customer with no window - one the job has not seen yet, or a tenant where the job has never + * run - falls back to the per-alert reckoning below, so the report renders either way. + */ +export interface BehaviourWindowByClient { + [clientId: number]: { + clientId: number; + clientName: string | null; + accountNo: string | null; + officeName: string | null; + riskCategoryAtReview: number | null; + txnCount: number | null; + distinctMonths: number | null; + windowFrom: any; + windowTo: any; + creditTotal: number | null; + debitTotal: number | null; + largestTxn: number | null; + cashTotal: number | null; + alertCount: number | null; + strCaseCount: number | null; + suggestedDirection: string | null; + suggestedCategory: number | null; + suggestionReason: string | null; + }; +} + +export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindowByClient = {}): RiskCustomerRow[] { const byClient = new Map(); for (const v of views) { const list = byClient.get(v.clientId); @@ -297,19 +375,59 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] { const largestAmount = Math.max(...alerts.map((a) => a.amount)); const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED'); - const recommendation = recommendRating( - proposedRank, - currentRisk, - largestAmount, - totalAmount, - escalated, - alerts.map((a) => a.ruleCode) - ); + // The server's own reckoning, where the nightly job has produced one. + // + // It is the better answer, and not because it is newer: it is drawn from a review period rather than from the + // alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts + // alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is + // supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the + // activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids + // lowering it. + // + // Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert + // reckoning below still stands, so the report never renders blank while the two are in step. + // A window is only good for as long as the rating it was computed against. The job takes a snapshot each night + // and records the rating the customer held at the time; once an officer moves that rating the snapshot describes + // a customer who no longer exists, and the screen would go on asking for a change that has already been applied + // and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up. + const raw = windows[clientId]; + const staleWindow = + raw?.riskCategoryAtReview != null && + currentRisk != null && + RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk; + const window = staleWindow ? undefined : raw; + const recommendation = window?.suggestedDirection + ? { + risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW', + reason: window.suggestionReason ?? '' + } + : recommendRating( + proposedRank, + currentRisk, + largestAmount, + totalAmount, + escalated, + alerts.map((a) => a.ruleCode), + alerts.length, + // Distinct calendar months the alerts fall in. Count alone does not establish a period: five alerts in one + // week is a busy fortnight, not five months of steady conduct. + new Set(alerts.map((a) => a.alertDate.slice(0, 7))).size + ); // Where the customer carries no rating there is nothing to move, so an // unrated customer is never counted as an upgrade or a downgrade. const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0; - const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same'; + const direction: RatingDirection = window?.suggestedDirection + ? window.suggestedDirection === 'UP' + ? 'up' + : window.suggestedDirection === 'DOWN' + ? 'down' + : 'same' + : delta > 0 + ? 'up' + : delta < 0 + ? 'down' + : 'same'; rows.push({ clientId, @@ -332,7 +450,10 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] { openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length, escalated, needsUpgrade: direction === 'up', - needsDowngrade: direction === 'down' + needsDowngrade: direction === 'down', + // Carried through so the screen can show the period a recommendation was drawn from. A rating an officer is + // asked to approve has to show its working, and the alerts on this row are not it. + behaviourWindow: window ?? null }); } @@ -461,9 +582,13 @@ export interface RiskReviewModel { * the summary all run once per filter change rather than once per template * binding, which matters because every view reads from the same result. */ -export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel { +export function buildRiskReview( + alerts: RiskAlertRow[], + filter: RiskReviewFilter, + windows: BehaviourWindowByClient = {} +): RiskReviewModel { const base = riskAlertViews(alerts, filter); - const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction); + const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction); return { alerts: narrowed.views, diff --git a/src/app/shared/icons.module.ts b/src/app/shared/icons.module.ts index 07f2b7ee5..cce399f5a 100644 --- a/src/app/shared/icons.module.ts +++ b/src/app/shared/icons.module.ts @@ -56,6 +56,7 @@ import { faGavel, faGlobe, faHandHoldingUsd, + faHandPaper, faHome, faKey, faLink, @@ -303,6 +304,7 @@ export class IconsModule { faHandHoldingHand, faHandHoldingHeart, faHandHoldingUsd, + faHandPaper, faHeart, faHome, faKey, diff --git a/src/app/shares/common-resolvers/share-account-actions.resolver.ts b/src/app/shares/common-resolvers/share-account-actions.resolver.ts index 21749db82..24679e699 100644 --- a/src/app/shares/common-resolvers/share-account-actions.resolver.ts +++ b/src/app/shares/common-resolvers/share-account-actions.resolver.ts @@ -32,7 +32,6 @@ export class ShareAccountActionsResolver { const actionName = route.paramMap.get('name'); const shareAccountId = route.paramMap.get('shareAccountId') || route.parent.parent.paramMap.get('shareAccountId'); switch (actionName) { - case 'Apply Additional Shares': case 'Redeem Shares': case 'Approve Additional Shares': case 'Reject Additional Shares': diff --git a/src/app/shares/reports/share-account-statement/share-account-statement.component.html b/src/app/shares/reports/share-account-statement/share-account-statement.component.html new file mode 100644 index 000000000..8e2f1d259 --- /dev/null +++ b/src/app/shares/reports/share-account-statement/share-account-statement.component.html @@ -0,0 +1,209 @@ + + +

+
+
+ +
+

Share Account Statement

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

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

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

+
+
+ +
+

Area Wise Shareholding

+
+ Where the society's members and its share capital sit — grouped by the member's own recorded address. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.areaCount | number }}
+
Areas
+
+
+
+ +
+
{{ totals.memberCount | number }}
+
Members
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+ +
+
{{ totals.shares | number }}
+
Shares Held
+
+
+
+ } + + @if (loading) { +
Loading area-wise shareholding…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!members.length) { +
No member holds share capital yet.
+ } @else { + +
+ + + + + + + + + + + + + @for (a of areas; track a.area) { + + + + + + + + + } + + + + + + + + + + + +
AreaMembersCapitalSharesShare Value% of Capital
+ {{ a.area }} + @if (areaFilter === a.area) { + filtered + } + {{ a.memberCount | number }} +
+
{{ a.shares | number }}₹{{ a.shareValue | number: '1.2-2' }}{{ capitalShare(a) | number: '1.2-2' }}%
All areas{{ totals?.memberCount | number }}{{ totals?.shares | number }}₹{{ totals?.shareValue | number: '1.2-2' }}100.00%
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + @if (!filteredMembers.length) { +
No member matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + @for (m of filteredMembers; track m.clientId) { + + + + + + + + + + + } + +
AreaMemberMembership NoMobileBranchPostal CodeShares HeldShare Value
{{ m.area }}{{ m.clientName }}{{ m.cifNumber || '—' }}{{ m.mobileNo || '—' }}{{ m.officeName || '—' }}{{ m.postalCode || '—' }}{{ m.sharesHeld | number }}₹{{ m.shareValue | number: '1.2-2' }}
+
+ } + } +
diff --git a/src/app/shares/reports/share-area-wise/share-area-wise.component.ts b/src/app/shares/reports/share-area-wise/share-area-wise.component.ts new file mode 100644 index 000000000..d2abd920e --- /dev/null +++ b/src/app/shares/reports/share-area-wise/share-area-wise.component.ts @@ -0,0 +1,188 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface AreaRow { + area: string; + memberCount: number; + shares: number; + shareValue: number; +} + +interface AreaMemberRow { + clientId: number; + cifNumber: string; + clientName: string; + mobileNo: string; + officeName: string; + area: string; + postalCode: string; + sharesHeld: number; + shareValue: number; +} + +/** + * Area Wise — where the society's members and its share capital actually sit. + * + *

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

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

+
+
+ +
+

Share Detail

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

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

+
+
+ +
+

Share Distinctive Number Detail

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

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

+
+
+ +
+

Dividend Statement

+
+ Every dividend declared to a member, year by year, with what has been paid and what is still owed. +
+
+
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + @if (loadError) { +
{{ loadError }}
+ } @else if (searching) { +
Loading the statement…
+ } @else if (matches.length) { +
{{ matches.length }} members match. Choose one:
+
+ + + + + + + + + + @for (m of matches; track m.clientId) { + + + + + + } + +
MemberMembership No
{{ m.clientName }}{{ m.cifNumber || '—' }}Open
+
+ } @else if (searched && !member) { +
No member with a share account matches "{{ search }}".
+ } @else if (member) { +
+
+
+
Member
+
{{ member.clientName }}
+
+
+
Membership No
+
{{ member.cifNumber || '—' }}
+
+
+
Branch
+
{{ member.officeName || '—' }}
+
+
+
+ + @if (totals) { +
+
+ +
+
{{ totals.declarationCount | number }}
+
Declarations
+
+
+
+ +
+
₹{{ totals.declared | number: '1.2-2' }}
+
Total Declared
+
+
+
+ +
+
₹{{ totals.paid | number: '1.2-2' }}
+
Paid
+
+
+
+ +
+
₹{{ totals.outstanding | number: '1.2-2' }}
+
Still Owed
+
+
+
+ } + + @if (!rows.length) { +
No dividend has been declared to this member yet.
+ } @else { +
+ + + + + + + + + + + + + + @for (row of rows; track row.dividendDetailId) { + + + + + + + + + + } + + + + + + + + +
Dividend PeriodShare A/c NoProductRateShares Held NowAmountStatus
{{ row.periodStart }} — {{ row.periodEnd }}{{ row.accountNo }}{{ row.productName }}{{ row.ratePercentage ? (row.ratePercentage | number: '1.2-2') + '%' : '—' }}{{ row.sharesHeldNow | number }}₹{{ row.amount | number: '1.2-2' }} + {{ + statusLabel(row.statusEnum) + }} +
Total declared₹{{ totals?.declared | number: '1.2-2' }}
+
+ } + } @else { +
Search for a member above to see their dividend statement.
+ } +
diff --git a/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts b/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts new file mode 100644 index 000000000..811f790be --- /dev/null +++ b/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts @@ -0,0 +1,151 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SHARE_DIVIDEND_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util'; + +/** + * Dividend Statement — every dividend declared to one member, with what has been paid and what is + * still owed. + * + *

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

+
+
+ +
+

Share Enquiry

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

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

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

+
+
+ +
+

Share Final Balance

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

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

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

+
+
+ +
+

Folio Statement

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

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

+
+
+ +
+

Guarantor Detail

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

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

+
+
+ +
+

Share Issued Register

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

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

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

+
+
+ +
+

Share Labels

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

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

+
+
+ +
+

Share Letter

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

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

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

+
+
+ +
+

Total Interest Received & Receivable

+
+ Interest earned from the borrowings of the members who hold the society's share capital. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
₹{{ totals.interestReceived | number: '1.2-2' }}
+
Interest Received
+
+
+
+ +
+
₹{{ totals.interestReceivable | number: '1.2-2' }}
+
Interest Receivable
+
+
+
+ +
+
{{ totals.borrowingMembers | number }} / {{ totals.memberCount | number }}
+
Borrowing Members
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Their Share Capital
+
+
+
+ } + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading interest figures…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No member holds share capital yet.
+ } @else if (!filteredRows.length) { +
No member matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.clientId) { + + + + + + + + + + + + + + + } + + + + + + + + + + + + + + + +
MemberMembership NoBranchSharesShare ValueLoansPrincipal O/SInterest ChargedInterest ReceivedInterest ReceivableWaivedWritten Off
{{ row.clientName }}{{ row.cifNumber || '—' }}{{ row.officeName || '—' }}{{ row.sharesHeld | number }}₹{{ row.shareValue | number: '1.2-2' }}{{ row.loanCount | number }}₹{{ row.principalOutstanding | number: '1.2-2' }}₹{{ row.interestCharged | number: '1.2-2' }}₹{{ row.interestReceived | number: '1.2-2' }}₹{{ row.interestReceivable | number: '1.2-2' }}{{ row.interestWaived ? '₹' + (row.interestWaived | number: '1.2-2') : '—' }} + {{ row.interestWrittenOff ? '₹' + (row.interestWrittenOff | number: '1.2-2') : '—' }} +
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} members + {{ sum('sharesHeld') | number }}₹{{ sum('shareValue') | number: '1.2-2' }}{{ sum('loanCount') | number }}₹{{ sum('principalOutstanding') | number: '1.2-2' }}₹{{ sum('interestCharged') | number: '1.2-2' }}₹{{ sum('interestReceived') | number: '1.2-2' }}₹{{ sum('interestReceivable') | number: '1.2-2' }}₹{{ sum('interestWaived') | number: '1.2-2' }}₹{{ sum('interestWrittenOff') | number: '1.2-2' }}
+
+ } +
diff --git a/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts new file mode 100644 index 000000000..e87fe1544 --- /dev/null +++ b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts @@ -0,0 +1,197 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface MemberInterestRow { + clientId: number; + cifNumber: string; + clientName: string; + officeName: string; + sharesHeld: number; + shareValue: number; + loanCount: number; + principalLent: number; + principalOutstanding: number; + interestCharged: number; + interestReceived: number; + interestReceivable: number; + interestWaived: number; + interestWrittenOff: number; +} + +/** + * Total Interest Received & Receivable — what the society has earned in interest from the borrowings + * of the members who hold its share capital. + * + *

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

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

+
+
+ +
+

Nominal Share Report

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

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

+
+
+ +
+

Share Payment Certificate

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

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

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

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

+ * Every tranche is shown rather than one total — a member who paid in instalments paid on several + * dates against several receipts, and a certificate carrying a single figure reconciles to none of + * them. + */ +@Component({ + selector: 'mifosx-share-payment-certificate', + templateUrl: './share-payment-certificate.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class SharePaymentCertificateComponent implements OnInit { + private sharesService = inject(SharesService); + private organizationService = inject(OrganizationService); + private route = inject(ActivatedRoute); + + accountSearch = ''; + accountOptions: any[] = []; + selectedAccountId: number | null = null; + + loading = false; + loadError = ''; + account: any = null; + payments: any[] = []; + charges: any[] = []; + totals: any = null; + + /** The society's own name and address, so the printed certificate is on its letterhead. */ + institution: any = null; + + private searchTerm$ = new Subject(); + + ngOnInit(): void { + this.searchTerm$ + .pipe( + debounceTime(350), + distinctUntilChanged(), + switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([])))) + ) + .subscribe((res: any) => { + this.accountOptions = Array.isArray(res) ? res : res?.pageItems || []; + }); + + this.organizationService + .getInstitutionProfile() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => (this.institution = res)); + + const preselected = this.route.snapshot.queryParamMap.get('shareAccountId'); + if (preselected) { + this.selectedAccountId = Number(preselected); + this.load(); + } + this.searchTerm$.next(''); + } + + onAccountSearch(term: string): void { + this.accountSearch = term; + this.searchTerm$.next(term.trim()); + } + + selectAccount(option: any): void { + this.selectedAccountId = option.id; + this.accountOptions = []; + this.accountSearch = `${option.clientName} — ${option.accountNo}`; + this.load(); + } + + load(): void { + if (!this.selectedAccountId) { + return; + } + this.loading = true; + this.loadError = ''; + this.sharesService + .getSharePaymentCertificate(this.selectedAccountId) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The payment certificate could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.account = res?.account || null; + this.payments = res?.payments || []; + this.charges = res?.charges || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + statusClass(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + funding(source: string): string { + return fundingLabel(source); + } + + /** Whatever identifies the money that came in: a voucher, else the receipt, else the route. */ + receiptRef(payment: any): string { + return ( + payment.voucherNo || payment.receiptNo || (payment.fundingSource === 'SAVINGS' ? 'Debited from savings' : '—') + ); + } + + /** Amount in words, because a certificate carrying a figure alone is trivially altered. */ + get amountInWords(): string { + const amount = Math.round(Number(this.totals?.totalPaid || 0)); + return amount ? `${this.toWords(amount)} rupees only` : ''; + } + + private toWords(value: number): string { + const ones = [ + '', + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + 'seven', + 'eight', + 'nine', + 'ten', + 'eleven', + 'twelve', + 'thirteen', + 'fourteen', + 'fifteen', + 'sixteen', + 'seventeen', + 'eighteen', + 'nineteen' + ]; + const tens = [ + '', + '', + 'twenty', + 'thirty', + 'forty', + 'fifty', + 'sixty', + 'seventy', + 'eighty', + 'ninety' + ]; + + const twoDigits = (n: number): string => + n < 20 ? ones[n] : `${tens[Math.floor(n / 10)]}${n % 10 ? '-' + ones[n % 10] : ''}`; + + // Indian grouping — a co-operative's certificates read in lakh and crore, not million. + const parts: string[] = []; + const crore = Math.floor(value / 10000000); + const lakh = Math.floor((value % 10000000) / 100000); + const thousand = Math.floor((value % 100000) / 1000); + const hundred = Math.floor((value % 1000) / 100); + const rest = value % 100; + + if (crore) { + parts.push(`${twoDigits(crore)} crore`); + } + if (lakh) { + parts.push(`${twoDigits(lakh)} lakh`); + } + if (thousand) { + parts.push(`${twoDigits(thousand)} thousand`); + } + if (hundred) { + parts.push(`${ones[hundred]} hundred`); + } + if (rest) { + parts.push(twoDigits(rest)); + } + const words = parts.join(' ').trim(); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Zero'; + } + + get today(): string { + const now = new Date(); + return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`; + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-product-summary/share-product-summary.component.html b/src/app/shares/reports/share-product-summary/share-product-summary.component.html new file mode 100644 index 000000000..5ef7dcd6b --- /dev/null +++ b/src/app/shares/reports/share-product-summary/share-product-summary.component.html @@ -0,0 +1,168 @@ + + +

+
+
+ +
+

Share Product Summary

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

+ * "Issued" is paid-up shares — settled purchases less redemptions, computed the same way every other + * report in this suite computes them — not {@code m_share_product.totalsubscribed_shares}. That + * platform column counts an allotment the moment it is submitted, before any money has necessarily + * arrived, so it runs ahead of what the society has actually received. A board reading this page is + * reading the same figure the GL Share Capital account would show, not a subscription count. + */ +@Component({ + selector: 'mifosx-share-product-summary', + templateUrl: './share-product-summary.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FaIconComponent + ] +}) +export class ShareProductSummaryComponent implements OnInit { + private sharesService = inject(SharesService); + + loading = true; + loadError = ''; + rows: ProductSummaryRow[] = []; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareProductSummary() + .pipe( + catchError((err: any) => { + this.loadError = this.readError(err, 'The product summary could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.loading = false; + }); + } + + /** How much of a product's authorised capital is actually issued, for the progress bar. */ + utilisationPercent(row: ProductSummaryRow): number { + if (!row.authorisedShares) { + return 0; + } + const pct = (row.issuedShares / row.authorisedShares) * 100; + return Math.max(0, Math.min(100, pct)); + } + + distinctiveRange(row: ProductSummaryRow): string { + if (!row.distinctiveFrom || !row.distinctiveTo) { + return 'None allotted yet'; + } + return `${row.distinctiveFrom} – ${row.distinctiveTo}`; + } + + get totals() { + return { + authorisedShares: this.rows.reduce((sum, r) => sum + Number(r.authorisedShares || 0), 0), + authorisedCapital: this.rows.reduce((sum, r) => sum + Number(r.authorisedCapital || 0), 0), + issuedShares: this.rows.reduce((sum, r) => sum + Number(r.issuedShares || 0), 0), + issuedCapital: this.rows.reduce((sum, r) => sum + Number(r.issuedCapital || 0), 0), + unpaidShares: this.rows.reduce((sum, r) => sum + Number(r.unpaidShares || 0), 0), + unpaidAmount: this.rows.reduce((sum, r) => sum + Number(r.unpaidAmount || 0), 0) + }; + } + + exportCsv(): void { + shareReportCsv( + 'share-product-summary', + [ + 'Product', + 'Unit Price', + 'Authorised Shares', + 'Authorised Capital', + 'Issued Shares', + 'Issued Capital', + 'Available Shares', + 'Unpaid Accounts', + 'Unpaid Shares', + 'Unpaid Amount', + 'Distinctive Range', + 'Accounts', + 'Members', + 'Min / Nominal / Max Per Member' + ], + this.rows.map((r) => [ + r.productName, + r.unitPrice, + r.authorisedShares, + r.authorisedCapital, + r.issuedShares, + r.issuedCapital, + r.availableShares, + r.unpaidAccounts, + r.unpaidShares, + r.unpaidAmount, + this.distinctiveRange(r), + r.accountCount, + r.memberCount, + `${r.minimumClientShares ?? '—'} / ${r.nominalClientShares ?? '—'} / ${r.maximumClientShares ?? '—'}` + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-report.shared.scss b/src/app/shares/reports/share-report.shared.scss new file mode 100644 index 000000000..b837ce60f --- /dev/null +++ b/src/app/shares/reports/share-report.shared.scss @@ -0,0 +1,511 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Shared table, pill and print styling for the share reports. + * + * Layered on top of certificates.shared.scss, which already supplies the page frame + * (.cert-root / .cert-head / .cert-kpis / .cert-filters). Only what those do not cover lives here. + */ + +$srep-border: #e2e8f0; +$srep-border-soft: #f1f5f9; +$srep-head-bg: #f8fafc; +$srep-text: #0f172a; +$srep-muted: #64748b; + +.srep-table-wrap { + overflow-x: auto; + border: 1px solid $srep-border; + border-radius: 10px; + background: #fff; +} + +.srep-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; + + th { + position: sticky; + top: 0; + z-index: 1; + background: $srep-head-bg; + color: $srep-muted; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + text-align: left; + padding: 0.55rem 0.7rem; + border-bottom: 1px solid $srep-border; + white-space: nowrap; + } + + td { + padding: 0.5rem 0.7rem; + border-bottom: 1px solid $srep-border-soft; + color: $srep-text; + vertical-align: middle; + } + + tbody tr:last-child td { + border-bottom: none; + } + + tbody tr.srep-row--click { + cursor: pointer; + + &:hover { + background: #f4f7fb; + } + } + + // A row that is over — closed, rejected, never activated — still belongs on the register, but it + // must not read like a live holding. Dimmed and marked at the edge rather than hidden, because a + // closed account that silently disappears is its own kind of confusion at a counter. + tbody tr.srep-row--muted { + background: #fafbfc; + color: $srep-muted; + + td { + color: $srep-muted; + } + + td:first-child { + box-shadow: inset 3px 0 0 #cbd5e1; + } + } + + // Allotted but never collected. Flagged, not dimmed: this one is live and wants chasing. + tbody tr.srep-row--flag { + background: #fffdf5; + + td:first-child { + box-shadow: inset 3px 0 0 #f59e0b; + } + } + + .srep-num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .srep-mono { + font-family: 'Courier New', monospace; + } + + // A second line under a figure, for the qualifier the figure alone cannot carry — "50 unpaid" + // beneath a held count of nothing. + .srep-sub { + margin-top: 1px; + font-size: 0.68rem; + font-weight: 600; + color: #b45309; + } + + tfoot td { + background: $srep-head-bg; + font-weight: 700; + border-top: 2px solid $srep-border; + border-bottom: none; + } +} + +.srep-pill { + display: inline-block; + padding: 1px 9px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + white-space: nowrap; + + &.srep-pill--green { + background: #ecfdf5; + color: #059669; + border: 1px solid #a7f3d0; + } + + &.srep-pill--amber { + background: #fffbeb; + color: #d97706; + border: 1px solid #fde68a; + } + + &.srep-pill--red { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + } + + &.srep-pill--blue { + background: #eff6ff; + color: #2563eb; + border: 1px solid #bfdbfe; + } + + &.srep-pill--grey { + background: #f1f5f9; + color: #475569; + border: 1px solid #cbd5e1; + } +} + +.srep-state { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem 1rem; + color: $srep-muted; + font-size: 0.85rem; + text-align: center; +} + +.srep-state--error { + color: #dc2626; +} + +/* A narrowed view says so, right where the numbers are, so it is never read as the whole society. */ +.srep-scope-note { + margin-top: 0.5rem; + font-size: 0.75rem; + color: $srep-muted; +} + +/* Account picker — results drop under the search box rather than into a dialog, so choosing an + account is one click from typing rather than a modal that has to be dismissed. */ +.srep-picker { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 20; + margin-top: 2px; + max-height: 260px; + overflow-y: auto; + background: #fff; + border: 1px solid $srep-border; + border-radius: 8px; + box-shadow: 0 10px 24px -8px rgb(15 23 42 / 18%); +} + +.srep-picker-item { + display: flex; + flex-direction: column; + gap: 1px; + width: 100%; + padding: 0.45rem 0.7rem; + background: none; + border: none; + border-bottom: 1px solid $srep-border-soft; + text-align: left; + cursor: pointer; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: #f4f7fb; + } +} + +.srep-picker-name { + font-size: 0.82rem; + font-weight: 600; + color: $srep-text; +} + +.srep-picker-meta { + font-size: 0.72rem; + color: $srep-muted; +} + +/* Inline distribution bar — the shareholding pattern reads as a shape before it reads as numbers, + and a chart library for one bar per row would be a dependency for nothing. */ +.srep-bar { + width: 100%; + height: 8px; + background: $srep-border-soft; + border-radius: 999px; + overflow: hidden; +} + +.srep-bar-fill { + height: 100%; + background: linear-gradient(90deg, #2563eb, #60a5fa); + border-radius: 999px; +} + +/* Statement header — the identity block a printed statement has to carry. */ +.srep-stmt-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1rem; + margin-bottom: 0.85rem; + background: #f8fafc; + border: 1px solid $srep-border; + border-radius: 10px; +} + +.srep-stmt-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.6rem 1.2rem; + flex: 1; +} + +.srep-stmt-l { + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $srep-muted; + font-weight: 700; +} + +.srep-stmt-v { + font-size: 0.84rem; + color: $srep-text; + font-weight: 500; +} + +/* A share class's own authorised-vs-issued fraction, read at a glance rather than only in the two + numbers beside it. */ +.product-utilisation-bar { + width: 100%; + min-width: 90px; + height: 6px; + border-radius: 999px; + background: $srep-border-soft; + overflow: hidden; +} + +.product-utilisation-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #2563eb, #059669); +} + +/* Something the reader has to know before trusting the number above it — an exclusion, a gap, a + figure that will not reconcile until someone acts. Loud enough to be read, not an error. */ +.srep-warn { + display: flex; + align-items: flex-start; + gap: 0.6rem; + margin: 0.6rem 0; + padding: 0.65rem 0.85rem; + background: #fffbeb; + border: 1px solid #fde68a; + border-left: 4px solid #d97706; + border-radius: 8px; + font-size: 0.82rem; + line-height: 1.55; + color: #92400e; +} + +/* The selected tab, in a screen that has two halves rather than two pages. */ +.srep-tab--active { + background: #eff6ff !important; + border-color: #2563eb !important; + color: #1e40af !important; + font-weight: 700; +} + +/* Printed certificate — a document, not a screen: it carries the society's letterhead, a body in + sentences and a signature block, because that is what a member is handed and files. */ +.spc-doc { + border: 1px solid $srep-border; + border-radius: 10px; + padding: 1.5rem 1.75rem; + background: #fff; +} + +.spc-letterhead { + text-align: center; + border-bottom: 2px solid $srep-text; + padding-bottom: 0.75rem; + margin-bottom: 1.1rem; +} + +.spc-org { + font-size: 1.15rem; + font-weight: 700; + color: $srep-text; + letter-spacing: 0.02em; +} + +.spc-org-sub { + font-size: 0.78rem; + color: $srep-muted; + margin-top: 2px; +} + +.spc-title { + margin-top: 0.7rem; + font-size: 0.95rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: $srep-text; +} + +.spc-body { + font-size: 0.88rem; + line-height: 1.7; + color: $srep-text; + margin-bottom: 1rem; +} + +.spc-words { + margin-top: 0.75rem; + font-size: 0.85rem; + color: $srep-text; +} + +.spc-foot { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 2rem; + margin-top: 2.5rem; + font-size: 0.82rem; +} + +.spc-sign { + text-align: center; + font-size: 0.8rem; + color: $srep-text; +} + +.spc-sign-line { + width: 170px; + border-top: 1px solid $srep-text; + margin-bottom: 4px; +} + +/* Label sheet — a grid sized to the label stationery a branch buys, printed straight from here. */ +.slab-sheet { + display: grid; + gap: 0.5rem; + margin-top: 0.6rem; +} + +.slab { + border: 1px dashed $srep-border; + border-radius: 6px; + padding: 0.65rem 0.8rem; + min-height: 92px; + font-size: 0.82rem; + line-height: 1.45; + color: $srep-text; + break-inside: avoid; +} + +.slab-name { + font-weight: 700; +} + +.slab-line { + color: $srep-text; +} + +.slab-meta { + margin-top: 3px; + font-size: 0.7rem; + color: $srep-muted; +} + +/* One letter, one page. */ +.slet-page { + border: 1px solid $srep-border; + border-radius: 10px; + padding: 1.5rem 1.75rem; + margin-top: 0.85rem; + background: #fff; + break-after: page; +} + +.slet-meta { + display: flex; + justify-content: space-between; + gap: 1.5rem; + font-size: 0.84rem; + line-height: 1.5; + margin-bottom: 1.1rem; +} + +.slet-date { + white-space: nowrap; + color: $srep-muted; +} + +.slet-subject { + font-weight: 700; + font-size: 0.86rem; + margin-bottom: 0.9rem; +} + +.slet-salutation { + font-size: 0.86rem; + margin-bottom: 0.6rem; +} + +.slet-body { + font-size: 0.86rem; + line-height: 1.75; + white-space: pre-wrap; + color: $srep-text; +} + +.slet-body-input { + width: 100%; + padding: 0.55rem 0.7rem; + border: 1px solid $srep-border; + border-radius: 8px; + font-family: inherit; + font-size: 0.84rem; + line-height: 1.6; + color: $srep-text; + resize: vertical; +} + +@media print { + .cert-head-r, + .cert-filters, + .srep-no-print { + display: none !important; + } + + .slab { + border: 1px solid #999; + } + + .slet-page { + border: none; + padding: 0; + margin: 0; + } + + .spc-doc { + border: none; + padding: 0; + } + + .srep-table-wrap { + overflow: visible; + border: none; + } + + .srep-table th { + position: static; + } +} diff --git a/src/app/shares/reports/share-report.util.ts b/src/app/shares/reports/share-report.util.ts new file mode 100644 index 000000000..4d609ef39 --- /dev/null +++ b/src/app/shares/reports/share-report.util.ts @@ -0,0 +1,185 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Shared vocabulary and plumbing for the share reports. + * + * Kept in one place because the reports are read side by side — a member shown as "Active" on the + * balance report and "Approved" on the status report would read as two different things when it is + * one status_enum, and a CSV that quotes its fields on one screen but not the next breaks whichever + * spreadsheet opens both. + */ + +/** m_share_account.status_enum → what a reader calls it, and the pill it wears. */ +export const SHARE_ACCOUNT_STATUS: { [key: number]: { label: string; cls: string } } = { + 100: { label: 'Pending Approval', cls: 'srep-pill--amber' }, + 200: { label: 'Approved', cls: 'srep-pill--blue' }, + 300: { label: 'Active', cls: 'srep-pill--green' }, + 500: { label: 'Rejected', cls: 'srep-pill--red' }, + 600: { label: 'Closed', cls: 'srep-pill--grey' } +}; + +/** + * Why a share account's holding reads the way it does — the server's own derivation, so every + * report tells the member the same story about the same account. + * + *

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

+
+
+ +
+

Share Status Report

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

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

+
+
+ +
+

Share Transfer

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

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

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

+
+
+ +
+

Share Wise Report

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

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

+ * No client-side filters beyond the date and scope the server already applied: a distribution that + * can be narrowed in the browser stops being a distribution — the percentages would still be of the + * whole while the bands shown were of a part. + */ +@Component({ + selector: 'mifosx-share-wise-report', + templateUrl: './share-wise-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareWiseReportComponent implements OnInit { + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + + loading = true; + loadError = ''; + bands: BandRow[] = []; + byProduct: ProductRow[] = []; + totals: any = null; + + asOnDate = ''; + + ngOnInit(): void { + const businessDate: Date = this.settingsService.businessDate || new Date(); + this.asOnDate = this.toIso(businessDate); + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareWiseReport({ asOnDate: this.asOnDate }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The shareholding pattern could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.bands = res?.bands || []; + this.byProduct = res?.byProduct || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get totalMembers(): number { + return this.bands.reduce((sum, b) => sum + Number(b.memberCount || 0), 0); + } + + /** A product's share of total capital — the same reading the bands give, by class instead. */ + productShare(row: ProductRow): number { + const total = Number(this.totals?.shareValue || 0); + return total ? (Number(row.shareValue || 0) * 100) / total : 0; + } + + /** Bar width for the inline distribution graphic; the largest band fills the cell. */ + barWidth(row: BandRow): number { + const largest = Math.max(...this.bands.map((b) => Number(b.memberCount || 0)), 1); + return (Number(row.memberCount || 0) * 100) / largest; + } + + exportCsv(): void { + shareReportCsv( + `share-wise-${this.asOnDate || 'today'}`, + [ + 'Holding Band', + 'Accounts', + 'Members', + 'Shares', + 'Share Value', + '% of Capital' + ], + [ + ...this.bands.map((b) => [ + b.band, + b.accountCount, + b.memberCount, + b.shares, + b.shareValue, + b.capitalShare + ]), + [ + 'TOTAL', + '', + this.totalMembers, + this.totals?.shares ?? '', + this.totals?.shareValue ?? '', + 100 + ] + ] + ); + } + + print(): void { + shareReportPrint(); + } + + private toIso(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/share-dialog.shared.scss b/src/app/shares/share-dialog.shared.scss new file mode 100644 index 000000000..710f215b4 --- /dev/null +++ b/src/app/shares/share-dialog.shared.scss @@ -0,0 +1,996 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// Shared chrome for Apply Collection and Apply Additional Shares dialogs. +// Each component @uses this, so every rule is emitted once per component +// and stays scoped to it -- nothing here leaks into the global stylesheet. + +/* ── Design tokens ─────────────────────────────────────────────────────── */ + +$primary: #1e3a8a; +$primary-mid: #2448b0; +$accent: #2563eb; +$accent-soft: #eff6ff; +$accent-border: #bfdbfe; + +$text-main: #0f172a; +$text-sub: #334155; +$text-muted: #64748b; +$border-color: #e2e8f0; +$bg-page: #f8fafc; +$bg-white: #fff; + +$success: #059669; +$success-bg: #ecfdf5; +$success-border: #6ee7b7; + +$danger: #dc2626; +$danger-bg: #fef2f2; +$danger-border: #fca5a5; + +/* ── Utilities ─────────────────────────────────────────────────────────── */ + +.mono { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-variant-numeric: tabular-nums; +} + +/* ── Dialog wrapper ────────────────────────────────────────────────────── */ + +.dialog-wrapper { + display: flex; + flex-direction: column; + max-height: 88vh; + background: $bg-white; + border-radius: 14px; + overflow: hidden; +} + +/* ── Header ────────────────────────────────────────────────────────────── */ + +.dialog-header { + display: flex; + flex: none; + align-items: center; + gap: 0.85rem; + padding: 1.1rem 1.5rem; + background: linear-gradient(135deg, $primary 0%, $primary-mid 60%, #3b5fc0 100%); + color: #fff; + + .header-icon { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 38px; + height: 38px; + border-radius: 9px; + background: rgb(255 255 255 / 15%); + border: 1px solid rgb(255 255 255 / 20%); + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #fff; + } + } + + .header-text { + flex: 1; + min-width: 0; + + h2 { + margin: 0; + font-size: 1.08rem; + font-weight: 700; + letter-spacing: 0.01em; + } + + p { + margin: 0.18rem 0 0; + font-size: 0.78rem; + opacity: 0.78; + } + } + + .header-close { + color: rgb(255 255 255 / 80%); + flex: none; + + &:hover { + color: #fff; + background: rgb(255 255 255 / 12%) !important; + } + } +} + +/* ── Body ──────────────────────────────────────────────────────────────── */ + +.dialog-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 1.1rem; + min-height: 0; + max-height: none !important; + padding: 1.4rem 1.5rem !important; + overflow-y: auto; + background: $bg-page; + + &::-webkit-scrollbar { + width: 5px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 99px; + } +} + +.state-block { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem 0; + color: $text-muted; + font-size: 0.85rem; +} + +/* ═══════════════════════════════════════════════════════════════════════ + Collection Summary Card (sc-* prefix) + ═══════════════════════════════════════════════════════════════════════ */ + +.sc-card { + border: 1px solid $accent-border; + border-radius: 10px; + background: $bg-white; + // No overflow:hidden — it clips row content in certain flex contexts + box-shadow: 0 2px 8px rgb(37 99 235 / 8%); +} + +/* ── Each item row (Share / Entry Fee) ─────────────────────────────────── */ + +.sc-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1.15rem; + background: $bg-white; + border-left: 4px solid transparent; + transition: + background 0.15s, + border-color 0.2s; + + &.sc-sep { + border-top: 1px solid $border-color; + } + + &.sc-ok { + background: $success-bg; + border-left-color: $success; + } + + &.sc-bad { + background: $danger-bg; + border-left-color: $danger; + } +} + +/* Left side: label + required amount */ +.sc-left { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.18rem; +} + +.sc-label { + display: block; + font-size: 0.86rem; + font-weight: 700; + color: $text-main; + line-height: 1.3; + min-height: 1em; // guarantees height even if content is empty +} + +.sc-sub { + display: block; + font-size: 0.72rem; + color: $text-muted; + line-height: 1.35; + min-height: 1em; + + b { + font-weight: 600; + color: $text-sub; + } +} + +/* Right side: collected amount + badge */ +.sc-right { + display: flex; + align-items: center; + gap: 0.5rem; + flex: none; +} + +.sc-val { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-variant-numeric: tabular-nums; + font-size: 0.96rem; + font-weight: 700; + color: $text-muted; + letter-spacing: -0.01em; + transition: color 0.2s; + + &.sc-val-ok { + color: $success; + } + + &.sc-val-bad { + color: $danger; + } +} + +.sc-tag { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.5rem; + border-radius: 99px; + font-size: 0.68rem; + font-weight: 700; + white-space: nowrap; + + &.sc-tag-ok { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + + &.sc-tag-bad { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } +} + +/* ── Total row ─────────────────────────────────────────────────────────── */ + +.sc-total-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 1.15rem; + border-top: 1px solid $border-color; + background: $bg-page; + transition: background 0.2s; + + &.sc-total-ok { + background: $success-bg; + } +} + +.sc-total-lbl { + font-size: 0.82rem; + font-weight: 800; + color: $text-main; + letter-spacing: 0.01em; +} + +.sc-total-nums { + display: flex; + align-items: center; + gap: 0.45rem; + flex: none; +} + +.sc-total-req { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-size: 0.78rem; + font-weight: 500; + color: $text-muted; +} + +.sc-arrow { + font-size: 0.8rem; + color: #94a3b8; + font-weight: 300; +} + +.sc-total-got { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-variant-numeric: tabular-nums; + font-size: 1rem; + font-weight: 800; + color: $text-muted; + letter-spacing: -0.02em; + transition: color 0.2s; + + &.sc-total-got-ok { + color: $success; + } +} + +.sc-status-pill { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.65rem; + border-radius: 99px; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.04em; + white-space: nowrap; + transition: all 0.2s; + + &.sc-pill-ok { + background: $success; + color: #fff; + box-shadow: 0 2px 6px rgb(5 150 105 / 30%); + } + + &.sc-pill-pending { + background: $border-color; + color: $text-muted; + } +} + +/* ═══════════════════════════════════════════════════════════════════════ */ + +/* ── Receipt step slots ────────────────────────────────────────────────── */ + +.slot { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1.1rem 1.25rem; + border: 1px solid $border-color; + border-radius: 10px; + background: $bg-white; + box-shadow: 0 1px 4px rgb(15 23 42 / 4%); + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 3px 12px rgb(15 23 42 / 7%); + } +} + +.slot-head { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.slot-step { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 26px; + height: 26px; + border-radius: 50%; + background: linear-gradient(135deg, $primary 0%, $accent 100%); + color: #fff; + font-size: 0.72rem; + font-weight: 800; + box-shadow: 0 2px 6px rgb(37 99 235 / 30%); +} + +.slot-title { + flex: 1; + min-width: 0; + + h3 { + margin: 0; + font-size: 0.9rem; + font-weight: 700; + color: $text-main; + } + + p { + margin: 0.12rem 0 0; + font-size: 0.72rem; + color: $text-muted; + } +} + +.slot-search { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex: none; + padding: 0.38rem 0.72rem; + border: 1.5px solid $accent-border; + border-radius: 7px; + background: $accent-soft; + color: $accent; + font-size: 0.74rem; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; + + mat-icon { + width: 15px; + height: 15px; + font-size: 15px; + } + + &:hover { + background: $accent; + border-color: $accent; + color: #fff; + box-shadow: 0 2px 8px rgb(37 99 235 / 25%); + } +} + +/* ── Pickable option list ───────────────────────────────────────────────── */ + +.options { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 210px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 99px; + } +} + +.option { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.72rem 0.9rem; + border: 1.5px solid $border-color; + border-radius: 7px; + cursor: pointer; + background: $bg-white; + transition: all 0.14s; + + &:hover { + border-color: $accent; + background: $accent-soft; + box-shadow: 0 2px 6px rgb(37 99 235 / 10%); + } + + &.option-busy { + opacity: 0.6; + pointer-events: none; + } +} + +.option-radio { + flex: none; + width: 14px; + height: 14px; + border: 2px solid #cbd5e1; + border-radius: 50%; + transition: border-color 0.14s; + + .option:hover & { + border-color: $accent; + } +} + +.option-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.option-voucher { + font-size: 0.81rem; + font-weight: 700; + color: $text-main; +} + +.option-meta { + font-size: 0.7rem; + color: $text-muted; +} + +.option-desc { + font-size: 0.7rem; + color: $text-muted; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.option-amount { + flex: none; + font-size: 0.85rem; + font-weight: 700; + color: $text-main; +} + +.option-info { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid $border-color; + border-radius: 5px; + background: transparent; + color: $text-muted; + cursor: pointer; + transition: all 0.14s; + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } + + &:hover { + color: $accent; + border-color: $accent; + background: $accent-soft; + } +} + +/* ── Chosen receipt ─────────────────────────────────────────────────────── */ + +.chosen { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.75rem 1rem; + border: 2px solid $success-border; + border-radius: 9px; + background: $success-bg; + box-shadow: 0 2px 8px rgb(5 150 105 / 10%); + + &.chosen-short { + border-color: $danger-border; + background: $danger-bg; + box-shadow: 0 2px 8px rgb(220 38 38 / 8%); + } +} + +.chosen-badge { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 30px; + height: 30px; + border-radius: 7px; + background: $success; + + .chosen-short & { + background: $danger; + } + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + color: #fff; + } +} + +.chosen-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.chosen-voucher { + font-size: 0.84rem; + font-weight: 700; + color: $text-main; +} + +.chosen-gl { + font-size: 0.7rem; + color: $text-muted; +} + +.chosen-amount { + flex: none; + font-size: 0.95rem; + font-weight: 800; + color: $text-main; + font-family: 'Roboto Mono', 'Courier New', monospace; +} + +.chosen-clear { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid $danger-border; + border-radius: 5px; + background: transparent; + color: #f87171; + cursor: pointer; + transition: all 0.14s; + + mat-icon { + width: 15px; + height: 15px; + font-size: 15px; + } + + &:hover { + color: $danger; + background: $danger-bg; + border-color: $danger; + } +} + +/* ── Slot notes ─────────────────────────────────────────────────────────── */ + +.slot-note { + margin: 0; + font-size: 0.74rem; + font-weight: 500; + + &.note-good { + color: $success; + } + + &.note-bad { + color: $danger; + font-weight: 700; + } + + &.note-muted { + color: $text-muted; + } +} + +.slot-empty { + margin: 0; + padding: 0.8rem 1rem; + border: 1.5px dashed $border-color; + border-radius: 7px; + background: $bg-page; + font-size: 0.75rem; + line-height: 1.55; + color: $text-muted; +} + +/* ── Error alert ─────────────────────────────────────────────────────────── */ + +.alert { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.7rem 0.9rem; + border: 1.5px solid $danger-border; + border-radius: 7px; + background: $danger-bg; + color: $danger; + font-size: 0.76rem; + line-height: 1.45; + + mat-icon { + flex: none; + width: 17px; + height: 17px; + font-size: 17px; + margin-top: 1px; + } +} + +/* ── Footer ─────────────────────────────────────────────────────────────── */ + +.dialog-actions { + display: flex; + flex: none; + justify-content: flex-end; + align-items: center; + gap: 0.75rem; + min-height: 0; + padding: 1rem 1.5rem !important; + margin: 0 !important; + border-top: 1px solid $border-color; + background: $bg-white; + box-shadow: 0 -2px 8px rgb(15 23 42 / 5%); + + // Both buttons: same height, same border-radius, same font + button { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 0.3rem !important; + height: 40px !important; + min-width: 110px !important; + padding: 0 1.1rem !important; + border-radius: 8px !important; + font-weight: 600 !important; + font-size: 0.85rem !important; + line-height: 1 !important; + letter-spacing: 0.01em !important; + + // Icon inside button — vertically center it and size it correctly + mat-icon { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 18px !important; + height: 18px !important; + font-size: 18px !important; + line-height: 1 !important; + vertical-align: middle !important; + margin: 0 !important; + flex: none; + } + + mat-spinner { + flex: none; + } + } + + // Stroked cancel button + [mat-stroked-button] { + border-color: $border-color !important; + color: $text-sub !important; + + &:hover { + border-color: #94a3b8 !important; + background: $bg-page !important; + } + } + + // Primary action button (enabled) + [mat-flat-button][color='primary']:not([disabled]) { + background: linear-gradient(135deg, $accent 0%, #1d4ed8 100%) !important; + box-shadow: 0 2px 8px rgb(37 99 235 / 28%) !important; + color: #fff !important; + + &:hover { + box-shadow: 0 4px 14px rgb(37 99 235 / 40%) !important; + transform: translateY(-1px) !important; + } + + &:active { + transform: translateY(0) !important; + } + } + + // Primary action button (disabled) + [mat-flat-button][color='primary'][disabled] { + background: #e2e8f0 !important; + color: #94a3b8 !important; + box-shadow: none !important; + } +} + +/* ── Form fields (in search popup) ─────────────────────────────────────── */ + +.field { + display: flex; + flex-direction: column; + gap: 0.35rem; + + > span { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-muted; + } + + select, + input { + padding: 0.55rem 0.7rem; + border: 1.5px solid $border-color; + border-radius: 7px; + font-size: 0.82rem; + color: $text-main; + background: #fff; + outline: none; + transition: + border-color 0.14s, + box-shadow 0.14s; + + &:hover { + border-color: #94a3b8; + } + + &:focus { + border-color: $accent; + box-shadow: 0 0 0 3px rgb(37 99 235 / 12%); + } + } +} + +/* ── Sub-popups ─────────────────────────────────────────────────────────── */ + +.overlay { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: rgb(15 23 42 / 55%); + backdrop-filter: blur(2px); +} + +.popup { + display: flex; + flex-direction: column; + width: 100%; + max-width: 460px; + max-height: 80vh; + border-radius: 12px; + background: #fff; + overflow: hidden; + box-shadow: 0 20px 50px -8px rgb(15 23 42 / 30%); + animation: popup-in 0.15s cubic-bezier(0.22, 1, 0.36, 1); +} + +@keyframes popup-in { + from { + opacity: 0; + transform: scale(0.96) translateY(8px); + } + + to { + opacity: 1; + transform: none; + } +} + +.popup-head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.85rem 0.85rem 0.85rem 1.2rem; + border-bottom: 1px solid $border-color; + background: $bg-page; + + h3 { + flex: 1; + margin: 0; + font-size: 0.92rem; + font-weight: 700; + color: $text-main; + } +} + +.popup-body { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 1.2rem; + overflow-y: auto; +} + +.popup-foot { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0.8rem 1.2rem; + border-top: 1px solid $border-color; + background: $bg-page; +} + +/* ── Receipt detail popup ───────────────────────────────────────────────── */ + +.detail-list { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; + border: 1px solid $border-color; + border-radius: 8px; + overflow: hidden; + + > div { + display: flex; + border-bottom: 1px solid $border-color; + + &:last-child { + border-bottom: none; + } + } + + dt { + flex: none; + width: 110px; + padding: 0.6rem 0.9rem; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $text-muted; + background: $bg-page; + border-right: 1px solid $border-color; + } + + dd { + flex: 1; + min-width: 0; + margin: 0; + padding: 0.6rem 0.9rem; + font-size: 0.8rem; + color: $text-main; + word-break: break-word; + } +} + +.detail-heading { + margin: 0.4rem 0 0.1rem; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $text-muted; +} + +.gl-table { + width: 100%; + border-collapse: collapse; + font-size: 0.76rem; + border: 1px solid $border-color; + border-radius: 7px; + overflow: hidden; + + th, + td { + padding: 0.45rem 0.6rem; + border-bottom: 1px solid $border-color; + text-align: left; + } + + tbody tr:last-child td { + border-bottom: none; + } + + th { + font-size: 0.64rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $text-muted; + background: $bg-page; + } + + tbody tr:hover { + background: $accent-soft; + } + + .num { + text-align: right; + } +} diff --git a/src/app/shares/share-receipt-option.ts b/src/app/shares/share-receipt-option.ts new file mode 100644 index 000000000..3218b0efa --- /dev/null +++ b/src/app/shares/share-receipt-option.ts @@ -0,0 +1,85 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * One Share Collection receipt the counter can choose, in a single shape whatever it came from. + * + * {@code GET /shareadvancecollection/unattributed} answers with two differently-shaped lists — the + * member's own receipts under {@code own}, and every other receipt still going spare under + * {@code rows} — and normalising both to this on arrival is what lets a screen show one plain list + * of vouchers instead of two tables whose difference a counter has to understand before they can + * pick anything. + * + * Shared by the two screens that settle a share purchase (the first application's Apply Collection + * dialog and the Apply Additional Shares dialog) so a receipt reads identically on both. + */ +export interface ReceiptOption { + /** Stable identity for tracking and comparison; unique across both source lists. */ + key: string; + /** + * The {@code m_share_advance_collection} row behind this receipt, or null while it is still only + * a voucher number. A null one is attached to the member on first pick. + */ + collectionId: number | null; + voucherNo: string; + glAccountNo: string; + glAccountName: string; + date: string; + amount: number; + description: string; + cashierName: string; +} + +/** + * A receipt that already has an {@code m_share_advance_collection} row — ready to settle as-is. + * + * Also the shape {@code GET /shareadvancecollection/by-voucher} answers with, so a receipt found by + * number arrives here too, already attached. + */ +export function receiptFromOwn(row: any): ReceiptOption { + return { + key: `c${row.id}`, + collectionId: Number(row.id), + voucherNo: row.voucher_no || row.receipt_no || '', + glAccountNo: row.gl_code || '', + glAccountName: '', + date: String(row.collection_date || '').slice(0, 10), + amount: Number(row.amount || 0), + description: row.note || '', + cashierName: row.collected_by_name || '' + }; +} + +/** A receipt that is still only a voucher, attached to the member when it is picked. */ +export function receiptFromUnattached(row: any): ReceiptOption { + return { + key: `v${row.id}`, + collectionId: null, + voucherNo: row.voucherNo || '', + glAccountNo: row.glAccountNo || '', + glAccountName: row.glAccountName || '', + date: String(row.txnDate || '').slice(0, 10), + amount: Number(row.amount || 0), + description: row.description || '', + cashierName: row.cashierName || '' + }; +} + +/** + * The GL account a receipt sits in, named where the name is known and coded where it is not. + * + * An attached receipt carries only its {@code gl_code}, so the name is looked up in whatever GL + * account list the screen already loaded for its voucher search rather than fetched again. + */ +export function receiptGlLabel(option: ReceiptOption, glAccounts: any[]): string { + const name = option.glAccountName || glAccounts.find((gl) => gl.gl_code === option.glAccountNo)?.name || ''; + if (!option.glAccountNo) { + return name || '—'; + } + return name ? `${option.glAccountNo} — ${name}` : option.glAccountNo; +} diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html index 4497dda2c..0299ae92f 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html @@ -6,292 +6,367 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -

- -
- -
- - {{ 'labels.inputs.Request Date' | translate }} - - - - @if (applySharesForm.controls.requestedDate.hasError('required')) { - - {{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - +
+
+
+ trending_up +
+
+

Apply Additional Shares

+

+ Share account {{ data.accountNo }} +

+
+ +
- - - {{ 'labels.inputs.Resolution Number' | translate }} - - @if (applySharesForm.controls.resolutionNumber.hasError('required')) { - - {{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - + +
+
+
+ Shares + +
+
+ Unit Price + {{ unitPrice | number: '1.2-2' }} +
+
+ Total Amount + {{ amount | number: '1.2-2' }} +
+
+
- - {{ 'labels.inputs.Resolution Date' | translate }} - - - - @if (applySharesForm.controls.resolutionDate.hasError('required')) { - - {{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ 'labels.inputs.Total No. of Shares' | translate }} - - @if (applySharesForm.controls.requestedShares.hasError('required')) { - - {{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ 'labels.inputs.Current Price' | translate }} - - - - - - {{ 'labels.inputs.Amount' | translate }} - - {{ 'labels.inputs.Total No. of Shares' | translate }} × - {{ 'labels.inputs.Current Price' | translate }} - - - -
-
{{ 'labels.inputs.Payment Option' | translate }}
- - Apply Collection (Cash) - Debit from Savings - -
- - @if (paymentMode === 'CASH') { -
-

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

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

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

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

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

+ + +
+
+ 1 +
+

Board Resolution

+

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

- +
- -
+ + +
+
+ 2 +
+

Funding

+

Approval posts Dr Sundry / Cr Share Capital either way.

+
+ @if (funding === 'CASH') { + + } +
+ +
+ - - - - +
+ + @if (funding === 'CASH') { + @if (loadingReceipts) { +
+ + Loading receipts… +
+ } @else if (receipt) { +
+
+ {{ shortfall > 0 ? 'warning' : 'check_circle' }} +
+
+ {{ receipt.voucherNo }} + {{ glLabel(receipt) }} +
+ {{ receipt.amount | number: '1.2-2' }} + +
+ @if (shortfall > 0) { +

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

+ } @else { +

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

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

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

+ } + } + + @if (funding === 'SAVINGS') { + @if (checkingSavings) { +
+ + Checking the linked savings account… +
+ } @else if (savings) { +
+
+ {{ savings.eligible ? 'check_circle' : 'warning' }} +
+
+ + @if (savings.savingsAccountNo) { + Savings {{ savings.savingsAccountNo }} + } @else { + Linked savings account + } + + + {{ savings.eligible ? 'Debited into Share Sundry on submit.' : savings.reason }} + +
+ @if (savings.savingsBalance !== null) { + {{ savings.savingsBalance | number: '1.2-2' }} + } +
+ } @else { +

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

+ } + } +
+ + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
+ } +
+ + + + +
+ + +@if (searchOpen) { +
+ +
+} + + +@if (viewing) { +
+ +
+} diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss index 9a9fa643c..ce4264083 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss @@ -6,231 +6,195 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -$primary: #1a3a6b; -$border: #d8e0ec; -$muted: #6b7a90; -$danger: #b3261e; +// Dialog chrome, receipt list, chosen state and popups come from the shared +// partial. Only what is unique to a tranche request lives below. +@use '../../share-dialog.shared' as *; -.container { - max-width: 37rem; +:host { + display: block; + width: 100%; } -.payment-mode { - margin: 4px 0 16px; +/* ── Shares sub-header (sits between dialog-header and dialog-body, never scrolls) ── */ + +.shares-subheader { + display: flex; + flex: none; // same as header/footer — doesn't participate in body flex growth + padding: 0.85rem 1.5rem; + background: #f1f5f9; + border-bottom: 1px solid $border-color; } -.payment-mode-label { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.4px; - color: $muted; - margin-bottom: 8px; +/* ── Shares / Price / Amount strip ─────────────────────────────────────── */ + +.shares-strip { + display: flex; + width: 100%; + border: 1px solid $accent-border; + border-radius: 10px; + overflow: hidden; + background: $bg-white; + box-shadow: 0 2px 8px rgb(37 99 235 / 7%); } -.payment-mode-options { +.shares-cell { + flex: 1; display: flex; flex-direction: column; - gap: 4px; + gap: 0.3rem; + padding: 0.9rem 1.1rem; + background: $bg-white; + border-right: 1px solid $border-color; + + &:last-child { + border-right: none; + } + + &.shares-cell-input { + // editable cell — slight highlight + background: #fafcff; + } + + &.shares-cell-total { + background: $accent-soft; + + .shares-label { + color: $accent; + } + + .shares-value { + color: $primary; + } + } } -/* The funding route's own detail, boxed off from the share fields above it: which voucher, or - which savings account, is what the clerk checks before submitting. */ -.funding-panel { - border: 1px solid $border; - border-radius: 6px; - padding: 14px; - margin-bottom: 16px; - background: #fbfcfe; +.shares-label { + display: block; + font-size: 0.63rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: $text-muted; + line-height: 1.3; } -.funding-intro { - font-size: 13px; - color: $muted; - margin: 0 0 12px; +.shares-value { + display: block; + font-size: 1.05rem; + font-weight: 700; + color: $text-main; + letter-spacing: -0.01em; + line-height: 1.3; } -.gl-select-field { - margin: 0 0 10px; -} +/* ── Inline number input inside the strip ──────────────────────────────── */ -.gl-select { +.req-input { width: 100%; - padding: 9px 11px; - border: 1px solid $border; - border-radius: 6px; - font-size: 13px; - color: $primary; - background: #fff; + padding: 0; + border: none; + background: transparent; + font-size: 1.05rem; + font-weight: 700; + color: $text-main; + letter-spacing: -0.01em; &:focus { outline: none; - border-color: $primary; } - &:disabled { - background: #f4f7fb; - color: $muted; + &::placeholder { + color: #cbd5e1; + font-weight: 400; + } + + // Remove browser number arrows + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + margin: 0; } } -.voucher-lookup { +/* ── Board resolution fields ────────────────────────────────────────────── */ + +.field-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.85rem; + + @media (width <= 560px) { + grid-template-columns: 1fr; + } +} + +/* ── Cash / Savings funding selector ───────────────────────────────────── */ + +.funding-choice { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; +} + +.funding-btn { display: flex; - gap: 8px; - align-items: center; - margin: 0 0 12px; -} - -.voucher-input { - flex: 1; - min-width: 0; - padding: 9px 11px; - border: 1px solid $border; - border-radius: 6px; - font-family: 'Roboto Mono', monospace; - font-size: 13px; - letter-spacing: 0.4px; - color: $primary; - - &:focus { - outline: none; - border-color: $primary; - } - - &:disabled { - background: #f4f7fb; - color: $muted; - } -} - -.funding-loading { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 0; - font-size: 13px; - color: $muted; -} - -.funding-summary { - border: 1px solid $border; - border-radius: 6px; - background: #fff; - padding: 10px 12px; - font-size: 13px; -} - -.funding-row { - display: flex; - justify-content: space-between; - gap: 12px; - padding: 4px 0; - color: $primary; - - > span:first-child { - color: $muted; - } -} - -.funding-row--total { - border-top: 1px solid #eceff4; - margin-top: 4px; - padding-top: 8px; - font-weight: 500; -} - -.mono { - font-family: 'Courier New', monospace; -} - -.funding-empty { - display: flex; - gap: 10px; + flex-direction: column; align-items: flex-start; - background: #f4f7fb; - border: 1px solid $border; - border-radius: 6px; - padding: 12px; - font-size: 13px; -} - -.funding-empty-hint { - color: $muted; - margin-top: 4px; -} - -.receipt-table { - width: 100%; - border-collapse: collapse; - font-size: 13px; - background: #fff; - border: 1px solid $border; - border-radius: 6px; - - th { - background: #eef2f8; - color: $primary; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.4px; - padding: 8px; - } - - td { - padding: 8px; - border-bottom: 1px solid #eceff4; - } - - tbody tr { - cursor: pointer; - - &:hover { - background: #f4f7fb; - } - - &.selected { - background: #e7effb; - } - } - - /* The running total against the tranche amount — what decides whether the request is accepted, - and not something to be added up by eye once a second receipt is in play. */ - tfoot .total-row { - border-top: 2px solid #cbd5e1; - - td { - padding-top: 10px; - color: #1f3d7a; - } - } -} - -.ta-left { + gap: 0.2rem; + padding: 0.85rem 1rem; + border: 2px solid $border-color; + border-radius: 10px; + background: $bg-white; text-align: left; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: 0 1px 3px rgb(15 23 42 / 4%); + + mat-icon { + width: 22px; + height: 22px; + font-size: 22px; + color: $text-muted; + transition: color 0.15s; + margin-bottom: 0.15rem; + } + + &:hover { + border-color: $accent; + background: $accent-soft; + box-shadow: 0 2px 8px rgb(37 99 235 / 12%); + transform: translateY(-1px); + + mat-icon { + color: $accent; + } + } + + &:active { + transform: translateY(0); + } + + &.funding-on { + border-color: $accent; + background: linear-gradient(135deg, $accent-soft 0%, #e0eaff 100%); + box-shadow: 0 2px 10px rgb(37 99 235 / 18%); + + mat-icon, + .funding-name { + color: $primary; + } + } } -.ta-right { - text-align: right; +.funding-name { + font-size: 0.88rem; + font-weight: 700; + color: $text-main; + letter-spacing: 0.01em; } -.ta-center { - text-align: center; -} - -.alert-error { - display: flex; - align-items: center; - gap: 8px; - background: #fdecea; - color: $danger; - border: 1px solid #f5c6c2; - border-radius: 6px; - padding: 10px 12px; - font-size: 13px; - margin-bottom: 12px; -} - -.pending-hint { - font-size: 12px; - color: $muted; - margin: 0; +.funding-hint { + font-size: 0.7rem; + color: $text-muted; + line-height: 1.3; } diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts index b071be51b..b4b046b49 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts @@ -6,109 +6,116 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** Angular Imports */ import { Component, OnInit, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; -import { MatRadioModule } from '@angular/material/radio'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { Subject, of } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators'; -import { of } from 'rxjs'; -/** Custom Services */ import { SharesService } from 'app/shares/shares.service'; import { CashierService } from 'app/cashier/cashier.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ReceiptOption, receiptFromOwn, receiptFromUnattached, receiptGlLabel } from 'app/shares/share-receipt-option'; + +/** Where the money for this tranche comes from. */ +export type FundingSource = 'CASH' | 'SAVINGS'; /** * Apply Additional Shares. * - * A member buying a further tranche of shares, which — like the account's first application — is - * only a request until it is approved. Two things have to be captured that the bare share count - * does not carry: - * - *
    - *
  • the board resolution authorising this particular tranche, which is its own meeting and its - * own number, not the one the account was opened under;
  • - *
  • where the money is coming from, because a tranche nobody has paid for cannot be approved.
  • - *
+ * A member buying a further tranche, which — like the account's first application — is only a + * request until it is approved. Two things have to be captured that the bare share count does not + * carry: the board resolution authorising this particular tranche (its own meeting, its own number, + * not the one the account was opened under), and where the money is coming from, because a tranche + * nobody has paid for cannot be approved. * * Both funding routes converge on the Share Sundry holding account, so approval posts the same * Dr Sundry / Cr Share Capital pair either way: * *
    - *
  • Cash — the member already paid at the counter; the cashier's receipts put the money - * into Sundry at that point. Submitting only reserves those receipts against this tranche. - * More than one can be ticked, because a member often pays in instalments and no single - * receipt covers the tranche. They are checked here rather than at approval, while the member - * is still at the counter to make up any shortfall.
  • + *
  • Cash — the member already paid at the counter and the cashier's receipt put the money + * into Sundry then. Submitting only reserves that receipt against this tranche. One receipt + * funds one tranche; it is checked here rather than at approval, while the member is still at + * the counter to make up any shortfall.
  • *
  • Savings — the member's savings are debited into Sundry on submit, so their balance * drops immediately. Approval then moves it out to share capital.
  • *
+ * + * Deliberately the same screen as the first application's Apply Collection dialog — same numbered + * steps, same receipt list, same voucher search — because it settles the same receipts for the same + * person, and two different designs for that were only ever two things to learn. */ @Component({ selector: 'mifosx-apply-shares', templateUrl: './apply-shares.component.html', styleUrls: ['./apply-shares.component.scss'], + standalone: true, imports: [ - ...STANDALONE_SHARED_IMPORTS, + CommonModule, FormsModule, - MatRadioModule, - MatProgressSpinnerModule, - MatIconModule + MatDialogModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule ] }) export class ApplySharesComponent implements OnInit { - private formBuilder = inject(UntypedFormBuilder); private sharesService = inject(SharesService); private cashierService = inject(CashierService); - private dateUtils = inject(Dates); - private route = inject(ActivatedRoute); - private router = inject(Router); private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); - /** Shares account data. */ - sharesAccountData: any; + public dialogRef = inject(MatDialogRef); + public data = inject<{ + shareAccountId: number; + clientId: number; + accountNo: string; + unitPrice: number; + }>(MAT_DIALOG_DATA); - /** Minimum date allowed. */ - minDate = new Date(2000, 0, 1); - /** Maximum date allowed. */ - maxDate = new Date(); - /** Apply Share Account form. */ - applySharesForm: UntypedFormGroup; - /** Shares Account Id */ - accountId: any; - - /** Submission in flight, so the form cannot be posted twice. */ submitting = false; - /** Anything that stopped the submission, shown above the buttons. */ errorMsg = ''; - /** - * The member's unapplied Share Sundry receipts, and which of them fund this tranche. - * - * Several, because a member often pays one tranche in instalments — ₹600 today, the ₹400 balance - * tomorrow — and neither receipt covers it alone. They are submitted together as one settlement - * rather than sending the member back to the counter for a single combined receipt. - */ - vouchers: any[] = []; - selectedVoucherIds: number[] = []; - loadingVouchers = false; - lookingUpVoucher = false; - voucherError = ''; + // ── The tranche ───────────────────────────────────────────────────────────────────────────── - /** GL account the deposit was posted to — required, and always picked by hand. Never resolved - * from a share product's accounting setup, so a broken or missing product mapping can never - * make a genuine voucher unreachable. */ - glAccountNo = ''; - glAccounts: any[] = []; - loadingGlAccounts = true; + /** How many shares this tranche is for. */ + requestedShares: number | null = null; + + /** The product's current market price. Read-only: the tranche's value and its GL posting must + * never be able to disagree because somebody typed over the price. */ + unitPrice = 0; + + /** Both as yyyy-MM-dd, the value a native date input reads and writes. */ + requestedDate = ''; + resolutionDate = ''; + resolutionNumber = ''; + + /** Shares x unit price — what this tranche is worth, and what has to be funded. */ + get amount(): number { + return (Number(this.requestedShares) || 0) * this.unitPrice; + } + + // ── Funding ───────────────────────────────────────────────────────────────────────────────── + + funding: FundingSource | null = null; + + /** Every Share Collection receipt that can fund this tranche, from both server lists, in one pool. */ + available: ReceiptOption[] = []; + loadingReceipts = false; + + /** The one receipt funding this tranche. One receipt, one tranche. */ + receipt: ReceiptOption | null = null; + + /** Key of the receipt currently being attached to this member, so only its own row shows a spinner. */ + attaching: string | null = null; /** What the member's savings can cover, for the savings route. */ - savingsEligibility: { + savings: { eligible: boolean; reason: string | null; savingsAccountNo: string | null; @@ -116,19 +123,24 @@ export class ApplySharesComponent implements OnInit { } | null = null; checkingSavings = false; - constructor() { - this.accountId = this.route.parent.snapshot.params['shareAccountId']; - this.route.data.subscribe((data: { shareAccountActionData: any }) => { - this.sharesAccountData = data.shareAccountActionData; + /** Re-checks the savings verdict as the share count settles, rather than on every keystroke of + * "1000" — which asks four times and lets the answers land out of order. */ + private sharesChanged = new Subject(); + + ngOnInit(): void { + this.unitPrice = Number(this.data.unitPrice) || 0; + // Opens on the business date rather than blank: a tranche is recorded the day it is taken, and + // asking a clerk to type that in is asking them to retype what the system already knows. + this.requestedDate = this.isoOf(this.settingsService.businessDate); + + this.sharesChanged.pipe(debounceTime(400), distinctUntilChanged()).subscribe(() => { + if (this.funding === 'SAVINGS') { + this.checkSavings(); + } }); - } - - ngOnInit() { - this.maxDate = this.settingsService.businessDate; - this.createApplySharesAccountForm(); - this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || ''); - this.applySharesForm.get('amount').patchValue(this.amount || ''); + // Needed only by the voucher search, which is the exception path -- loaded quietly in the + // background so opening that popup never waits on a request. this.cashierService .getShareAdvanceGlAccounts() .pipe(catchError(() => of(null))) @@ -138,191 +150,109 @@ export class ApplySharesComponent implements OnInit { }); } - /** - * Creates the apply shares form. - * - * The request date opens on the business date rather than blank: an additional-share request is - * recorded the day it is taken, and a clerk who has to fill that in by hand is being asked to - * retype what the system already knows. - */ - createApplySharesAccountForm() { - this.applySharesForm = this.formBuilder.group({ - requestedDate: [ - this.settingsService.businessDate, - Validators.required - ], - requestedShares: [ - '', - Validators.required - ], - unitPrice: [{ value: '', disabled: true }], - // Disabled, not readonly: the amount is derived from shares x price and must never be a - // figure somebody typed, or the tranche's value and its GL posting could disagree. - amount: [{ value: '', disabled: true }], - resolutionNumber: [ - '', - Validators.required - ], - resolutionDate: [ - '', - Validators.required - ], - paymentMode: [ - '', - Validators.required - ] - }); - - // Recompute as the share count is typed, so the clerk can read the amount back to the member - // before committing to a funding route. - this.applySharesForm.get('requestedShares').valueChanges.subscribe(() => { - this.applySharesForm.get('amount').patchValue(this.amount || ''); - // A savings verdict is cleared the moment the amount changes rather than when the re-check - // returns, so a stale "can cover it" is never on screen beside a larger amount. The ticked - // vouchers stay: they are the receipts the member actually holds, and the running total below - // shows against the new amount whether they still cover it. - this.voucherError = ''; - this.savingsEligibility = null; - }); - - // Debounced separately from the clearing above: the check is a server round-trip, and firing it - // on every keystroke of "1000" asks four times and lets the answers land out of order. - this.applySharesForm - .get('requestedShares') - .valueChanges.pipe(debounceTime(400), distinctUntilChanged()) - .subscribe(() => { - if (this.paymentMode === 'SAVINGS') { - this.checkSavings(); - } - }); - - this.applySharesForm.get('paymentMode').valueChanges.subscribe((mode: string) => { - this.vouchers = []; - this.selectedVoucherIds = []; - this.voucherError = ''; - this.errorMsg = ''; - this.savingsEligibility = null; - if (mode === 'SAVINGS') { - this.checkSavings(); - } else if (mode === 'CASH') { - this.loadVouchers(); - } - }); + onSharesChange(): void { + // The verdict is cleared the moment the amount changes rather than when the re-check returns, so + // a stale "can cover it" is never on screen beside a larger amount. The chosen receipt stays -- + // it is the receipt the member actually holds, and the shortfall line below re-reads against the + // new amount on its own. + this.savings = null; + this.errorMsg = ''; + this.sharesChanged.next(Number(this.requestedShares) || 0); } - /** Shares x unit price — what this tranche is worth, and what has to be funded. */ - get amount(): number { - const shares = Number(this.applySharesForm?.get('requestedShares')?.value) || 0; - const price = Number(this.applySharesForm?.get('unitPrice')?.value) || 0; - return shares * price; - } - - get paymentMode(): string { - return this.applySharesForm?.get('paymentMode')?.value || ''; - } - - /** - * Loads the member's unapplied Share Sundry receipts so they can be ticked off. - * - * A single receipt is ticked for them: with only one candidate there is nothing to choose, and - * making the clerk tick it adds a step that can only be got wrong by forgetting it. - */ - private loadVouchers(): void { - this.loadingVouchers = true; - this.cashierService - .getUnappliedShareAdvances(this.sharesAccountData.clientId) - .pipe( - catchError((err: any) => { - this.voucherError = this.readError(err, 'Could not load the member’s receipts.'); - this.loadingVouchers = false; - return of(null); - }) - ) - .subscribe((res: any) => { - this.loadingVouchers = false; - if (!res) { - return; - } - this.vouchers = res.unappliedCollections || []; - if (this.vouchers.length === 1) { - this.selectedVoucherIds = [this.vouchers[0].id]; - } - }); - } - - isVoucherSelected(id: number): boolean { - return this.selectedVoucherIds.includes(id); - } - - toggleVoucher(id: number): void { - this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [ - ...this.selectedVoucherIds, - id - ]; - this.voucherError = ''; - } - - /** - * What the ticked receipts come to. - * - * Shown live, because the submission only goes through when the total covers the tranche, and - * adding the rows up by eye is exactly what leads to a rejected submission. - */ - get selectedVoucherTotal(): number { - return this.vouchers.filter((v) => this.isVoucherSelected(v.id)).reduce((sum, v) => sum + Number(v.amount || 0), 0); - } - - /** - * Resolves a voucher number the member quotes and adds it to the list. - * - * Kept alongside the tick list rather than replacing it: the member usually quotes the number on - * the slip in their hand, which is faster than finding it among their receipts. The lookup is - * deliberately not restricted to this member, so a voucher belonging to somebody else is - * surfaced here as a plain mismatch rather than silently ignored. - */ - lookupVoucher(voucherNo: string): void { - const trimmed = (voucherNo || '').trim(); - const glAccountNo = this.glAccountNo.trim(); - if (!trimmed || !glAccountNo || this.lookingUpVoucher) { + chooseFunding(source: FundingSource): void { + if (this.funding === source) { return; } - this.lookingUpVoucher = true; - this.voucherError = ''; + this.funding = source; + this.receipt = null; + this.savings = null; + this.errorMsg = ''; + if (source === 'SAVINGS') { + this.checkSavings(); + } else if (!this.available.length) { + this.loadReceipts(); + } + } + /** + * Loads every Share Collection receipt that could fund this tranche — the member's own, and the + * ones still belonging to nobody — as one list. + * + * Nothing is pre-selected, not even when a single receipt is listed: which receipt funds the + * tranche is a choice the person applying it makes, not one they inherit from a pre-ticked row + * they never looked at. + */ + private loadReceipts(): void { + this.loadingReceipts = true; this.cashierService - .getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId) + .getShareCollectionReceipts(this.data.clientId) .pipe( catchError((err: any) => { - this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`); - this.lookingUpVoucher = false; + this.errorMsg = this.readError(err, 'Could not load the available receipts.'); + this.loadingReceipts = false; return of(null); }) ) .subscribe((res: any) => { - this.lookingUpVoucher = false; + this.loadingReceipts = false; if (!res) { return; } - if (Number(res.client_id) !== Number(this.sharesAccountData.clientId)) { - this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`; - return; - } - // Added to the selection rather than replacing it — a looked-up voucher is usually the - // second half of a part-payment, not a change of mind about the first. No amount check - // here: one instalment is not meant to cover the tranche on its own. - this.vouchers = [ - res, - ...this.vouchers.filter((v) => v.id !== res.id) + this.available = [ + ...(res.own || []).map((row: any) => receiptFromOwn(row)), + ...(res.rows || []).map((row: any) => receiptFromUnattached(row)) ]; - if (!this.isVoucherSelected(res.id)) { - this.selectedVoucherIds = [ - ...this.selectedVoucherIds, - res.id - ]; - } }); } + /** + * Chooses the receipt funding this tranche, attaching it to this member first if it is still only + * a voucher number. + * + * A receipt paid straight into a GL account names no member — a GL account is nobody's account — + * so it has to be attributed before it can fund anything. That attribution is exactly the + * by-voucher lookup a hand-typed number already runs; doing it on the pick makes it one deliberate + * choice against a member on screen, rather than an Attach in one table and a tick in another. + */ + pick(option: ReceiptOption): void { + if (this.attaching || this.submitting) { + return; + } + if (this.receipt?.key === option.key) { + this.receipt = null; + return; + } + if (option.collectionId !== null) { + this.receipt = option; + return; + } + + this.attaching = option.key; + this.errorMsg = ''; + this.findVoucher(option.voucherNo, option.glAccountNo).subscribe((res) => { + this.attaching = null; + if (!res) { + return; + } + // Attached now, so it takes its real row's place in the pool -- picking it again must never + // run the attach a second time. + const attached = receiptFromOwn(res); + this.available = this.available.map((entry) => (entry.key === option.key ? attached : entry)); + this.receipt = attached; + }); + } + + clearReceipt(): void { + this.receipt = null; + } + + /** What the chosen receipt is still missing, zero once it covers the tranche. */ + get shortfall(): number { + const short = this.amount - Number(this.receipt?.amount || 0); + return this.receipt && short > 0 ? short : 0; + } + /** * Whether the member's savings can cover this tranche. * @@ -331,104 +261,215 @@ export class ApplySharesComponent implements OnInit { * submission will be valued at. Deciding it in the browser would let the two drift apart. */ private checkSavings(): void { - const shares = Number(this.applySharesForm.get('requestedShares').value) || 0; + const shares = Number(this.requestedShares) || 0; this.checkingSavings = true; this.sharesService - .getAdditionalSharesSavingsEligibility(this.accountId, shares) + .getAdditionalSharesSavingsEligibility(String(this.data.shareAccountId), shares) .pipe(catchError(() => of(null))) .subscribe((res: any) => { this.checkingSavings = false; - if (!res) { - this.savingsEligibility = { - eligible: false, - reason: 'Could not check the savings account linked to this share account.', - savingsAccountNo: null, - savingsBalance: null - }; - return; - } - this.savingsEligibility = { - eligible: !!res.eligible, - reason: res.reason ?? null, - savingsAccountNo: res.savingsAccountNo ?? null, - savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance) - }; + this.savings = res + ? { + eligible: !!res.eligible, + reason: res.reason ?? null, + savingsAccountNo: res.savingsAccountNo ?? null, + savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance) + } + : { + eligible: false, + reason: 'Could not check the savings account linked to this share account.', + savingsAccountNo: null, + savingsBalance: null + }; }); } /** - * Whether the chosen funding route is ready — receipts that between them cover the tranche, or - * savings that can cover it. + * Whether the chosen funding route is ready — a receipt that covers the tranche, or savings that + * can. * - * The total is checked here rather than only on submit, so a part-paid tranche is visibly short - * at the counter instead of being refused after the member has left. + * Checked here rather than only on submit, so a part-paid tranche is visibly short at the counter + * instead of being refused after the member has left. */ get fundingReady(): boolean { - if (this.paymentMode === 'CASH') { - return this.selectedVoucherIds.length > 0 && this.selectedVoucherTotal >= this.amount; + if (this.funding === 'CASH') { + return !!this.receipt && this.shortfall === 0; } - if (this.paymentMode === 'SAVINGS') { - return !!this.savingsEligibility?.eligible; + if (this.funding === 'SAVINGS') { + return !!this.savings?.eligible; } return false; } - /** The shortfall to tell the member about, or zero when the ticked receipts cover the tranche. */ - get voucherShortfall(): number { - const short = this.amount - this.selectedVoucherTotal; - return short > 0 ? short : 0; + get canSubmit(): boolean { + return ( + this.amount > 0 && + !!this.requestedDate && + !!this.resolutionNumber.trim() && + !!this.resolutionDate && + this.fundingReady && + !this.submitting + ); } - get canSubmit(): boolean { - return this.applySharesForm.valid && this.amount > 0 && this.fundingReady && !this.submitting; + // ── Voucher search, for a receipt the list does not carry ─────────────────────────────────── + + searchOpen = false; + searchVoucherNo = ''; + searchGlAccountNo = ''; + searching = false; + + /** GL accounts, for the search only — a voucher is found by number *and* the account it sits in. */ + glAccounts: any[] = []; + loadingGlAccounts = true; + + openSearch(): void { + this.searchOpen = true; + this.searchVoucherNo = ''; + this.searchGlAccountNo = this.glAccounts.length === 1 ? this.glAccounts[0].gl_code : ''; + this.errorMsg = ''; } + closeSearch(): void { + this.searchOpen = false; + this.errorMsg = ''; + } + + runSearch(): void { + const voucher = this.searchVoucherNo.trim(); + const glAccountNo = this.searchGlAccountNo.trim(); + // Both fields are genuinely needed: a blank GL account here means nothing has been chosen yet, + // not that none is required. + if (!voucher || !glAccountNo || this.searching) { + return; + } + this.searching = true; + this.errorMsg = ''; + this.findVoucher(voucher, glAccountNo).subscribe((res) => { + this.searching = false; + if (!res) { + return; + } + const found = receiptFromOwn(res); + this.available = [ + found, + ...this.available.filter((entry) => entry.key !== found.key && entry.voucherNo !== found.voucherNo) + ]; + this.receipt = found; + this.searchOpen = false; + }); + } + + private findVoucher(voucher: string, glAccountNo: string) { + // No client-match check: any receipt can fund any share account, regardless of which member it + // was first recorded against. + return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe( + catchError((err: any) => { + this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found in that GL account.`); + return of(null); + }) + ); + } + + // ── A receipt's own GL entries ────────────────────────────────────────────────────────────── + + viewing: ReceiptOption | null = null; + + /** The receipt's own real Dr/Cr legs, read under its own transaction id — never the GL account + * picked in the search box, which is unrelated screen state naming a different account. */ + viewingGlEntries: any[] | null = null; + loadingGlEntries = false; + + openDetail(option: ReceiptOption, event: Event): void { + event.stopPropagation(); + if (option.collectionId === null) { + return; + } + this.viewing = option; + this.viewingGlEntries = null; + this.loadingGlEntries = true; + this.cashierService + .getShareAdvanceGlEntries(option.collectionId) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.viewingGlEntries = res?.entries || []; + this.loadingGlEntries = false; + }); + } + + closeDetail(): void { + this.viewing = null; + this.viewingGlEntries = null; + } + + // ── Submission ────────────────────────────────────────────────────────────────────────────── + /** * Records the request. Nothing is issued here — the tranche sits pending until it is approved, * which is what moves the money out of Sundry into share capital. */ - submit() { + submit(): void { if (!this.canSubmit) { return; } this.submitting = true; this.errorMsg = ''; - const formValue = this.applySharesForm.value; - const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; - - const data: any = { - requestedShares: formValue.requestedShares, - requestedDate: this.dateUtils.formatDate(formValue.requestedDate, dateFormat), - unitPrice: this.applySharesForm.get('unitPrice').value, - resolutionNumber: formValue.resolutionNumber, - meetingDate: this.dateUtils.formatDate(formValue.resolutionDate, dateFormat), - fundingSource: formValue.paymentMode, + const payload: any = { + requestedShares: this.requestedShares, + requestedDate: this.dateUtils.formatDate(this.dateOf(this.requestedDate), dateFormat), + unitPrice: this.unitPrice, + resolutionNumber: this.resolutionNumber.trim(), + meetingDate: this.dateUtils.formatDate(this.dateOf(this.resolutionDate), dateFormat), + fundingSource: this.funding, dateFormat, - locale + locale: this.settingsService.language.code }; - if (formValue.paymentMode === 'CASH') { - // First receipt leads, the rest ride along; the server settles them as one funding. - const [ - leadId, - ...additionalCollectionIds - ] = this.selectedVoucherIds; - data.advanceCollectionId = leadId; - if (additionalCollectionIds.length > 0) { - data.additionalCollectionIds = additionalCollectionIds; - } + if (this.funding === 'CASH' && this.receipt) { + payload.advanceCollectionId = this.receipt.collectionId; } - this.sharesService.executeSharesAccountCommand(this.accountId, 'applyadditionalshares', data).subscribe({ - next: () => { - this.router.navigate(['../../'], { relativeTo: this.route }); - }, - error: (err: any) => { - this.submitting = false; - this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.'); - } - }); + this.sharesService + .executeSharesAccountCommand(String(this.data.shareAccountId), 'applyadditionalshares', payload) + .subscribe({ + next: (res: any) => { + this.dialogRef.close(res || true); + }, + error: (err: any) => { + this.submitting = false; + this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.'); + } + }); + } + + // ── Helpers ───────────────────────────────────────────────────────────────────────────────── + + /** The GL account a receipt sits in, named from the list the voucher search already loaded. */ + glLabel(option: ReceiptOption): string { + return receiptGlLabel(option, this.glAccounts); + } + + /** A Date as the yyyy-MM-dd a native date input reads, in local time. */ + private isoOf(date: Date): string { + const local = new Date(date); + return `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, '0')}-${String(local.getDate()).padStart(2, '0')}`; + } + + /** + * A yyyy-MM-dd back into a local Date. + * + * Built from the parts rather than {@code new Date(iso)}, which reads the string as UTC midnight + * and so lands on the previous day for anyone west of Greenwich — a request dated a day early is + * not something a date picker should be able to produce. + */ + private dateOf(iso: string): Date { + const [ + year, + month, + day + ] = iso.split('-').map(Number); + return new Date(year, month - 1, day); } private readError(err: any, fallback: string): string { diff --git a/src/app/shares/shares-account-actions/shares-account-actions.component.html b/src/app/shares/shares-account-actions/shares-account-actions.component.html index cd62cb4e5..bad4bae7c 100644 --- a/src/app/shares/shares-account-actions/shares-account-actions.component.html +++ b/src/app/shares/shares-account-actions/shares-account-actions.component.html @@ -21,9 +21,6 @@ @if (actions['Undo Approval']) { } -@if (actions['Apply Additional Shares']) { - -} @if (actions['Redeem Shares']) { } diff --git a/src/app/shares/shares-account-actions/shares-account-actions.component.ts b/src/app/shares/shares-account-actions/shares-account-actions.component.ts index ac6457cf2..5b64c4c0b 100644 --- a/src/app/shares/shares-account-actions/shares-account-actions.component.ts +++ b/src/app/shares/shares-account-actions/shares-account-actions.component.ts @@ -17,7 +17,6 @@ import { RejectSharesAccountComponent } from './reject-shares-account/reject-sha import { CloseSharesAccountComponent } from './close-shares-account/close-shares-account.component'; import { ActivateSharesAccountComponent } from './activate-shares-account/activate-shares-account.component'; import { UndoApprovalSharesAccountComponent } from './undo-approval-shares-account/undo-approval-shares-account.component'; -import { ApplySharesComponent } from './apply-shares/apply-shares.component'; import { RedeemSharesComponent } from './redeem-shares/redeem-shares.component'; import { ApproveSharesComponent } from './approve-shares/approve-shares.component'; import { RejectSharesComponent } from './reject-shares/reject-shares.component'; @@ -37,7 +36,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; CloseSharesAccountComponent, ActivateSharesAccountComponent, UndoApprovalSharesAccountComponent, - ApplySharesComponent, RedeemSharesComponent, ApproveSharesComponent, RejectSharesComponent @@ -55,7 +53,6 @@ export class SharesAccountActionsComponent { Close: boolean; Activate: boolean; 'Undo Approval': boolean; - 'Apply Additional Shares': boolean; 'Redeem Shares': boolean; 'Approve Additional Shares': boolean; 'Reject Additional Shares': boolean; @@ -65,7 +62,6 @@ export class SharesAccountActionsComponent { Close: false, Activate: false, 'Undo Approval': false, - 'Apply Additional Shares': false, 'Redeem Shares': false, 'Approve Additional Shares': false, 'Reject Additional Shares': false diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html index 4733ce6b2..4a91e4673 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html @@ -6,222 +6,416 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -

Apply Share Advance Collection

- - -

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

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

Apply Share Collection

+

+ Share account {{ data.accountNo }} +

+
- - @if (requirement && requirement.chargeAmount > 0) { -
- - - @if (chargeReceipt) { -
- {{ chargeShortfall > 0 ? 'error_outline' : 'check_circle_outline' }} - - {{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }} - — {{ chargeReceipt.amount | number: '1.2-2' }} - @if (chargeShortfall > 0) { - (short by {{ chargeShortfall | number: '1.2-2' }}) - } @else { - — entry fee verified - } - - -
- } @else { -
- + @for (gl of glAccounts; track gl.id) { } -
+ -
+
+ -
- Leave this blank if the fee was already paid as part of the share receipt above. -
- } -
- } - - @if (errorMsg) { -
- error_outline - {{ errorMsg }} -
- } - - @if (loading) { -
- - Loading receipts… -
- } @else if (receipts.length === 0) { -
- info_outline -
- No unapplied receipt for this member today. -
- If the cash was taken on an earlier day, enter that voucher number above. Otherwise collect it first from - Cashier › Transaction Entry as a deposit to the Share Sundry GL — once a manager approves it, the - voucher appears here. -
-
-
- } @else { - - - - - - - - - - - @for (r of receipts; track r.id) { - - - - - - + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
} - - - @if (selectedIds.length > 0) { - - - - - - - - } -
Voucher / ReceiptDateAmount
- - - {{ r.voucher_no || r.receipt_no }}{{ r.collection_date }}{{ r.amount | number: '1.2-2' }}
- {{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected - {{ selectedTotal | number: '1.2-2' }}
+
-
- Their total must cover the account's share value — plus any entry fee, unless it was verified as its own receipt - above. Anything paid over that is credited to the member's savings account. +
+ + +
- } - +
+} - - - - + +@if (viewing) { +
+ +
+} diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss index b7546ba4a..6252c63cb 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss @@ -6,265 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -$primary: #1a3a6b; -$border: #d8e0ec; -$muted: #6b7a90; -$danger: #b3261e; -$success: #1e7d3c; +// Everything this dialog looks like lives in the shared partial, which its sibling Apply Additional +// Shares dialog uses too -- the two settle the same receipts for the same person and are meant to be +// the same screen. +@use '../../share-dialog.shared' as *; -.intro { - font-size: 13px; - color: $muted; - margin: 0 0 12px; -} - -.requirement-summary { - display: flex; - flex-direction: column; - gap: 4px; - background: #f4f7fb; - border: 1px solid $border; - border-radius: 6px; - padding: 10px 12px; - font-size: 13px; - color: $primary; - margin: 0 0 14px; -} - -.requirement-progress { - font-size: 12px; - font-weight: 500; - color: $danger; - - &.covered { - color: $success; - } -} - -.voucher-section { - margin: 0 0 14px; -} - -.voucher-section-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.4px; - color: $muted; - margin: 0 0 6px; -} - -.charge-receipt-found { - display: flex; - align-items: center; - gap: 8px; - background: #eefaf1; - border: 1px solid #bfe6cb; - border-radius: 6px; - padding: 8px 10px; - font-size: 13px; - color: $success; - - &.short { - background: #fdecea; - border-color: #f5c6c2; - color: $danger; - } - - mat-icon { - flex-shrink: 0; - } -} - -.charge-receipt-detail { - flex: 1; - min-width: 0; -} - -.charge-receipt-hint { - font-size: 11px; - color: $muted; - margin-top: 4px; -} - -.voucher-lookup { - display: flex; - gap: 8px; - align-items: center; - margin: 0 0 14px; -} - -.voucher-input { - flex: 1; - min-width: 0; - padding: 9px 11px; - border: 1px solid $border; - border-radius: 6px; - font-family: 'Roboto Mono', monospace; - font-size: 13px; - letter-spacing: 0.4px; - color: $primary; - - &:focus { - outline: none; - border-color: $primary; - } - - &:disabled { - background: #f4f7fb; - color: $muted; - } -} - -.gl-select-field { - margin: 0 0 10px; -} - -.gl-select { +:host { + display: block; width: 100%; - padding: 9px 11px; - border: 1px solid $border; - border-radius: 6px; - font-size: 13px; - color: $primary; - background: #fff; - - &:focus { - outline: none; - border-color: $primary; - } - - &:disabled { - background: #f4f7fb; - color: $muted; - } -} - -.loading { - display: flex; - align-items: center; - gap: 10px; - padding: 16px 0; - font-size: 13px; - color: $muted; -} - -.empty { - display: flex; - gap: 10px; - align-items: flex-start; - background: #f4f7fb; - border: 1px solid $border; - border-radius: 6px; - padding: 12px; - font-size: 13px; -} - -.empty-hint { - color: $muted; - margin-top: 4px; -} - -.receipt-table { - width: 100%; - border-collapse: collapse; - font-size: 13px; - border: 1px solid $border; - border-radius: 6px; - overflow: hidden; - - th { - background: #eef2f8; - color: $primary; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.4px; - padding: 9px 12px; - white-space: nowrap; - } - - td { - padding: 10px 12px; - border-bottom: 1px solid #eceff4; - color: #1f2937; - } - - tbody tr:last-child td { - border-bottom: none; - } - - /* Just wide enough for the box, so the voucher number starts near the left edge instead of - after a column of empty space. */ - .col-tick { - width: 36px; - } - - tbody tr { - cursor: pointer; - - &:hover { - background: #f4f7fb; - } - - &.selected { - background: #e7effb; - } - } - - /* Tabular figures so the amounts line up digit for digit down the column and against the total - below them — the whole point of the column is that it can be read as a sum. */ - .amount-cell { - font-variant-numeric: tabular-nums; - font-weight: 500; - white-space: nowrap; - } - - tfoot .total-row { - background: #f4f7fb; - border-top: 2px solid #cbd5e1; - - td { - padding-top: 10px; - font-size: 13px; - font-weight: 600; - color: #1f3d7a; - border-bottom: none; - } - } -} - -.ta-left { - text-align: left; -} - -.ta-right { - text-align: right; -} - -.ta-center { - text-align: center; -} - -.mono { - font-family: 'Courier New', monospace; -} - -.note { - margin-top: 10px; - font-size: 12px; - color: $muted; -} - -.alert-error { - display: flex; - align-items: center; - gap: 8px; - background: #fdecea; - color: $danger; - border: 1px solid #f5c6c2; - border-radius: 6px; - padding: 10px 12px; - font-size: 13px; - margin-bottom: 12px; } diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts index 7ceb51747..1fe74f803 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts @@ -13,26 +13,32 @@ import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/materia import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { catchError, map } from 'rxjs/operators'; +import { catchError } from 'rxjs/operators'; import { of } from 'rxjs'; import { CashierService } from 'app/cashier/cashier.service'; -import { SettingsService } from 'app/settings/settings.service'; -import { Dates } from 'app/core/utils/dates'; +import { ReceiptOption, receiptFromOwn, receiptFromUnattached, receiptGlLabel } from 'app/shares/share-receipt-option'; + +/** Which of the two receipt slots a pick, a search or a clear is meant for. */ +export type ReceiptSlot = 'share' | 'fee'; /** - * Applies a Share Advance receipt to an activated share account. + * Applies Share Advance receipts to an activated share account. * - * The member's cash is already sitting in the Share Sundry liability from the cashier's advance - * collection. Applying a receipt here posts Dr Sundry / Cr Share Capital (+ Cr Income from Fees - * when an entry fee applies) and reveals the purchase entry in the transactions and passbook. + * The member's cash is already sitting in the Share Sundry liability from the cashier's collection. + * Settling here posts Dr Sundry / Cr Share Capital and reveals the purchase in the transactions and + * the passbook. * - * The list shows the member's unapplied receipts for the business date, since a member settling an - * application at the counter has almost always paid for it today, and an older unapplied receipt of - * theirs is more likely money taken for something else. Nothing in it is ticked for the clerk — see - * {@link ngOnInit}. + * Two receipts settle an application and no more: one for the share value, and — when the product + * charges one — one for the entry fee. They are chosen in two separate slots because they are two + * different things: the share receipt is money that moves into share capital, while the fee receipt + * only proves the fee was already collected and is never posted a second time (its money was + * recognised as income by the cashier's own entry). Picking both out of one ticked list was what + * made that distinction invisible. * - * A receipt outside that narrow list is reached by voucher number through {@link lookupVoucher} — - * cash taken on an earlier day, or a slip the member is holding that is quicker to type than to find. + * Every available receipt is offered in both slots, whether it already names this member or is a + * voucher paid straight into a GL account. A voucher that names nobody yet is attached to this + * member at the moment it is picked, so the counter does an Attach-then-tick as a single click + * rather than as two steps in two different tables. */ @Component({ selector: 'mifosx-apply-collection-dialog', @@ -50,89 +56,60 @@ import { Dates } from 'app/core/utils/dates'; }) export class ApplyCollectionDialogComponent implements OnInit { private cashierService = inject(CashierService); - private settingsService = inject(SettingsService); - private dateUtils = inject(Dates); - - loading = true; - submitting = false; - errorMsg = ''; - receipts: any[] = []; - - /** - * Receipts chosen for this settlement, in the order they were picked. - * - * More than one because a member often pays an application in instalments — ₹4,500 today, the - * ₹500 balance tomorrow — and neither receipt settles the account on its own. They are applied - * together as a single settlement rather than forcing the member back to the counter for a fresh - * receipt covering the whole amount. - */ - selectedIds: number[] = []; - - /** Voucher number quoted by the member, for a receipt the list does not show. */ - voucherNo = ''; - lookingUp = false; - - /** GL account the deposit was posted to — required, and always picked by hand. Never resolved - * from a share product's accounting setup, so a broken or missing product mapping can never - * make a genuine voucher unreachable. */ - glAccountNo = ''; - glAccounts: any[] = []; - loadingGlAccounts = true; - - /** A second, separately-tracked receipt search for the entry fee — a member sometimes pays the - * share value and the fee as two separate receipts, posted to two different GL accounts, so one - * voucher search covering only the share value would never reach the fee on its own. - * - * Kept entirely apart from {@link receipts}/{@link selectedIds}: this receipt is sent to the - * server as its own field (see {@link apply}) so it is never swept into the Sundry-side sum and - * never gets a GL entry of its own — it is proof the fee was paid, not money that needs to move, - * since it never sat in Sundry in the first place. */ - chargeVoucherNo = ''; - chargeGlAccountNo = ''; - lookingUpCharge = false; - chargeReceipt: any = null; - - /** What this account needs before a collection can be applied — fetched up front so the counter - * knows the full figure (and that it may take two receipts) before a search ever comes up short. */ - requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null; - loadingRequirement = true; public dialogRef = inject(MatDialogRef); public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA); + loading = true; + submitting = false; + errorMsg = ''; + + /** Every receipt that can be picked, from both server lists, in one pool. */ + available: ReceiptOption[] = []; + + /** The receipt covering the share value. Required — nothing settles without it. */ + shareReceipt: ReceiptOption | null = null; + + /** The receipt covering the entry fee. Verification only: never posted to a GL account. */ + chargeReceipt: ReceiptOption | null = null; + + /** What this account needs before a collection can be applied, fetched up front. */ + requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null; + + /** Key of the receipt currently being attached to this member, so only its own row shows a spinner. */ + attaching: string | null = null; + ngOnInit(): void { + // One call for both lists -- the member's own receipts and every other Share Collection receipt + // still available. They come from the same table under the same filters, so asking twice only + // ever fetched the same rows twice. this.cashierService - .getUnappliedShareAdvances(this.data.clientId) + .getShareCollectionReceipts(this.data.clientId) .pipe( catchError((err) => { - this.errorMsg = this.readError(err, 'Could not load the member’s receipts.'); + this.errorMsg = this.readError(err, 'Could not load the available receipts.'); this.loading = false; return of(null); }) ) .subscribe((res: any) => { if (res) { - const today = this.dateUtils.formatDate(this.settingsService.businessDate, 'yyyy-MM-dd'); - this.receipts = (res.unappliedCollections || []).filter( - (receipt: any) => this.collectionDateOf(receipt) === today - ); - // Deliberately nothing ticked, not even when a single receipt is listed. Applying a - // collection turns the member's money into share capital, so which receipt it comes out of - // should be a choice the clerk made rather than one they inherited from a pre-ticked box - // and never looked at. + this.available = [ + ...(res.own || []).map((row: any) => receiptFromOwn(row)), + ...(res.rows || []).map((row: any) => receiptFromUnattached(row)) + ]; + // Nothing pre-selected, not even when a single receipt is listed. Settling turns the + // member's money into share capital, so which receipt it comes out of should be a choice + // somebody made rather than one they inherited and never looked at. } this.loading = false; }); + // Needed only by the voucher search, which is the exception path -- loaded quietly in the + // background so opening that popup never waits on a request. this.cashierService .getShareAdvanceGlAccounts() - .pipe( - catchError((err) => { - this.errorMsg = this.readError(err, 'Could not load the GL account list.'); - this.loadingGlAccounts = false; - return of(null); - }) - ) + .pipe(catchError(() => of(null))) .subscribe((res: any) => { this.glAccounts = res?.glAccounts || []; this.loadingGlAccounts = false; @@ -149,14 +126,80 @@ export class ApplyCollectionDialogComponent implements OnInit { required: Number(res.required || 0) }; } - this.loadingRequirement = false; }); } + // ── The two slots ─────────────────────────────────────────────────────────────────────────── + + /** Receipts offered for a slot: everything available except whatever the other slot already holds. */ + optionsFor(slot: ReceiptSlot): ReceiptOption[] { + const taken = slot === 'share' ? this.chargeReceipt : this.shareReceipt; + return taken ? this.available.filter((option) => option.key !== taken.key) : this.available; + } + + selected(slot: ReceiptSlot): ReceiptOption | null { + return slot === 'share' ? this.shareReceipt : this.chargeReceipt; + } + + isPicked(slot: ReceiptSlot, option: ReceiptOption): boolean { + return this.selected(slot)?.key === option.key; + } + /** - * What the share receipts still need to reach — the share value alone once the entry fee has its - * own verified receipt, otherwise the combined share-plus-fee total, matching exactly what the - * server checks the Sundry-side receipts against in {@link apply}. + * Chooses a receipt for a slot, attaching it to this member first if it is still only a voucher + * number. + * + * A receipt paid straight into a GL account names no member — a GL account is nobody's account — + * so it has to be attributed before it can settle anything. That attribution is exactly the + * by-voucher lookup a hand-typed voucher number already runs; doing it on the pick means the + * counter makes one deliberate choice against a member they can see, instead of an Attach in one + * table followed by a tick in another. + */ + pick(slot: ReceiptSlot, option: ReceiptOption): void { + if (this.attaching || this.submitting) { + return; + } + if (this.isPicked(slot, option)) { + this.clear(slot); + return; + } + if (option.collectionId !== null) { + this.assign(slot, option); + return; + } + + this.attaching = option.key; + this.errorMsg = ''; + this.findVoucher(option.voucherNo, option.glAccountNo).subscribe((res) => { + this.attaching = null; + if (!res) { + return; + } + // Attached now, so it takes its real row's place in the pool -- picking it a second time (or + // in the other slot) must never run the attach again. + const attached = receiptFromOwn(res); + this.available = this.available.map((entry) => (entry.key === option.key ? attached : entry)); + this.assign(slot, attached); + }); + } + + clear(slot: ReceiptSlot): void { + this.assign(slot, null); + } + + private assign(slot: ReceiptSlot, option: ReceiptOption | null): void { + if (slot === 'share') { + this.shareReceipt = option; + } else { + this.chargeReceipt = option; + } + } + + // ── What still has to be covered ──────────────────────────────────────────────────────────── + + /** + * What the share receipt has to reach — the share value alone once the fee has its own receipt, + * otherwise the combined share-plus-fee total, matching exactly what the server checks. */ get shareRequired(): number { if (!this.requirement) { @@ -165,20 +208,19 @@ export class ApplyCollectionDialogComponent implements OnInit { return this.chargeReceipt ? this.requirement.shareAmount : this.requirement.required; } - /** What the share receipts are still missing, zero once fully covered. */ - get shortfall(): number { - const short = this.shareRequired - this.selectedTotal; - return short > 0 ? short : 0; + /** What the chosen share receipt is still missing, zero once it covers the requirement. */ + get shareShortfall(): number { + const short = this.shareRequired - Number(this.shareReceipt?.amount || 0); + return this.shareReceipt && short > 0 ? short : 0; } - /** Whether a verified entry fee receipt still needs to be found — false when there is no fee, or - * when the fee was already folded into the share receipt(s) above. */ - get chargeUnverified(): boolean { - return !!this.requirement && this.requirement.chargeAmount > 0 && !this.chargeReceipt; + /** Whether this product charges an entry fee at all. */ + get hasFee(): boolean { + return !!this.requirement && this.requirement.chargeAmount > 0; } - /** Whether the found entry fee receipt actually covers the fee. */ - get chargeShortfall(): number { + /** What the chosen fee receipt is still missing, zero once it covers the fee. */ + get feeShortfall(): number { if (!this.requirement || !this.chargeReceipt) { return 0; } @@ -187,162 +229,129 @@ export class ApplyCollectionDialogComponent implements OnInit { } /** - * A receipt's collection date as {@code yyyy-MM-dd}. + * Whether the settlement can go through. * - * Normalises both shapes the API can send — that string, or Fineract's {@code [yyyy, m, d]} array - * — rather than assuming one, so the filter cannot silently match nothing if the serialiser - * behind this endpoint changes. + * Blocked on a shortfall rather than letting the server reject it: the figures are all on screen + * already, so a submission that can only come back as an error is a round trip nobody needed. */ - private collectionDateOf(receipt: any): string { - const raw = receipt?.collection_date ?? receipt?.collectionDate; - if (Array.isArray(raw) && raw.length >= 3) { - const [ - year, - month, - day - ] = raw; - return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - } - return typeof raw === 'string' ? raw.slice(0, 10) : ''; + get canApply(): boolean { + return !!this.shareReceipt && this.shareShortfall === 0 && this.feeShortfall === 0 && !this.submitting; } - isSelected(id: number): boolean { - return this.selectedIds.includes(id); + // ── Voucher search, for a receipt the list does not carry ─────────────────────────────────── + + /** Slot the open search popup is searching for, or null while it is closed. */ + searchFor: ReceiptSlot | null = null; + searchVoucherNo = ''; + searchGlAccountNo = ''; + searching = false; + + /** GL accounts, for the search only — a voucher is found by number *and* the account it sits in. */ + glAccounts: any[] = []; + loadingGlAccounts = true; + + openSearch(slot: ReceiptSlot): void { + this.searchFor = slot; + this.searchVoucherNo = ''; + this.searchGlAccountNo = this.glAccounts.length === 1 ? this.glAccounts[0].gl_code : ''; + this.errorMsg = ''; } - toggle(id: number): void { - this.selectedIds = this.isSelected(id) ? this.selectedIds.filter((x) => x !== id) : [ - ...this.selectedIds, - id - ]; + closeSearch(): void { + this.searchFor = null; + this.errorMsg = ''; } - /** - * What the selected receipts come to. - * - * Shown live as receipts are ticked, because the settlement only goes through when the total - * covers the account — checking that by adding up the rows by eye is exactly what leads to a - * rejected submission. - */ - get selectedTotal(): number { - return this.receipts.filter((r) => this.isSelected(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0); - } - - /** - * Resolves the voucher number the member quotes into a receipt and adds it to the list. - * - * The list is deliberately narrow — this member, today, unapplied — so the search is how a - * receipt outside that gets settled: one taken yesterday, or one the member holds a slip for that - * is quicker to type than to find. The lookup itself is not restricted to this member, so a - * voucher belonging to somebody else is surfaced here as a clear mismatch rather than failing - * later inside apply(). - */ - lookupVoucher(): void { - const voucher = this.voucherNo.trim(); - const glAccountNo = this.glAccountNo.trim(); - if (!voucher || !glAccountNo || this.lookingUp) { + runSearch(): void { + const voucher = this.searchVoucherNo.trim(); + const glAccountNo = this.searchGlAccountNo.trim(); + const slot = this.searchFor; + // Both fields are genuinely needed: a blank GL account here means nothing has been chosen yet, + // not that none is required. + if (!voucher || !glAccountNo || !slot || this.searching) { return; } - this.lookingUp = true; + this.searching = true; this.errorMsg = ''; this.findVoucher(voucher, glAccountNo).subscribe((res) => { - this.lookingUp = false; + this.searching = false; if (!res) { return; } - // Added to the table (replacing any stale copy) and ticked, unlike the listed receipts which - // are left for the clerk: typing a voucher number IS the deliberate choice of that receipt, - // so making them tick it again would be asking the same question twice. - this.receipts = [ - res, - ...this.receipts.filter((r) => r.id !== res.id) + const found = receiptFromOwn(res); + this.available = [ + found, + ...this.available.filter((entry) => entry.key !== found.key && entry.voucherNo !== found.voucherNo) ]; - if (!this.isSelected(res.id)) { - this.selectedIds = [ - ...this.selectedIds, - res.id - ]; - } - this.voucherNo = ''; + this.assign(slot, found); + this.searchFor = null; }); } - /** - * Same lookup as {@link lookupVoucher}, against its own GL account and voucher fields — for a - * receipt that covers the entry fee rather than the share value. - * - * Deliberately NOT added to {@link receipts}/{@link selectedIds}: this receipt is kept on its own - * ({@link chargeReceipt}) and sent to the server as a distinct field, so it is never swept into the - * Sundry-side total and never gets a settlement GL entry of its own — it proves the fee was paid, - * it does not move money that never sat in Sundry to begin with. - */ - lookupChargeVoucher(): void { - const voucher = this.chargeVoucherNo.trim(); - const glAccountNo = this.chargeGlAccountNo.trim(); - if (!voucher || !glAccountNo || this.lookingUpCharge) { - return; - } - this.lookingUpCharge = true; - this.errorMsg = ''; - this.findVoucher(voucher, glAccountNo).subscribe((res) => { - this.lookingUpCharge = false; - if (!res) { - return; - } - this.chargeReceipt = res; - this.chargeVoucherNo = ''; - }); - } - - /** Clears a wrongly-found entry fee receipt so a different voucher can be searched instead. */ - clearChargeReceipt(): void { - this.chargeReceipt = null; - } - - /** - * Resolves one voucher number into the receipt it names, common to both lookups above. Each caller - * decides what to do with the result — added to the shared table for a share receipt, kept on its - * own for the entry fee — since the two are never allowed to mix into the same sum. - */ private findVoucher(voucher: string, glAccountNo: string) { + // No client-match check: any receipt can settle any share account, regardless of which member it + // was first recorded against. return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe( - map((res: any) => { - if (Number(res.client_id) !== Number(this.data.clientId)) { - this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`; - return null; - } - return res; - }), catchError((err) => { - this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`); + this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found in that GL account.`); return of(null); }) ); } + // ── A receipt's own GL entries ────────────────────────────────────────────────────────────── + + viewing: ReceiptOption | null = null; + + /** + * The receipt's own real Dr/Cr legs, read under its own {@code gl_transaction_id} — never the GL + * account picked in a search box, which is unrelated screen state naming a different account. + */ + viewingGlEntries: any[] | null = null; + loadingGlEntries = false; + + openDetail(option: ReceiptOption, event: Event): void { + event.stopPropagation(); + if (option.collectionId === null) { + return; + } + this.viewing = option; + this.viewingGlEntries = null; + this.loadingGlEntries = true; + this.cashierService + .getShareAdvanceGlEntries(option.collectionId) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.viewingGlEntries = res?.entries || []; + this.loadingGlEntries = false; + }); + } + + closeDetail(): void { + this.viewing = null; + this.viewingGlEntries = null; + } + + // ── Submission ────────────────────────────────────────────────────────────────────────────── + apply(): void { - if (this.selectedIds.length === 0 || this.submitting) { + if (!this.canApply || !this.shareReceipt) { return; } this.submitting = true; this.errorMsg = ''; - // The first receipt leads on the URL; the rest ride along in the body, and the server settles - // them as one transaction. The entry fee receipt, when one was found, rides along as its own - // field — never folded into additionalCollectionIds, so the server never sums it into the - // Sundry-side total or posts a GL entry for it. - const [ - leadId, - ...additionalCollectionIds - ] = this.selectedIds; - - const data: any = { shareAccountId: this.data.shareAccountId, additionalCollectionIds }; + // The share receipt leads on the URL and is the only one the server sums into the Sundry-side + // total. The fee receipt rides along as its own field so it is never added to that total and + // never gets a GL entry of its own -- its money was already recognised as income when the + // cashier took it. + const payload: any = { shareAccountId: this.data.shareAccountId, additionalCollectionIds: [] }; if (this.chargeReceipt) { - data.chargeCollectionId = this.chargeReceipt.id; + payload.chargeCollectionId = this.chargeReceipt.collectionId; } this.cashierService - .applyShareAdvance(leadId, data) + .applyShareAdvance(this.shareReceipt.collectionId as number, payload) .pipe( catchError((err) => { this.errorMsg = this.readError(err, 'Applying the collection failed.'); @@ -357,6 +366,11 @@ export class ApplyCollectionDialogComponent implements OnInit { }); } + /** The GL account a receipt sits in, named from the list the voucher search already loaded. */ + glLabel(option: ReceiptOption): string { + return receiptGlLabel(option, this.glAccounts); + } + private readError(err: any, fallback: string): string { return ( err?.error?.errors?.[0]?.defaultUserMessage || diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss index 840774130..274b178df 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss @@ -384,18 +384,59 @@ /* Print CSS */ @media print { - /* Hide non-overlay contents during print */ + ::ng-deep html, + ::ng-deep body { + height: auto !important; + overflow: visible !important; + } + + /* Hide non-overlay contents during print. Deliberately NOT `::ng-deep` here: under Angular's + emulated encapsulation a bare `body` selector never carries this component's scoping + attribute, so a scoped rule can never match the real and is safely inert — whereas + `::ng-deep` emits it fully unscoped into the GLOBAL stylesheet for the rest of the session + (Angular never removes a component's injected styles when it's destroyed), silently hiding + every other page's print output app-wide the moment this dialog is opened once. The app + shell (nav, toolbar, sidebar) is already hidden for every print via the global rules in + main.scss, so this dialog doesn't need its own global override to get the same effect. */ body > :not(.cdk-overlay-container) { display: none !important; } + /* The dialog's own scroll region clips anything past 80vh -- printing only captures what a + scrollable/max-height box shows on screen, so both constraints must be lifted. */ + .dialog-content-container { + max-height: none !important; + overflow: visible !important; + } + + /* CDK's overlay wrapper/backdrop/pane are all position: fixed. Unset them so the full certificate prints statically. */ + ::ng-deep .cdk-overlay-backdrop { + display: none !important; + } + + ::ng-deep .cdk-overlay-container, + ::ng-deep .cdk-global-overlay-wrapper, + ::ng-deep .cdk-overlay-pane, + ::ng-deep .mat-mdc-dialog-container, + ::ng-deep .mdc-dialog__container, + ::ng-deep .mdc-dialog__surface { + position: static !important; + display: block !important; + max-height: none !important; + max-width: none !important; + height: auto !important; + width: auto !important; + overflow: visible !important; + transform: none !important; + box-shadow: none !important; + background: transparent !important; + } + /* Make certificate fill page */ #share-certificate-print { - position: absolute; - left: 0; - top: 0; + position: static !important; width: 100% !important; - height: 100% !important; + height: auto !important; padding: 0 !important; margin: 0 !important; background-color: #fff !important; @@ -411,7 +452,135 @@ display: none !important; } - .mat-mdc-dialog-container { + ::ng-deep .mat-mdc-dialog-container { padding: 0 !important; } + + /* Compact every spacing/font value so the whole certificate fits a single printed page */ + .certificate-container { + padding: 6px !important; + } + + .certificate-border-inner { + padding: 14px !important; + } + + .certificate-header { + margin-bottom: 10px !important; + padding-bottom: 10px !important; + + .bank-name { + font-size: 19px !important; + margin-bottom: 2px !important; + } + + .bank-sub { + font-size: 10px !important; + margin-bottom: 8px !important; + } + + .cert-title { + font-size: 19px !important; + letter-spacing: 2px !important; + margin-bottom: 2px !important; + } + + .cert-subtitle { + font-size: 9px !important; + } + } + + .cert-meta-row { + margin-bottom: 10px !important; + padding-bottom: 8px !important; + + .meta-label { + font-size: 9px !important; + margin-bottom: 1px !important; + } + + .meta-value { + font-size: 12px !important; + } + } + + .cert-body-section { + margin-bottom: 10px !important; + + .cert-main-text { + font-size: 11px !important; + line-height: 1.4 !important; + margin-bottom: 10px !important; + } + } + + .issued-to-details { + padding: 8px 14px !important; + margin: 8px auto !important; + + .client-name-details { + font-size: 14px !important; + } + + .client-ids, + .client-address { + font-size: 10px !important; + } + } + + .certificate-details-table { + margin-bottom: 12px !important; + + td { + padding: 5px 10px !important; + font-size: 10px !important; + } + + .lbl-col-highlight { + font-size: 12px !important; + } + + .val-col-highlight { + font-size: 13px !important; + } + } + + .allotment-table { + margin-bottom: 12px !important; + + th { + padding: 4px 8px !important; + font-size: 8px !important; + } + + td { + padding: 4px 8px !important; + font-size: 9px !important; + } + } + + .cert-footer { + margin-top: 8px !important; + + .seal-ring { + width: 60px !important; + height: 60px !important; + } + + .seal-text { + font-size: 8px !important; + } + + .closing-text { + font-size: 10px !important; + } + + .signature-line { + height: 22px !important; + } + + .signatory-title { + font-size: 10px !important; + } + } } diff --git a/src/app/shares/shares-account-view/shares-account-view.component.ts b/src/app/shares/shares-account-view/shares-account-view.component.ts index 84103260f..1b90ad39e 100644 --- a/src/app/shares/shares-account-view/shares-account-view.component.ts +++ b/src/app/shares/shares-account-view/shares-account-view.component.ts @@ -15,6 +15,7 @@ import { MatDialog } from '@angular/material/dialog'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { ShareCertificateDialogComponent } from './share-certificate-dialog/share-certificate-dialog.component'; import { ApplyCollectionDialogComponent } from './apply-collection-dialog/apply-collection-dialog.component'; +import { ApplySharesComponent } from '../shares-account-actions/apply-shares/apply-shares.component'; /** Custom Services */ import { SharesService } from '../shares.service'; @@ -149,6 +150,10 @@ export class SharesAccountViewComponent implements OnInit { // capital that was never issued. if (!this.isSettled) { this.buttonConfig.removeOption('Share Certificate'); + // A further tranche on top of an application the member never paid for makes no sense: the + // first allotment is still sitting unissued, and a second one would only grow a holding that + // does not exist yet. Settle the original collection first, then add to it. + this.buttonConfig.removeButton('Apply Additional Shares'); } else { // "Apply Collection" settles the account's ORIGINAL application funding -- a one-time // step. Once collectionApplied is true there is nothing left to settle this way; any @@ -171,7 +176,6 @@ export class SharesAccountViewComponent implements OnInit { case 'Close': case 'Activate': case 'Undo Approval': - case 'Apply Additional Shares': case 'Redeem Shares': case 'Approve Additional Shares': case 'Reject Additional Shares': @@ -186,6 +190,9 @@ export class SharesAccountViewComponent implements OnInit { case 'Apply Collection': this.applyCollection(); break; + case 'Apply Additional Shares': + this.applyAdditionalShares(); + break; case 'Share Certificate': this.viewShareCertificate(); break; @@ -202,7 +209,11 @@ export class SharesAccountViewComponent implements OnInit { */ private applyCollection() { const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, { - width: '560px', + // Two receipt slots and a requirement strip, nothing wider -- the screen used to carry a + // multi-tick table plus a second, separate attach table, which is what needed 820px. + width: '780px', + maxWidth: '96vw', + panelClass: 'apply-collection-dialog-panel', data: { shareAccountId: this.sharesAccountData.id, clientId: this.sharesAccountData.clientId, @@ -213,18 +224,54 @@ export class SharesAccountViewComponent implements OnInit { if (result) { // Account data comes from a route resolver, so re-running the route is what refreshes the // newly revealed transaction and the now-paid charges. - this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { - this.router.navigate([ - '/clients', - this.sharesAccountData.clientId, - 'shares-accounts', - this.sharesAccountData.id - ]); - }); + this.reloadAccount(); } }); } + /** + * Raises a request for a further tranche of shares on this account. + * + * A dialog rather than its own page, like Apply Collection beside it: both are a short form filled + * in with the member standing at the counter, and sending the clerk to a separate screen only + * meant losing the account they were looking at to come back to. + */ + private applyAdditionalShares() { + const dialogRef = this.dialog.open(ApplySharesComponent, { + width: '780px', + maxWidth: '96vw', + panelClass: 'apply-collection-dialog-panel', + data: { + shareAccountId: this.sharesAccountData.id, + clientId: this.sharesAccountData.clientId, + accountNo: this.sharesAccountData.accountNo, + unitPrice: this.sharesAccountData.currentMarketPrice + } + }); + dialogRef.afterClosed().subscribe((result: any) => { + if (result) { + this.reloadAccount(); + } + }); + } + + /** + * Re-runs this account's route so its resolver refetches everything on screen. + * + * Through the root first, because Angular reuses the component when only the same route's data + * changes — which would leave the page showing exactly the figures the dialog just changed. + */ + private reloadAccount() { + this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { + this.router.navigate([ + '/clients', + this.sharesAccountData.clientId, + 'shares-accounts', + this.sharesAccountData.id + ]); + }); + } + /** * Whether the member's money has actually been taken and the shares issued. * diff --git a/src/app/shares/shares-routing.module.ts b/src/app/shares/shares-routing.module.ts index a2f7f614f..1e80216e3 100644 --- a/src/app/shares/shares-routing.module.ts +++ b/src/app/shares/shares-routing.module.ts @@ -41,6 +41,147 @@ const routes: Routes = [ data: { title: 'Share Holders', breadcrumb: 'Share Holders' }, component: ShareHoldersComponent }, + // Share reports. Lazily loaded one component at a time: a clerk opening the Share menu for a + // withdrawal should not pay for four report screens they did not ask for, and each is + // self-contained enough that there is nothing to share by bundling them together. + { + path: 'reports/final-balance', + data: { title: 'Share Final Balance', breadcrumb: 'Final Balance' }, + loadComponent: () => + import('./reports/share-final-balance/share-final-balance.component').then( + (m) => m.ShareFinalBalanceComponent + ) + }, + { + path: 'reports/account-statement', + data: { title: 'Share Account Statement', breadcrumb: 'Account Statement' }, + loadComponent: () => + import('./reports/share-account-statement/share-account-statement.component').then( + (m) => m.ShareAccountStatementComponent + ) + }, + { + path: 'reports/share-issued', + data: { title: 'Share Issued Register', breadcrumb: 'Share Issued' }, + loadComponent: () => + import('./reports/share-issued-report/share-issued-report.component').then( + (m) => m.ShareIssuedReportComponent + ) + }, + { + path: 'reports/share-status', + data: { title: 'Share Status Report', breadcrumb: 'Share Status' }, + loadComponent: () => + import('./reports/share-status-report/share-status-report.component').then( + (m) => m.ShareStatusReportComponent + ) + }, + { + path: 'reports/product-summary', + data: { title: 'Share Product Summary', breadcrumb: 'Product Summary' }, + loadComponent: () => + import('./reports/share-product-summary/share-product-summary.component').then( + (m) => m.ShareProductSummaryComponent + ) + }, + { + path: 'reports/nominal-shares', + data: { title: 'Nominal Share Report', breadcrumb: 'Nominal Share Report' }, + loadComponent: () => + import('./reports/share-nominal-report/share-nominal-report.component').then( + (m) => m.ShareNominalReportComponent + ) + }, + { + path: 'reports/share-wise', + data: { title: 'Share Wise Report', breadcrumb: 'Share Wise Report' }, + loadComponent: () => + import('./reports/share-wise-report/share-wise-report.component').then((m) => m.ShareWiseReportComponent) + }, + { + path: 'reports/guarantor-detail', + data: { title: 'Guarantor Detail', breadcrumb: 'Guarantor Detail' }, + loadComponent: () => + import('./reports/share-guarantor-report/share-guarantor-report.component').then( + (m) => m.ShareGuarantorReportComponent + ) + }, + { + path: 'reports/member-interest', + data: { title: 'Total Interest Received & Receivable', breadcrumb: 'Total Int Rec' }, + loadComponent: () => + import('./reports/share-member-interest-report/share-member-interest-report.component').then( + (m) => m.ShareMemberInterestReportComponent + ) + }, + { + path: 'reports/enquiry', + data: { title: 'Share Enquiry', breadcrumb: 'Share Enquiry' }, + loadComponent: () => + import('./reports/share-enquiry/share-enquiry.component').then((m) => m.ShareEnquiryComponent) + }, + { + path: 'reports/payment-certificate', + data: { title: 'Share Payment Certificate', breadcrumb: 'Payment Certificate' }, + loadComponent: () => + import('./reports/share-payment-certificate/share-payment-certificate.component').then( + (m) => m.SharePaymentCertificateComponent + ) + }, + { + path: 'reports/share-detail', + data: { title: 'Share Detail', breadcrumb: 'Share Detail' }, + loadComponent: () => + import('./reports/share-detail-report/share-detail-report.component').then( + (m) => m.ShareDetailReportComponent + ) + }, + { + path: 'reports/dividend-statement', + data: { title: 'Dividend Statement', breadcrumb: 'Dividend Statement' }, + loadComponent: () => + import('./reports/share-dividend-statement/share-dividend-statement.component').then( + (m) => m.ShareDividendStatementComponent + ) + }, + { + path: 'reports/folio-statement', + data: { title: 'Folio Statement', breadcrumb: 'Folio Statement' }, + loadComponent: () => + import('./reports/share-folio-statement/share-folio-statement.component').then( + (m) => m.ShareFolioStatementComponent + ) + }, + { + path: 'transfers', + data: { title: 'Share Transfer', breadcrumb: 'Share Transfer' }, + loadComponent: () => + import('./reports/share-transfer/share-transfer.component').then((m) => m.ShareTransferComponent) + }, + { + path: 'reports/distinctive-numbers', + data: { title: 'Share Distinctive Number Detail', breadcrumb: 'Distinctive Numbers' }, + loadComponent: () => + import('./reports/share-distinctive-numbers/share-distinctive-numbers.component').then( + (m) => m.ShareDistinctiveNumbersComponent + ) + }, + { + path: 'reports/area-wise', + data: { title: 'Area Wise Shareholding', breadcrumb: 'Area Wise' }, + loadComponent: () => + import('./reports/share-area-wise/share-area-wise.component').then((m) => m.ShareAreaWiseComponent) + }, + { + path: 'reports/labels', + data: { title: 'Share Labels', breadcrumb: 'Share Label' }, + loadComponent: () => import('./reports/share-label/share-label.component').then((m) => m.ShareLabelComponent) + }, + { + path: 'reports/letters', + data: { title: 'Share Letter', breadcrumb: 'Share Letter' }, + loadComponent: () => import('./reports/share-letter/share-letter.component').then((m) => m.ShareLetterComponent) + }, { path: 'create', data: { title: 'Create Shares Account', breadcrumb: 'Create Shares Account' }, diff --git a/src/app/shares/shares.module.ts b/src/app/shares/shares.module.ts index 6a88f04fc..e279265ec 100644 --- a/src/app/shares/shares.module.ts +++ b/src/app/shares/shares.module.ts @@ -30,7 +30,6 @@ import { RejectSharesAccountComponent } from './shares-account-actions/reject-sh import { CloseSharesAccountComponent } from './shares-account-actions/close-shares-account/close-shares-account.component'; import { ActivateSharesAccountComponent } from './shares-account-actions/activate-shares-account/activate-shares-account.component'; import { UndoApprovalSharesAccountComponent } from './shares-account-actions/undo-approval-shares-account/undo-approval-shares-account.component'; -import { ApplySharesComponent } from './shares-account-actions/apply-shares/apply-shares.component'; import { RedeemSharesComponent } from './shares-account-actions/redeem-shares/redeem-shares.component'; import { ApproveSharesComponent } from './shares-account-actions/approve-shares/approve-shares.component'; import { ApproveShareDialogComponent } from './shares-account-actions/approve-shares/approve-share-dialog/approve-share-dialog.component'; @@ -83,7 +82,6 @@ import { MatInputModule } from '@angular/material/input'; CloseSharesAccountComponent, ActivateSharesAccountComponent, UndoApprovalSharesAccountComponent, - ApplySharesComponent, RedeemSharesComponent, ApproveSharesComponent, ApproveShareDialogComponent, diff --git a/src/app/shares/shares.service.ts b/src/app/shares/shares.service.ts index 0bf0f7d71..744cd8f22 100644 --- a/src/app/shares/shares.service.ts +++ b/src/app/shares/shares.service.ts @@ -190,4 +190,184 @@ export class SharesService { rejectShareAccountEdit(accountId: string, rejectReason?: string): Observable { return this.http.post(`/shareaccounts/${accountId}/edit/reject`, { rejectReason: rejectReason || '' }); } + + // ── Share reports ────────────────────────────────────────────────────── + // + // Each of these is a single server-side query returning finished rows plus its own totals, rather + // than the fetch-all-accounts-then-fan-out pattern the older letter-mgmt reports use. That pattern + // stops at the 200-account page it asks for and recomputes totals from whatever arrived, which a + // report a balance sheet has to reconcile to cannot do. + + /** + * Share capital held per member as on a date, derived from settled purchases less redemptions. + * + * @param params asOnDate (yyyy-MM-dd), officeId, productId, includeZero — all optional. + */ + getShareFinalBalance(params: { + asOnDate?: string; + officeId?: number | string; + productId?: number | string; + includeZero?: boolean; + }): Observable { + return this.http.get('/sharereports/final-balance', { params: this.cleanParams(params) }); + } + + /** One share account's movements in date order, with the running holding after each. */ + getShareAccountStatement( + shareAccountId: number | string, + params: { fromDate?: string; toDate?: string } = {} + ): Observable { + return this.http.get(`/sharereports/account-statement/${shareAccountId}`, { params: this.cleanParams(params) }); + } + + /** Allotment register — one row per tranche of shares issued in the period. */ + getShareIssuedReport(params: { + fromDate?: string; + toDate?: string; + officeId?: number | string; + productId?: number | string; + status?: number | string; + }): Observable { + return this.http.get('/sharereports/share-issued', { params: this.cleanParams(params) }); + } + + /** Share accounts grouped by status and product, with the accounts behind each group. */ + getShareStatusSummary(params: { officeId?: number | string; productId?: number | string } = {}): Observable { + return this.http.get('/sharereports/status-summary', { params: this.cleanParams(params) }); + } + + /** Holdings measured against the share class's own nominal, minimum and maximum, at face value. */ + getShareNominalReport( + params: { asOnDate?: string; officeId?: number | string; productId?: number | string } = {} + ): Observable { + return this.http.get('/sharereports/nominal-shares', { params: this.cleanParams(params) }); + } + + /** + * Every share product's authorised capital against what has actually been paid up — shares issued, + * shares remaining, unpaid allotments and the distinctive-number range, per product. + */ + getShareProductSummary(): Observable { + return this.http.get('/sharereports/product-summary'); + } + + /** Shareholding pattern — members and capital per holding band, plus the per-product split. */ + getShareWiseReport( + params: { asOnDate?: string; officeId?: number | string; productId?: number | string } = {} + ): Observable { + return this.http.get('/sharereports/share-wise', { params: this.cleanParams(params) }); + } + + /** Active loan guarantors with the shareholding each one holds in the society. */ + getShareGuarantorDetail(params: { officeId?: number | string; onlyMembers?: boolean } = {}): Observable { + return this.http.get('/sharereports/guarantor-detail', { params: this.cleanParams(params) }); + } + + /** Interest received and receivable from the members who hold share capital. */ + getShareMemberLoanInterest( + params: { officeId?: number | string; includeClosedLoans?: boolean } = {} + ): Observable { + return this.http.get('/sharereports/member-loan-interest', { params: this.cleanParams(params) }); + } + + /** + * One member's whole share position — accounts, movements, dividends and linked savings. + * + * Sends `search` when the member is being looked for and `clientId` once one is chosen: a search + * matching several members comes back as a list to pick from rather than guessing between them. + */ + getShareEnquiry(params: { search?: string; clientId?: number | string }): Observable { + return this.http.get('/sharereports/enquiry', { params: this.cleanParams(params) }); + } + + /** What a member actually paid for their shares, and against which receipt — for the certificate. */ + getSharePaymentCertificate(shareAccountId: number | string): Observable { + return this.http.get(`/sharereports/payment-certificate/${shareAccountId}`); + } + + /** The full share register: every account with its class, terms, lock-in, charges and resolution. */ + getShareDetailRegister( + params: { officeId?: number | string; productId?: number | string; status?: number | string } = {} + ): Observable { + return this.http.get('/sharereports/share-detail', { params: this.cleanParams(params) }); + } + + /** One member's dividends across every year, declared against paid. */ + getShareDividendStatement(params: { search?: string; clientId?: number | string }): Observable { + return this.http.get('/sharereports/dividend-statement', { params: this.cleanParams(params) }); + } + + /** One member's folio: every share account they hold, with the movements running across all of them. */ + getShareFolioStatement(params: { + search?: string; + clientId?: number | string; + fromDate?: string; + toDate?: string; + }): Observable { + return this.http.get('/sharereports/folio-statement', { params: this.cleanParams(params) }); + } + + /** The numbered share range each allotment covers, derived from issue order per share class. */ + getShareDistinctiveNumbers( + params: { productId?: number | string; clientId?: number | string } = {} + ): Observable { + return this.http.get('/sharereports/distinctive-numbers', { params: this.cleanParams(params) }); + } + + /** Members and share capital grouped by the area their address puts them in. */ + getShareAreaWise(params: { productId?: number | string } = {}): Observable { + return this.http.get('/sharereports/area-wise', { params: this.cleanParams(params) }); + } + + /** + * Name, address, folio and holding for every shareholding member. + * + * One call behind both Share Label and Share Letter — they want the same rows and differ only in + * what is printed around them. + */ + getShareMailingList( + params: { productId?: number | string; officeId?: number | string; area?: string } = {} + ): Observable { + return this.http.get('/sharereports/mailing-list', { params: this.cleanParams(params) }); + } + + // ── Share transfers (maker-checker) ──────────────────────────────────── + + /** Records a transfer between two members. Nothing moves until a checker approves it. */ + submitShareTransfer(data: any): Observable { + return this.http.post('/sharetransfers', data); + } + + /** The transfer register — every transfer with both members and the resolution behind it. */ + getShareTransfers(status?: string): Observable { + return this.http.get('/sharetransfers', { params: this.cleanParams({ status }) }); + } + + /** Approves a transfer: re-checks every rule, then moves the shares. */ + approveShareTransfer(transferId: number): Observable { + return this.http.post(`/sharetransfers/${transferId}/approve`, {}); + } + + rejectShareTransfer(transferId: number, rejectionReason?: string): Observable { + return this.http.post(`/sharetransfers/${transferId}/reject`, { rejectionReason: rejectionReason || '' }); + } + + /** + * Drops empty filters rather than sending them blank — the report endpoints treat an absent + * parameter as "no filter", but a present-and-empty one would be read as a real value. + */ + private cleanParams(params: Record): HttpParams { + let httpParams = new HttpParams(); + Object.entries(params).forEach( + ([ + key, + value + ]) => { + if (value !== null && value !== undefined && value !== '') { + httpParams = httpParams.set(key, String(value)); + } + } + ); + return httpParams; + } } diff --git a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html index e1a192400..b4b5384ed 100644 --- a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html +++ b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html @@ -5,124 +5,244 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
-
-
-

Sponsor Bank DD Inventory

-

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

+ +
+ +
+
+
+ +
+
+

Sponsor Bank DD Inventory

+

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

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

No DD Series Found

+

No registered DD stock series match your filter criteria.

+
+ } +
+
diff --git a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts index 22da93780..9fe0013b0 100644 --- a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts +++ b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts @@ -22,6 +22,8 @@ import { MatNativeDateModule } from '@angular/material/core'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { HttpClient } from '@angular/common/http'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { SponsorDDService } from '../sponsor-dd.service'; @Component({ @@ -30,106 +32,482 @@ import { SponsorDDService } from '../sponsor-dd.service'; imports: [ CommonModule, RouterModule, + FormsModule, + ReactiveFormsModule, MatCardModule, MatTableModule, MatButtonModule, MatDialogModule, - MatSnackBarModule + MatSnackBarModule, + MatFormFieldModule, + MatInputModule, + MatIconModule, + MatTooltipModule, + FaIconComponent ], templateUrl: './dd-inventory-list.component.html', styles: [ ` - .inventory-container { + .inv-container { padding: 24px; - max-width: 1200px; + max-width: 1360px; margin: 0 auto; } - .header-row { + + /* Hero Header */ + .inv-hero { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; + gap: 16px; + flex-wrap: wrap; } - .title-area h2 { - margin: 0 0 4px 0; - font-size: 24px; - font-weight: 600; - color: #1e293b; - } - .title-area p { - margin: 0; - color: #64748b; - font-size: 14px; - } - .mat-mdc-card { - border-radius: 12px; - border: 1px solid #e2e8f0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); - overflow: hidden; - } - table { - width: 100%; - } - th { - font-weight: 600; - color: #475569; - background-color: #f8fafc; - } - td { - color: #334155; - } - .status-badge { - display: inline-flex; - align-items: center; - padding: 4px 8px; - border-radius: 9999px; - font-size: 12px; - font-weight: 500; - } - .status-badge.active { - background-color: #dcfce7; - color: #15803d; - } - .status-badge.exhausted { - background-color: #f1f5f9; - color: #475569; - } - .action-btn { - color: #7c3aed; - font-weight: 500; - } - .leaves-low { - color: #b91c1c; - font-weight: 600; - } - .dividend-banner { + .inv-hero__left { display: flex; align-items: center; gap: 16px; - margin-bottom: 20px; - padding: 12px 16px; - border: 1px solid #c7d2fe; - border-left: 4px solid #4f46e5; - border-radius: 8px; - background-color: #eef2ff; } - .dividend-banner__text { + .inv-hero__icon { + width: 52px; + height: 52px; + border-radius: 14px; + background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + box-shadow: 0 4px 14px rgba(37, 99, 235, 0.25); + } + .inv-hero__title { + margin: 0; + font-size: 22px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.02em; + } + .inv-hero__sub { + margin: 2px 0 0 0; + font-size: 13.5px; + color: #64748b; + } + + /* Dividend Banner */ + .inv-dividend-banner { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + padding: 16px 20px; + border-radius: 12px; + border: 1px solid #c7d2fe; + background: linear-gradient(135deg, #eef2ff 0%, #f5f3ff 100%); + box-shadow: 0 2px 8px rgba(79, 70, 229, 0.08); + } + .inv-dividend-banner__ic { + width: 40px; + height: 40px; + border-radius: 10px; + background: #4f46e5; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; + } + .inv-dividend-banner__text { display: flex; flex-direction: column; gap: 2px; - font-size: 13px; + font-size: 13.5px; color: #312e81; } - .dividend-banner__text strong { - font-size: 13.5px; - } - .dividend-banner__hint { - color: #4338ca; + .inv-dividend-banner__hint { font-size: 12px; + color: #4338ca; } - .dividend-banner button { + .inv-dividend-banner button { margin-left: auto; } + + /* KPI Cards */ + .inv-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; + margin-bottom: 24px; + } + .inv-kpi-card { + background: #ffffff; + border-radius: 12px; + padding: 18px 20px; + border: 1px solid #e2e8f0; + display: flex; + align-items: center; + gap: 16px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); + transition: + transform 0.15s ease, + box-shadow 0.15s ease; + } + .inv-kpi-card:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); + } + .inv-kpi-card__icon { + width: 46px; + height: 46px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; + } + .inv-kpi-card__icon.blue { + background: #eff6ff; + color: #2563eb; + } + .inv-kpi-card__icon.green { + background: #f0fdf4; + color: #16a34a; + } + .inv-kpi-card__icon.purple { + background: #faf5ff; + color: #9333ea; + } + .inv-kpi-card__icon.emerald { + background: #ecfdf5; + color: #059669; + } + .inv-kpi-card__info { + display: flex; + flex-direction: column; + } + .inv-kpi-card__label { + font-size: 12px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .inv-kpi-card__val { + font-size: 24px; + font-weight: 800; + color: #0f172a; + margin-top: 2px; + line-height: 1; + } + + /* Toolbar */ + .inv-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + flex-wrap: wrap; + } + .inv-search { + position: relative; + flex: 1; + min-width: 280px; + max-width: 460px; + } + .inv-search input { + width: 100%; + padding: 10px 14px 10px 38px; + border: 1px solid #cbd5e1; + border-radius: 10px; + font-size: 13.5px; + background: #ffffff; + color: #0f172a; + outline: none; + box-sizing: border-box; + transition: + border-color 0.15s, + box-shadow 0.15s; + } + .inv-search input:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); + } + .inv-search__ic { + position: absolute; + left: 13px; + top: 50%; + transform: translateY(-50%); + color: #94a3b8; + font-size: 13px; + pointer-events: none; + } + .inv-search__clear { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + padding: 4px; + } + .inv-search__clear:hover { + color: #ef4444; + } + + .inv-filter-chips { + display: flex; + align-items: center; + gap: 8px; + } + .inv-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 999px; + border: 1px solid #e2e8f0; + background: #ffffff; + color: #475569; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + } + .inv-chip:hover { + background: #f8fafc; + border-color: #cbd5e1; + } + .inv-chip.active { + background: #1e40af; + border-color: #1e40af; + color: #ffffff !important; + } + .inv-chip.active-chip.active { + background: #16a34a; + border-color: #16a34a; + color: #ffffff !important; + } + .inv-chip.exhausted-chip.active { + background: #475569; + border-color: #475569; + color: #ffffff !important; + } + .inv-chip__dot { + width: 7px; + height: 7px; + border-radius: 50%; + } + .inv-chip__dot.green { + background: #16a34a; + } + .inv-chip__dot.grey { + background: #64748b; + } + .inv-chip.active .inv-chip__dot { + background: #ffffff; + } + .inv-chip__count { + padding: 2px 7px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.07); + font-size: 11px; + font-weight: 700; + } + .inv-chip.active .inv-chip__count { + background: rgba(255, 255, 255, 0.25); + color: #ffffff; + } + + /* Card & Table */ + .inv-card { + background: #ffffff; + border-radius: 14px; + border: 1px solid #e2e8f0; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05); + overflow: hidden; + } + .inv-card__head { + padding: 16px 20px; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%); + } + .inv-card__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #0f172a; + } + .inv-card__title-ic { + color: #2563eb; + } + .inv-card__count { + margin-left: 6px; + padding: 2px 10px; + border-radius: 999px; + background: #eff6ff; + color: #1e40af; + font-size: 11px; + font-weight: 700; + } + + .inv-table-wrap { + overflow-x: auto; + } + .inv-table { + width: 100%; + border-collapse: collapse; + } + .inv-table th { + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 12px 16px; + border-bottom: 1px solid #e2e8f0; + white-space: nowrap; + } + .inv-table td { + padding: 12px 16px; + color: #334155; + border-bottom: 1px solid #f1f5f9; + font-size: 13.5px; + vertical-align: middle; + } + .inv-table tr:hover { + background: #f8fafc; + } + + .inv-cell-bank { + display: flex; + align-items: center; + gap: 8px; + } + .bank-icon { + color: #2563eb; + font-size: 14px; + } + + .inv-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .inv-strong { + font-weight: 700; + color: #0f172a; + } + .inv-muted { + color: #64748b; + } + .inv-num { + text-align: right; + } + + .inv-leaves-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 6px; + background: #f0fdf4; + color: #16a34a; + font-weight: 700; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .inv-leaves-badge.low { + background: #fef2f2; + color: #dc2626; + } + + .inv-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + } + .inv-badge__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + } + .inv-badge.active { + background: #dcfce7; + color: #15803d; + } + .inv-badge.exhausted { + background: #f1f5f9; + color: #64748b; + } + + .inv-actions-col { + text-align: right; + } + + /* Buttons */ + .inv-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 18px; + border-radius: 10px; + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.15s ease; + } + .inv-btn.primary { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + color: #ffffff; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3); + } + .inv-btn.primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(37, 99, 235, 0.4); + } + .inv-btn.ghost { + background: #ffffff; + border-color: #cbd5e1; + color: #475569; + } + .inv-btn.ghost:hover { + background: #f8fafc; + color: #0f172a; + } + .inv-btn.small { + padding: 6px 12px; + font-size: 12px; + border-radius: 8px; + } + + .inv-empty { + text-align: center; + padding: 48px 20px; + color: #64748b; + } + .inv-empty__ic { + font-size: 36px; + color: #cbd5e1; + margin-bottom: 8px; + } + .inv-empty__title { + font-size: 16px; + font-weight: 700; + color: #334155; + margin: 0 0 4px 0; + } + .inv-empty__sub { + font-size: 13px; + margin: 0; + } ` ] }) @@ -140,14 +518,9 @@ export class DdInventoryListComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); - /** - * The dividend this visit is drawing a draft for, when the clerk arrived from a dividend claim - * rather than from the menu. - * - * Null on an ordinary visit, which is what keeps this page's normal behaviour untouched — the - * banner, the relabelled action and the forwarded query params all hang off this being set. - */ dividendContext: { lockId: number; amount: number; beneficiary: string; financialYear: string } | null = null; + searchText = ''; + selectedStatusFilter = 'ALL'; displayedColumns: string[] = [ 'id', @@ -168,13 +541,6 @@ export class DdInventoryListComponent implements OnInit { this.loadInventories(); } - /** - * Picks up the dividend a claim sent us here to pay out. - * - * Read from the URL rather than handed over in a service so a refreshed or bookmarked page still - * knows which lock it is drawing against — losing it silently would issue an ordinary counter - * draft against the member instead of settling their dividend. - */ private readDividendContext(): void { const params = this.route.snapshot.queryParams; const lockId = Number(params['dividendLockId']); @@ -189,7 +555,6 @@ export class DdInventoryListComponent implements OnInit { }; } - /** The dividend context to carry onto the leaflets page; empty on an ordinary visit. */ get dividendQueryParams(): Record { return this.dividendContext ? { @@ -201,7 +566,6 @@ export class DdInventoryListComponent implements OnInit { : {}; } - /** Abandons the claim and returns to the dividend dashboard, leaving the lock untouched. */ cancelDividendClaim(): void { this.router.navigate([ '/clerk', @@ -212,7 +576,7 @@ export class DdInventoryListComponent implements OnInit { loadInventories(): void { this.sponsorDDService.getInventories().subscribe({ next: (data) => { - this.dataSource = data; + this.dataSource = data || []; }, error: (err) => { this.snackBar.open('Error loading inventories: ' + (err.error?.message || err.message), 'Close', { @@ -222,9 +586,44 @@ export class DdInventoryListComponent implements OnInit { }); } + get totalBatches(): number { + return this.dataSource.length; + } + + get activeBatchesCount(): number { + return this.dataSource.filter((x) => (x.status || '').toUpperCase() === 'ACTIVE').length; + } + + get totalLeavesCount(): number { + return this.dataSource.reduce((acc, curr) => acc + (Number(curr.totalLeaves) || 0), 0); + } + + get totalAvailableLeaves(): number { + return this.dataSource.reduce((acc, curr) => acc + (Number(curr.availableLeaves) || 0), 0); + } + + get filteredData(): any[] { + let list = this.dataSource; + if (this.selectedStatusFilter !== 'ALL') { + list = list.filter((item) => (item.status || '').toUpperCase() === this.selectedStatusFilter); + } + if (this.searchText.trim()) { + const q = this.searchText.trim().toLowerCase(); + list = list.filter( + (item) => + String(item.id).toLowerCase().includes(q) || + (item.sponsorBankName || '').toLowerCase().includes(q) || + (item.branchName || '').toLowerCase().includes(q) || + String(item.seriesStart).includes(q) || + String(item.seriesEnd).includes(q) + ); + } + return list; + } + openAddInventoryDialog(): void { const dialogRef = this.dialog.open(AddInventoryDialogComponent, { - width: '500px', + width: '520px', disableClose: true }); diff --git a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html index 49ee7aed2..15eb71f78 100644 --- a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html +++ b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html @@ -45,9 +45,28 @@ DD No. - {{ element.ddNumber }} -
- Slip · {{ batchSiblingCount(element) }} drafts + +
+ +
+ {{ element.ddNumber }} +
+ Slip #{{ element.batchId }} · draft {{ batchPositionOf(element) }} of + {{ batchSiblingCount(element) }} +
+
@@ -64,7 +83,19 @@ Face Value - {{ element.faceValue | number: '1.2-2' }} + + {{ element.faceValue | number: '1.2-2' }} + +
+ Slip debits ₹{{ batchTotalDebit(element) | number: '1.2-2' }} +
+
@@ -107,19 +138,34 @@ Actions -
+ +
- Approving/rejecting applies to all {{ batchSiblingCount(element) }} drafts in this slip. + Covers all {{ batchSiblingCount(element) }} drafts. You can leave out any the customer no longer wants. +
+
+ ↑ handled with Slip #{{ element.batchId }}
{ - this.dataSource.data = data; + // Grouped here rather than in the template: a slip's drafts have to be adjacent rows for the + // shared colour bar and the "n of m" position to read as one block. + this.dataSource.data = this.groupBySlip(data || []); this.applyFilter(); }, error: (err) => { @@ -235,6 +272,78 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { return this.batchSiblings(request).length; } + /** + * Whether this row is the one that carries its slip's buttons — its first draft. + * + * A single (non-slip) request is always its own lead, so the ordinary case is untouched. + */ + isBatchLead(request: any): boolean { + return !request.batchId || this.batchPositionOf(request) === 1; + } + + /** + * Which draft of its slip this row is — "2 of 3". + * + * A slip's drafts are ordered by the request id they were created with, which is the order the + * clerk entered them in and the order the server allocated leaflets in. Without this an approver + * looking at three rows of one slip cannot tell whether they are seeing all of it. + */ + batchPositionOf(request: any): number { + if (!request.batchId) { + return 1; + } + const ordered = this.batchSiblings(request).sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0)); + return ordered.findIndex((r) => r.id === request.id) + 1; + } + + /** What the whole slip debits: every draft's face value, charges and GST together. */ + batchTotalDebit(request: any): number { + return this.batchSiblings(request).reduce( + (sum, r) => sum + (Number(r.faceValue) || 0) + (Number(r.chargesAmount) || 0) + (Number(r.gstAmount) || 0), + 0 + ); + } + + /** + * A stable colour per slip, so two customers' slips sitting next to each other are told apart at a + * glance rather than by reading every batch number. + * + * Keyed off the batch id itself so the same slip keeps its colour across reloads and across the + * PENDING/APPROVED/ALL tabs — a colour that reshuffled on every refresh would be worse than none. + */ + batchColour(request: any): string { + if (!request.batchId) { + return 'transparent'; + } + const palette = [ + '#7c3aed', + '#0891b2', + '#c2410c', + '#15803d', + '#be123c', + '#4338ca' + ]; + return palette[Number(request.batchId) % palette.length]; + } + + /** + * Orders the table so a slip's drafts sit together, newest slip first. + * + * The list arrives in whatever order the server returns, which scattered one slip's drafts among + * another's — an approver then had no way of seeing which rows belonged together, and the "Slip · + * 3 drafts" badge only said that some three rows somewhere did. + */ + private groupBySlip(rows: any[]): any[] { + return [...rows].sort((a, b) => { + const groupA = Number(a.batchId ?? a.id) || 0; + const groupB = Number(b.batchId ?? b.id) || 0; + if (groupA !== groupB) { + return groupB - groupA; + } + return (Number(a.id) || 0) - (Number(b.id) || 0); + }); + } + approve(request: any): void { if (request.batchId) { this.approveBatch(request); @@ -287,29 +396,51 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { } private approveBatch(request: any): void { - const siblings = this.batchSiblings(request); + // Ordered by request id so the checkbox list matches the "draft n of m" the table shows. + const siblings = this.batchSiblings(request).sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0)); const total = siblings.reduce((sum, r) => sum + (Number(r.faceValue) || 0), 0); this.dialog .open(ApproveIssueRequestDialogComponent, { - width: '460px', + width: '520px', maxWidth: '95vw', data: { isBatch: true, clientName: request.clientName, totalFaceValue: total, - lines: siblings.map((r) => ({ beneficiaryName: r.beneficiaryName, faceValue: Number(r.faceValue) || 0 })), + // id, charges and GST travel with each line: the dialog debits by what is ticked, and + // names the rows back to the server by id rather than by position. + lines: siblings.map((r) => ({ + id: r.id, + ddNumber: r.ddNumber, + beneficiaryName: r.beneficiaryName, + faceValue: Number(r.faceValue) || 0, + chargesAmount: Number(r.chargesAmount) || 0, + gstAmount: Number(r.gstAmount) || 0 + })), effect: this.effectTextFor(request, siblings.length) } }) .afterClosed() - .subscribe((confirmed) => { - if (!confirmed) { + .subscribe((result) => { + if (!result) { return; } - this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({ + // true means the whole slip, so no selection is sent and the server takes its ordinary path. + const approvedRequestIds = result === true ? undefined : result.approvedRequestIds; + const issuing = approvedRequestIds ? approvedRequestIds.length : siblings.length; + const dropped = siblings.length - issuing; + this.sponsorDDService.approveIssueRequestBatch(request.batchId, approvedRequestIds).subscribe({ next: () => { - this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 }); + this.snackBar.open( + dropped > 0 + ? `${issuing} draft${issuing === 1 ? '' : 's'} issued · ${dropped} not required, DD number${ + dropped === 1 ? '' : 's' + } returned to stock` + : 'DD slip approved and posted successfully', + 'Close', + { duration: dropped > 0 ? 6000 : 3000 } + ); this.loadRequests(); }, error: (err) => { @@ -380,11 +511,35 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
- -
    -
  • {{ l.beneficiaryName }} — ₹{{ l.faceValue | number: '1.2-2' }}
  • -
+ +
+ +
+ + +
+
+ {{ selectedCount }} of {{ data.lines.length }} drafts selected + ₹{{ selectedDebit | number: '1.2-2' }} +
+

+ {{ droppedCount }} draft{{ droppedCount === 1 ? '' : 's' }} will not be issued. Their DD numbers return to + stock for the next customer, and nothing is debited for them. +

+

{{ data.effect }} @@ -392,8 +547,18 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { - `, @@ -433,11 +598,69 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { } .approve-lines { margin: 0 0 12px; - padding-left: 20px; font-size: 12.5px; color: #334155; - max-height: 160px; + max-height: 200px; overflow-y: auto; + border: 1px solid #e2e8f0; + border-radius: 8px; + } + /* Whole row is the hit target — a checkbox alone is a small thing to aim at when the decision + it carries is whether a customer's draft gets issued. */ + .approve-line { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-bottom: 1px solid #f1f5f9; + cursor: pointer; + } + .approve-line:last-child { + border-bottom: none; + } + .approve-line:hover { + background: #f8fafc; + } + .approve-line input { + cursor: pointer; + width: 16px; + height: 16px; + flex-shrink: 0; + } + .approve-line__dd { + font-family: monospace; + color: #0369a1; + flex-shrink: 0; + } + .approve-line__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .approve-line__amt { + font-weight: 600; + flex-shrink: 0; + } + /* A draft that has been unticked is still listed, greyed, so the approver can see what they + are leaving out rather than watching it disappear. */ + .approve-line input:not(:checked) ~ span { + opacity: 0.45; + text-decoration: line-through; + } + .approve-selection { + border: 1px solid #bfdbfe; + background: #eff6ff; + border-radius: 8px; + padding: 2px 14px; + margin-bottom: 12px; + } + .approve-dropped { + margin: 0 0 10px; + font-size: 11.5px; + line-height: 1.5; + color: #92400e; } .approve-effect { margin: 0; @@ -455,6 +678,75 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { export class ApproveIssueRequestDialogComponent { dialogRef = inject(MatDialogRef); data = inject(MAT_DIALOG_DATA); + + /** + * Which of the slip's drafts are still wanted, by line index. + * + * Every draft starts ticked: the clerk submitted the slip for a customer who asked for all of it, + * so approving the whole thing has to stay the single-click case. Dropping one is the exception + * and costs one tick. + */ + selected = new Set((this.data.lines || []).map((_l: any, i: number) => i)); + + isSelected(index: number): boolean { + return this.selected.has(index); + } + + toggle(index: number): void { + if (this.selected.has(index)) { + this.selected.delete(index); + } else { + this.selected.add(index); + } + } + + get selectedCount(): number { + return this.selected.size; + } + + get droppedCount(): number { + return (this.data.lines?.length || 0) - this.selected.size; + } + + /** What one draft costs the account: its face value, its own charge, and the GST on that charge. */ + lineDebitAt(index: number): number { + const line = this.data.lines?.[index]; + if (!line) { + return 0; + } + return (Number(line.faceValue) || 0) + (Number(line.chargesAmount) || 0) + (Number(line.gstAmount) || 0); + } + + /** The debit as it currently stands — only the ticked drafts. */ + get selectedDebit(): number { + return [...this.selected].reduce((sum, i) => sum + this.lineDebitAt(i), 0); + } + + /** + * Closes with the request ids to issue, or true for "all of them". + * + * True rather than the full id list when nothing was dropped, so the caller sends no selection at + * all and the server takes its ordinary whole-slip path. + */ + confirm(): void { + if (!this.data.isBatch) { + this.dialogRef.close(true); + return; + } + if (this.selectedCount === 0) { + return; + } + if (this.droppedCount === 0) { + this.dialogRef.close(true); + return; + } + this.dialogRef.close({ + approvedRequestIds: [...this.selected] + .sort((a, b) => a - b) + .map((i) => this.data.lines[i].id) + .filter((id: any) => id != null) + }); + } } // ── REJECT DIALOG COMPONENT ── diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.html b/src/app/sponsor-dd/dd-list/dd-list.component.html index 1a0b5be09..4350c76c8 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.html +++ b/src/app/sponsor-dd/dd-list/dd-list.component.html @@ -5,251 +5,326 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -

-
-
- -

DD Leaflet Registry

-

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

+ +
+

DD Leaflet Registry

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

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

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

- - - - - - - + +
+
+
+ + Leaflets + {{ dataSource.filteredData.length }} of {{ dataSource.data.length }} +
+
+
+
DD Leaflet No. - {{ element.ddNumber }} -
+ + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - Revoke Pending Approval (Req #{{ element.pendingRevokeRequestId }}) - + + + + + - - - Revoked → replaced by DD {{ element.revokedReplacedByDdNumber }} - - - Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }} - - - - + + + + + + - - -
DD Leaflet No.{{ element.ddNumber }}Status - - {{ element.status }} - - Face Value - {{ element.faceValue > 0 ? (element.faceValue | number: '1.2-2') : '-' }} - Purchaser / Client - {{ element.issuedToClientName || element.walkinPurchaserName || '-' }} - Beneficiary{{ element.beneficiaryName || '-' }}Issued Date{{ element.issuedDate || '-' }}Advice Status - {{ element.status === 'ISSUED' ? element.adviceStatus : '-' }} - Actions -
- - - - Pending Approval (Req #{{ element.pendingIssueRequestId }}) - - - - - Amendment Pending (Req #{{ element.pendingAmendmentRequestId }}) - - - - - - - - Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }}) + + +
Status + + + {{ element.status | titlecase }} + Face Value + {{ element.faceValue > 0 ? '₹' + (element.faceValue | number: '1.2-2') : '—' }} + Purchaser / Client + {{ element.issuedToClientName || element.walkinPurchaserName || '—' }} +
+ + + Beneficiary + {{ element.beneficiaryName || '—' }} + -
- {{ dataSource.data.length === 0 ? 'Loading leaflets...' : 'No leaflets match the selected filter.' }} + + + Issued Date + {{ element.issuedDate || '—' }} + + + + + Advice Status + + {{ element.status === 'ISSUED' ? (element.adviceStatus | titlecase) : '—' }} + + + + + + Actions + +
+ + + + + Pending Approval (Req #{{ element.pendingIssueRequestId }}) + + + + + + + + + + + + + + + + + + + + + + Amendment Pending (Req #{{ element.pendingAmendmentRequestId }}) + + + + Reprint Pending (Req #{{ element.pendingReprintRequestId }}) + + + + Revoke Pending (Req #{{ element.pendingRevokeRequestId }}) + + + + + + Replaced by DD {{ element.revokedReplacedByDdNumber }} + + + + Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }} + +
+ +
+ + + + + +
+ + {{ dataSource.data.length === 0 ? 'Loading leaflets…' : 'No leaflets match the selected filter.' }} +
-
+
diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.ts b/src/app/sponsor-dd/dd-list/dd-list.component.ts index d9df2dc0d..2a45d2e05 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.ts +++ b/src/app/sponsor-dd/dd-list/dd-list.component.ts @@ -21,6 +21,9 @@ import { MatSelectModule } from '@angular/material/select'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatNativeDateModule } from '@angular/material/core'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { HttpClient } from '@angular/common/http'; import { debounceTime } from 'rxjs/operators'; @@ -40,145 +43,624 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request. MatDialogModule, MatSnackBarModule, MatFormFieldModule, - MatInputModule + MatInputModule, + MatMenuModule, + MatIconModule, + MatTooltipModule, + FaIconComponent ], templateUrl: './dd-list.component.html', styles: [ ` - .leaflet-container { - padding: 24px; - max-width: 1200px; + /* Same visual language as users.component.scss (gradient header icon, KPI-style chips, card + table with sticky head + row-hover, icon-button/overflow-menu actions) so this registry + reads as part of the same app rather than a one-off style. */ + .ddreg-container { + padding: 20px 24px; + max-width: 1320px; margin: 0 auto; } - .dividend-banner { + + /* ─── Header ─── */ + .ddreg-header { display: flex; align-items: center; - gap: 16px; - margin-bottom: 20px; + margin-bottom: 16px; + } + .ddreg-header__left { + display: flex; + align-items: center; + gap: 12px; + } + .ddreg-back { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9px; + border: 1px solid #d0d5dd; + background: #fff; + color: #475569; + cursor: pointer; + transition: all 0.15s; + } + .ddreg-back:hover { + background: #f8fafc; + border-color: #94a3b8; + color: #1e293b; + } + .ddreg-header__icon { + width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: linear-gradient(135deg, #1e88e5 0%, #7c3aed 100%); + color: #fff; + font-size: 18px; + box-shadow: 0 2px 8px rgb(29 78 216 / 25%); + } + .ddreg-header__title { + margin: 0; + font-size: 20px; + font-weight: 800; + color: #0d1b2a; + line-height: 1.1; + } + .ddreg-header__sub { + margin-top: 2px; + font-size: 12.5px; + color: #64748b; + } + + /* ─── Dividend context banner ─── */ + .ddreg-dividend { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; padding: 12px 16px; border: 1px solid #c7d2fe; border-left: 4px solid #4f46e5; - border-radius: 8px; - background-color: #eef2ff; + border-radius: 10px; + background: linear-gradient(180deg, #eef2ff 0%, #f5f3ff 100%); } - .dividend-banner__text { + .ddreg-dividend__ic { + width: 34px; + height: 34px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9px; + background: #4f46e5; + color: #fff; + font-size: 14px; + } + .ddreg-dividend__text { display: flex; flex-direction: column; gap: 2px; font-size: 13px; color: #312e81; } - .dividend-banner__hint { + .ddreg-dividend__hint { color: #4338ca; font-size: 12px; } - .dividend-banner button { + .ddreg-dividend .ddreg-btn { margin-left: auto; } - .header-row { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 24px; - } - .title-area h2 { - margin: 0 0 4px 0; - font-size: 24px; - font-weight: 600; - color: #1e293b; - } - .title-area p { - margin: 0; - color: #64748b; - font-size: 14px; - } - .search-field { - width: 320px; - display: block; - margin-bottom: 8px; - } - .mat-mdc-card { - border-radius: 12px; - border: 1px solid #e2e8f0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); - overflow: hidden; - } - table { - width: 100%; - } - th { - font-weight: 600; - color: #475569; - background-color: #f8fafc; - } - td { - color: #334155; - } - .status-badge { + + /* ─── Buttons ─── */ + .ddreg-btn { display: inline-flex; align-items: center; - padding: 4px 8px; - border-radius: 9999px; + gap: 8px; + padding: 8px 16px; + border-radius: 9px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + border: 1px solid transparent; + background: #fff; + } + .ddreg-btn fa-icon { font-size: 12px; - font-weight: 500; } - .status-badge.available { - background-color: #dcfce7; - color: #15803d; - } - .status-badge.issued { - background-color: #dbeafe; - color: #1d4ed8; - } - .status-badge.cleared { - background-color: #f1f5f9; + .ddreg-btn.ghost { + border-color: #d0d5dd; color: #475569; } - .status-badge.cancelled { - background-color: #fee2e2; + .ddreg-btn.ghost:hover { + background: #f8fafc; + border-color: #94a3b8; + color: #0d1b2a; + } + .ddreg-btn.ghost.active { + background: #ede9fe; + border-color: #c4b5fd; + color: #6d28d9; + } + .ddreg-btn.primary { + background: linear-gradient(135deg, #1e88e5 0%, #1e40af 100%); + border-color: #1e40af; + color: #fff; + box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%); + } + .ddreg-btn.primary:hover { + box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%); + transform: translateY(-1px); + } + .ddreg-btn.small { + padding: 6px 12px; + font-size: 12px; + } + + /* ─── Toolbar ─── */ + .ddreg-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; + } + .ddreg-toolbar__search { + position: relative; + flex: 1; + min-width: 260px; + max-width: 420px; + } + .ddreg-toolbar__search input { + width: 100%; + padding: 9px 34px; + border: 1px solid #d0d5dd; + border-radius: 9px; + font-size: 13px; + background: #fff; + color: #0d1b2a; + transition: all 0.15s; + box-sizing: border-box; + } + .ddreg-toolbar__search input:focus { + outline: none; + border-color: #1e88e5; + box-shadow: 0 0 0 3px rgb(30 136 229 / 12%); + } + .ddreg-toolbar__search-ic { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: #94a3b8; + font-size: 12px; + pointer-events: none; + } + .ddreg-toolbar__clear { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid #e2e8f0; + background: #fff; + color: #94a3b8; + font-size: 10px; + cursor: pointer; + } + .ddreg-toolbar__clear:hover { + background: #fef2f2; + color: #ef4444; + border-color: #fecaca; + } + + /* ─── KPI Cards ─── */ + .ddreg-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; + margin-bottom: 20px; + } + .ddreg-kpi-card { + background: #ffffff; + border-radius: 12px; + padding: 16px 18px; + border: 1px solid #e2e8f0; + display: flex; + align-items: center; + gap: 14px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); + transition: + transform 0.15s ease, + box-shadow 0.15s ease; + } + .ddreg-kpi-card:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); + } + .ddreg-kpi-card__icon { + width: 42px; + height: 42px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; + } + .ddreg-kpi-card__icon.blue { + background: #eff6ff; + color: #2563eb; + } + .ddreg-kpi-card__icon.green { + background: #f0fdf4; + color: #16a34a; + } + .ddreg-kpi-card__icon.indigo { + background: #eef2ff; + color: #4f46e5; + } + .ddreg-kpi-card__icon.amber { + background: #fffbeb; + color: #d97706; + } + .ddreg-kpi-card__icon.slate { + background: #f8fafc; + color: #64748b; + } + + .ddreg-kpi-card__info { + display: flex; + flex-direction: column; + } + .ddreg-kpi-card__label { + font-size: 11.5px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .ddreg-kpi-card__val { + font-size: 22px; + font-weight: 800; + color: #0f172a; + margin-top: 1px; + line-height: 1; + } + + /* ─── Status filter chips (doubles as the count-at-a-glance) ─── */ + .ddreg-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + } + .ddreg-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid #e2e8f0; + background: #fff; + color: #475569; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + } + .ddreg-chip:hover { + border-color: #cbd5e1; + background: #f8fafc; + } + .ddreg-chip__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + opacity: 0.6; + } + .ddreg-chip__count { + padding: 1px 7px; + border-radius: 999px; + background: rgb(0 0 0 / 6%); + font-size: 11px; + font-weight: 700; + } + .ddreg-chip.s-available { + color: #15803d; + } + .ddreg-chip.s-issued { + color: #1d4ed8; + } + .ddreg-chip.s-cancelled { color: #b91c1c; } - .status-badge.stopped { - background-color: #fef3c7; + .ddreg-chip.s-stopped { + color: #b45309; + } + .ddreg-chip.s-cleared, + .ddreg-chip.s-revoked { + color: #475569; + } + .ddreg-chip.s-all { + color: #4338ca; + } + .ddreg-chip.active { + background: #1e40af; + border-color: #1e40af; + color: #ffffff !important; + } + .ddreg-chip.active.s-available { + background: #16a34a; + border-color: #16a34a; + } + .ddreg-chip.active.s-issued { + background: #2563eb; + border-color: #2563eb; + } + .ddreg-chip.active.s-cancelled { + background: #dc2626; + border-color: #dc2626; + } + .ddreg-chip.active.s-stopped { + background: #d97706; + border-color: #d97706; + } + .ddreg-chip.active.s-cleared, + .ddreg-chip.active.s-revoked { + background: #475569; + border-color: #475569; + } + .ddreg-chip.active.s-all { + background: #4f46e5; + border-color: #4f46e5; + } + .ddreg-chip.active .ddreg-chip__dot { + background: #fff; + opacity: 0.9; + } + .ddreg-chip.active .ddreg-chip__count { + background: rgb(255 255 255 / 25%); + color: #fff; + } + + .ddreg-hint { + display: flex; + align-items: center; + gap: 6px; + color: #64748b; + font-size: 12.5px; + margin: 0 0 16px; + } + + /* ─── Card wrapper ─── */ + .ddreg-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 1px 3px rgb(15 23 42 / 6%); + overflow: hidden; + } + .ddreg-card__head { + display: flex; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); + } + .ddreg-card__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #0d1b2a; + } + .ddreg-card__title-ic { + color: #1e88e5; + } + .ddreg-card__count { + margin-left: 8px; + padding: 2px 10px; + background: #eff6ff; + color: #1e40af; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + } + + /* ─── Table ─── */ + .ddreg-table-wrap { + overflow: auto; + } + .ddreg-table { + width: 100%; + } + .ddreg-table th { + position: sticky; + top: 0; + z-index: 1; + background: #f1f5f9; + color: #475569; + text-align: left; + padding: 11px 14px !important; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid #e2e8f0; + } + .ddreg-table td { + padding: 10px 14px !important; + color: #334155; + border-bottom: 1px solid #f1f5f9; + } + .ddreg-table tr.mat-mdc-row:hover { + background: #f8fafc; + } + .ddreg-num { + text-align: right; + } + .ddreg-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12.5px; + } + .ddreg-strong { + font-weight: 700; + color: #0d1b2a; + } + .ddreg-muted { + color: #94a3b8; + } + + /* ─── Status badge (per-row, same palette as the filter chips) ─── */ + .ddreg-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 9999px; + font-size: 11.5px; + font-weight: 600; + } + .ddreg-badge__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + } + .ddreg-badge.available { + background: #dcfce7; + color: #15803d; + } + .ddreg-badge.issued { + background: #dbeafe; + color: #1d4ed8; + } + .ddreg-badge.cleared { + background: #f1f5f9; + color: #475569; + } + .ddreg-badge.cancelled { + background: #fee2e2; + color: #b91c1c; + } + .ddreg-badge.stopped { + background: #fef3c7; color: #b45309; } /* Spoiled stock. Grey rather than red: nothing went wrong with the customer's draft, only with the paper it was printed on, and the draft itself is alive on another leaflet. */ - .status-badge.revoked { - background-color: #e2e8f0; + .ddreg-badge.revoked { + background: #e2e8f0; color: #475569; } - .action-btn-group { - display: flex; - gap: 8px; + + /* ─── Actions ─── */ + .ddreg-actions-col { + min-width: 190px; } - .pending-badge { + .ddreg-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + } + .ddreg-act { + width: 30px; + height: 30px; + border: 1px solid #e2e8f0; + background: #fff; + border-radius: 7px; + cursor: pointer; display: inline-flex; align-items: center; - padding: 4px 10px; - border-radius: 9999px; - font-size: 12px; - font-weight: 500; - background-color: #fef3c7; - color: #b45309; - } - .status-filter-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 16px; - } - .status-filter-row button.filter-active { - background-color: #ede9fe; - } - .filter-count { + justify-content: center; color: #64748b; - margin-left: 4px; + font-size: 12px; + transition: all 0.12s; } - .hint-text { + .ddreg-act:hover { + transform: translateY(-1px); + } + .ddreg-act.more:hover { + background: #f5f3ff; + border-color: #ddd6fe; + color: #7c3aed; + } + .ddreg-menu__ic { + width: 18px; + margin-right: 10px; color: #64748b; font-size: 13px; - margin: -8px 0 16px 0; + text-align: center; + } + .ddreg-menu__ic.warn { + color: #ef4444; + } + + .ddreg-pending { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 9999px; + font-size: 11px; + font-weight: 600; + background: #fef3c7; + color: #b45309; + } + .ddreg-pending fa-icon { + font-size: 10px; + } + .ddreg-info { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + color: #64748b; + } + .ddreg-info fa-icon { + font-size: 10px; + } + + .ddreg-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 48px 16px; + color: #94a3b8; + font-size: 13px; + } + .ddreg-empty fa-icon { + font-size: 22px; + opacity: 0.6; + } + + /* ─── Responsive ─── */ + @media (max-width: 900px) { + .ddreg-toolbar { + flex-direction: column; + align-items: stretch; + } + .ddreg-toolbar__search { + max-width: none; + } + } + @media (max-width: 600px) { + .ddreg-table th, + .ddreg-table td { + padding: 8px 10px !important; + font-size: 12px; + } } ` ] diff --git a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts index b4516b3c4..d2f1315f5 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 @@ -15,6 +15,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { SponsorDDService } from '../sponsor-dd.service'; @@ -30,6 +31,7 @@ import { SponsorDDService } from '../sponsor-dd.service'; MatInputModule, MatSelectModule, MatButtonModule, + MatIconModule, MatSnackBarModule ], template: ` @@ -41,15 +43,25 @@ import { SponsorDDService } from '../sponsor-dd.service';

- - - Sponsor DD Payable GL Account * - - {{ acc.glCode }} · {{ acc.name }} - - Credited for the DD Face Value - Required - + +
+ account_balance +
+ DD Payable is now set per sponsor bank. +
+ Each bank credits its own payable account, so what is owed to each can be settled separately. Set it in + Organization > Sponsor Banks. +
+
+
@@ -131,6 +143,29 @@ import { SponsorDDService } from '../sponsor-dd.service'; gap: 12px; padding-top: 8px; } + .payable-moved-note { + display: flex; + gap: 10px; + align-items: flex-start; + margin-bottom: 18px; + padding: 11px 14px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-left: 3px solid #1d4ed8; + border-radius: 6px; + font-size: 13px; + color: #334155; + } + .payable-moved-note mat-icon { + color: #1d4ed8; + flex-shrink: 0; + } + .payable-moved-sub { + margin-top: 3px; + font-size: 11.5px; + line-height: 1.5; + color: #64748b; + } .full-width { width: 100%; } @@ -170,10 +205,10 @@ export class GlSettingsDialogComponent implements OnInit { ngOnInit(): void { this.form = this.fb.group({ - ddPayableGlAccountId: [ - null, - [Validators.required] - ], + // No longer edited here — it lives on the sponsor bank. Kept in the form, unvalidated, so + // saving this screen round-trips the stored value instead of clearing it: existing banks were + // seeded from it on upgrade, and nothing should quietly erase what they were seeded from. + ddPayableGlAccountId: [null], chargesIncomeGlAccountId: [ null, [Validators.required] 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 3138111e7..5a55ebe6c 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 @@ -14,9 +14,11 @@ import { HttpClient } from '@angular/common/http'; import { MatCardModule } from '@angular/material/card'; import { MatTableModule } from '@angular/material/table'; import { MatButtonModule } from '@angular/material/button'; -import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatDialog, MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; @Component({ @@ -29,6 +31,7 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; MatTableModule, MatButtonModule, MatDialogModule, + MatIconModule, MatSnackBarModule ], template: ` @@ -75,6 +78,27 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; {{ element.swiftCode || '-' }} + + + DD Payable GL + + + + warning Not set — drafts cannot be issued + + + + + + + + + + + @@ -87,9 +111,27 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; ` .sponsor-bank-container { padding: 24px; - max-width: 1000px; + max-width: 1280px; margin: 0 auto; } + .gl-account { + font-size: 12.5px; + color: #0f766e; + } + /* Loud on purpose: a bank in this state silently blocks every draft drawn on it. */ + .gl-missing { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: #b45309; + font-weight: 600; + } + .gl-missing mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } .header-row { display: flex; justify-content: space-between; @@ -143,7 +185,9 @@ export class SponsorBankListComponent implements OnInit { 'description', 'ifscCode', 'bankCode', - 'swiftCode' + 'swiftCode', + 'ddPayable', + 'actions' ]; dataSource: any[] = []; @@ -163,9 +207,18 @@ export class SponsorBankListComponent implements OnInit { } openAddDialog(): void { + this.openBankDialog(null); + } + + openEditDialog(bank: any): void { + this.openBankDialog(bank); + } + + private openBankDialog(bank: any): void { const dialogRef = this.dialog.open(AddSponsorBankDialogComponent, { - width: '450px', - disableClose: true + width: '480px', + disableClose: true, + data: { bank } }); dialogRef.afterClosed().subscribe((result) => { @@ -187,10 +240,11 @@ export class SponsorBankListComponent implements OnInit { MatDialogModule, MatFormFieldModule, MatInputModule, + MatSelectModule, MatButtonModule ], template: ` -

Add Sponsor Bank

+

{{ isEdit ? 'Edit Sponsor Bank' : 'Add Sponsor Bank' }}

@@ -236,6 +290,29 @@ export class SponsorBankListComponent implements OnInit { />
+ + + + + DD Payable GL Account + + + {{ acc.glCode }} · {{ acc.name }} + + + Credited for the face value of every draft drawn on this bank + + A DD Payable account is required — drafts on this bank cannot be issued without one + + @@ -261,6 +338,16 @@ export class SponsorBankListComponent implements OnInit { .half-width { flex: 1; } + .gl-section-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #475569; + margin: 4px 0 12px; + padding-top: 12px; + border-top: 1px solid #e2e8f0; + } ` ] }) @@ -270,22 +357,53 @@ export class AddSponsorBankDialogComponent implements OnInit { private dialogRef = inject(MatDialogRef); private snackBar = inject(MatSnackBar); + /** The bank being edited, or null when adding a new one. */ + data = inject(MAT_DIALOG_DATA, { optional: true }) as any; + form!: FormGroup; loading = false; + /** + * Liability accounts only, and only postable, enabled ones. + * + * A DD payable is money owed to the sponsor until the draft is presented, so offering asset or + * income accounts here would only invite a choice the server then refuses. + */ + liabilityGlAccounts: any[] = []; + + get isEdit(): boolean { + return !!this.data?.bank?.id; + } ngOnInit(): void { + const bank = this.data?.bank; this.form = this.fb.group({ name: [ - '', + bank?.name ?? '', Validators.required ], - description: ['Sponsor Bank'], + description: [bank?.description ?? 'Sponsor Bank'], ifscCode: [ - '', + bank?.ifscCode ?? '', Validators.pattern(/^[A-Za-z]{4}0[A-Za-z0-9]{6}$/) ], - bankCode: [''], - swiftCode: [''] + bankCode: [bank?.bankCode ?? ''], + swiftCode: [bank?.swiftCode ?? ''], + // Required: a bank with no payable cannot have drafts issued on it at all, so it is better + // refused while the bank is being set up than when a clerk is standing at a counter. + ddPayableGlAccountId: [ + bank?.ddPayableGlAccountId ?? null, + Validators.required + ] + }); + this.loadLiabilityGlAccounts(); + } + + private loadLiabilityGlAccounts(): void { + // type=2 is LIABILITY, usage=1 is DETAIL — the same filters the DD GL settings dialog uses for + // its own postable accounts. + this.http.get('/glaccounts?type=2&manualEntriesAllowed=true&disabled=false&usage=1').subscribe({ + next: (accounts) => (this.liabilityGlAccounts = accounts || []), + error: () => (this.liabilityGlAccounts = []) }); } @@ -297,9 +415,14 @@ export class AddSponsorBankDialogComponent implements OnInit { if (this.form.invalid) return; this.loading = true; - this.http.post('/sponsorbanks', this.form.value).subscribe({ + const request$ = this.isEdit + ? this.http.put(`/sponsorbanks/${this.data.bank.id}`, this.form.value) + : this.http.post('/sponsorbanks', this.form.value); + request$.subscribe({ next: () => { - this.snackBar.open('Sponsor Bank added successfully', 'Close', { duration: 3000 }); + 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/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index 707a5a9da..e811f563a 100644 --- a/src/app/sponsor-dd/sponsor-dd.service.ts +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -108,13 +108,14 @@ export class SponsorDDService { chargesAmount?: number; issuingBranchId: number; issuedDate: string; // yyyy-MM-dd - paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH'; + paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH' | 'GL'; savingsAccountId?: number; currentAccountId?: number; chequeNumber?: string; chequeBankName?: string; cashierDepositRequestId?: number; denominationData?: string; + glAccountId?: number; } ): Observable { return this.http.post(`/sponsordd/leaflets/${leafletId}/issue-request`, data); @@ -124,18 +125,22 @@ export class SponsorDDService { return this.http.get('/sponsordd/cashier-deposit-requests/available'); } - // ── Multi-DD slip (one account debit/cheque funds several drafts) ── + // ── Multi-DD slip (one account debit / cheque / GL debit funds several drafts) ── + // CASH is the one funding method a slip cannot use: a single counted pile of notes cannot be + // apportioned across drafts without the denomination total ceasing to prove what was taken. submitIssueRequestBatch( inventoryId: number, data: { clientId?: number; issuingBranchId: number; issuedDate: string; // yyyy-MM-dd - paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE'; + paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'GL'; savingsAccountId?: number; currentAccountId?: number; chequeNumber?: string; chequeBankName?: string; + /** The GL account a GL-funded slip is debited from; omitted for every other method. */ + glAccountId?: number; lines: { beneficiaryName: string; payableAtBranch: string; @@ -148,8 +153,12 @@ export class SponsorDDService { return this.http.post(`/sponsordd/inventory/${inventoryId}/issue-request-batch`, data); } - approveIssueRequestBatch(batchId: number): Observable { - return this.http.post(`/sponsordd/issue-requests/batch/${batchId}/approve`, {}); + /** + * Approves a slip. Omitting approvedRequestIds issues every draft on it; supplying them issues + * only those, debits only for them, and rejects the rest — whose DD numbers return to stock. + */ + approveIssueRequestBatch(batchId: number, approvedRequestIds?: number[]): Observable { + return this.http.post(`/sponsordd/issue-requests/batch/${batchId}/approve`, { approvedRequestIds }); } rejectIssueRequestBatch(batchId: number, rejectionReason: string): Observable { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html index d7f2c7136..4e7fc8f42 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html @@ -68,18 +68,70 @@ Voucher / Receipt Date + Description Collected by Amount + Detail @for (v of detail.vouchers; track v.id) { - + {{ v.voucherNo || v.receiptNo }} {{ v.collectionDate }} + {{ v.note || '—' }} {{ v.collectedByName || '—' }} {{ v.amount | number: '1.2-2' }} + + + + @if (expandedVoucherId === v.id) { + + + @if (loadingGlEntriesId === v.id) { +
Loading GL entry…
+ } @else if (!glEntriesByVoucherId[v.id]?.length) { +
No GL posting recorded for this receipt.
+ } @else { + + + + + + + + + + @for (leg of glEntriesByVoucherId[v.id]; track leg.glCode + leg.drCr) { + + + + + + } + +
GL AccountDr / CrAmount
{{ leg.glCode }} — {{ leg.accountName }} + + {{ leg.drCr }} + + {{ leg.amount | number: '1.2-2' }}
+ } + + + } } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss index 4e8a7aff8..7b9c8d19f 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss @@ -121,3 +121,93 @@ $danger: #b3261e; font-size: 13px; margin-top: 12px; } + +.ta-center { + text-align: center; +} + +.sard-voucher-row { + cursor: pointer; + + &:hover { + background: #f4f7fb; + } +} + +.sard-desc-cell { + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sard-detail-btn { + width: 30px; + height: 30px; + line-height: 30px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $primary; + } +} + +.sard-gl-row td { + background: #fbfcfe; + border-top: none; + padding: 4px 8px 10px; +} + +.sard-gl-loading, +.sard-gl-empty { + font-size: 12px; + color: $muted; + padding: 4px 0; +} + +.sard-gl-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + background: #fff; + border: 1px solid $border; + border-radius: 6px; + overflow: hidden; + + th { + color: $muted; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 5px 8px; + background: #f4f7fb; + border-bottom: 1px solid $border; + } + + td { + padding: 5px 8px; + border-top: 1px solid #eceff4; + } +} + +.sard-drcr-pill { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + + &.sard-drcr-pill--dr { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + } + + &.sard-drcr-pill--cr { + background: #ecfdf5; + color: #059669; + border: 1px solid #a7f3d0; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts index 05a5267e8..2920bb3b7 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts @@ -50,6 +50,12 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit { actioning = false; detail: any = null; + /** Which voucher's row is currently expanded to show its real Dr/Cr GL legs, or null if none. */ + expandedVoucherId: number | null = null; + loadingGlEntriesId: number | null = null; + /** Fetched once per voucher and kept — a checker toggling the same row twice shouldn't re-fetch. */ + glEntriesByVoucherId: { [id: number]: { glCode: string; accountName: string; drCr: string; amount: number }[] } = {}; + public dialogRef = inject(MatDialogRef); public data = inject<{ transactionId: number }>(MAT_DIALOG_DATA); @@ -76,6 +82,26 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit { return this.detail?.vouchers?.length || 0; } + /** Shows or hides a voucher's real GL posting, fetching it the first time it is opened. */ + toggleVoucherGlEntries(voucherId: number): void { + if (this.expandedVoucherId === voucherId) { + this.expandedVoucherId = null; + return; + } + this.expandedVoucherId = voucherId; + if (this.glEntriesByVoucherId[voucherId]) { + return; + } + this.loadingGlEntriesId = voucherId; + this.cashierService + .getShareAdvanceGlEntries(voucherId) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.glEntriesByVoucherId[voucherId] = res?.entries || []; + this.loadingGlEntriesId = null; + }); + } + approve(): void { this.act('approveadditionalshares'); } diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 9a5bb2c85..03f8fa26a 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -232,6 +232,12 @@ DD Revoke Requests + + + + Risk Category Changes +
diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 03713aea3..e247d0f7a 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -243,6 +243,12 @@ const routes: Routes = [ (m) => m.DdRevokeApprovalsComponent ), data: { title: 'DD Revoke Approvals' } + }, + { + path: 'risk-change-approvals', + loadComponent: () => + import('../risk-review/risk-change-approvals.component').then((m) => m.RiskChangeApprovalsComponent), + data: { title: 'Risk Category Approvals' } } ] }, diff --git a/src/main.scss b/src/main.scss index 387d35837..16f91750d 100644 --- a/src/main.scss +++ b/src/main.scss @@ -982,6 +982,16 @@ mat-card { .mat-mdc-tab-header, .mat-stepper-vertical-line, .mat-step-header, + /* Account-view pages (Shares, Savings, Current, Deposits…) use ` } + + @else if (currentUrl.includes('/fd-dashboard')) { + + } @else if (currentUrl.includes('/clerk/')) {
+
+ + + @if (errorMessage) { +
+ + {{ errorMessage }} +
+ } + + +
+ @for (key of stageKeys; track key) { + + } +
+ + +
+ + + @if (stageFilter || search) { + + } + +
+ +
+ + {{ visibleRows.length }} of {{ summary.total }} active FDs +
+
+ + + @if (loading) { +
+ +

Loading FD maturity list...

+
+ } @else if (visibleRows.length === 0) { +
+ +

+ @if (!stageFilter && summary.total > 0) { + No FDs are matured or maturing within {{ policy.upcomingDays }} days. Click "On Track" to see all active FDs. + } @else { + No FDs match these filters. + } +

+ @if (stageFilter || search) { + + } +
+ } @else { +
+
+ + + + + + + + + + + + + + + @for (r of visibleRows; track r.id) { + + + + + + + + + + + + + + + + + + } + +
AccountClientProductMaturity DateDeposit AmountMaturity AmountStatusAction
+ {{ r.accountNo || '—' }} + +
+ {{ r.clientName }} +
+
+ {{ r.productName }} + +
+ {{ r.maturityDate }} + + {{ maturityText(r) }} + +
+
+ + {{ r.depositAmount != null ? '₹' + (r.depositAmount | number: '1.2-2') : '—' }} + + + + {{ r.maturityAmount != null ? '₹' + (r.maturityAmount | number: '1.2-2') : '—' }} + + +
+ + {{ r.meta.label }} + + @if (r.renewable) { +
+ {{ graceStatusText(r) }} +
+ } @else if (r.configuredInstructionLabel) { +
Configured: {{ r.configuredInstructionLabel }} (auto-processes)
+ } +
+
+ @if (r.renewable) { + + } @else { + + } +
+
+
+ } +
diff --git a/src/app/fd-dashboard/fd-dashboard.component.scss b/src/app/fd-dashboard/fd-dashboard.component.scss new file mode 100644 index 000000000..cf28bd3ba --- /dev/null +++ b/src/app/fd-dashboard/fd-dashboard.component.scss @@ -0,0 +1,578 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Ultra-Compact Light FD Dashboard & Maturity Ladder Styles + * CBSNexora Core Banking Design System + */ + +:host { + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: #0f172a; + background: #f8fafc; + min-height: 100vh; + padding: 12px 16px; + box-sizing: border-box; +} + +.fdd-container { + max-width: 1400px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ── Light Compact Header ────────────────────────────────────────────────── */ + +.fdd-header { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + box-shadow: 0 1px 3px rgb(0 0 0 / 4%); + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + width: 36px; + height: 36px; + border-radius: 8px; + background: #eff6ff; + color: #2563eb; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; + } + + &__title-row { + display: flex; + align-items: center; + gap: 8px; + } + + &__title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: #0f172a; + } + + &__badge { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 6px; + border-radius: 4px; + background: #e0f2fe; + color: #0369a1; + } + + &__sub { + margin: 2px 0 0; + font-size: 11.5px; + color: #64748b; + + kbd { + background: #f1f5f9; + border: 1px solid #cbd5e1; + border-radius: 3px; + padding: 1px 4px; + font-family: inherit; + font-size: 10.5px; + font-weight: 700; + color: #334155; + } + } +} + +/* ── Alerts ─────────────────────────────────────────────────────────────── */ + +.fdd-alert { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + + &--danger { + background: #fef2f2; + border: 1px solid #fca5a5; + color: #dc2626; + } + + &--warning { + background: #fffbeb; + border: 1px solid #fcd34d; + color: #d97706; + } +} + +/* ── Compact Stat Cards Grid ────────────────────────────────────────────── */ + +.fdd-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; +} + +.fdd-stat-card { + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #cbd5e1; + border-radius: 10px; + padding: 10px 14px; + text-align: left; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + border-color: #cbd5e1; + box-shadow: 0 4px 12px rgb(15 23 42 / 6%); + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; + } + + &__label { + font-size: 12px; + font-weight: 700; + color: #334155; + } + + &__icon { + font-size: 14px; + } + + &__val { + font-size: 20px; + font-weight: 800; + line-height: 1.1; + color: #0f172a; + margin-bottom: 2px; + } + + &__sub { + font-size: 10.5px; + color: #64748b; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* Tone styling */ + &--danger { + border-left-color: #ef4444; + + .fdd-stat-card__icon { + color: #ef4444; + } + + &.fdd-stat-card--active { + background: #fef2f2; + border-color: #fca5a5; + } + } + + &--warn { + border-left-color: #f59e0b; + + .fdd-stat-card__icon { + color: #f59e0b; + } + + &.fdd-stat-card--active { + background: #fffbeb; + border-color: #fcd34d; + } + } + + &--info { + border-left-color: #3b82f6; + + .fdd-stat-card__icon { + color: #3b82f6; + } + + &.fdd-stat-card--active { + background: #eff6ff; + border-color: #93c5fd; + } + } + + &--neutral { + border-left-color: #64748b; + + .fdd-stat-card__icon { + color: #64748b; + } + + &.fdd-stat-card--active { + background: #f1f5f9; + border-color: #cbd5e1; + } + } +} + +/* ── Compact Filter Toolbar ─────────────────────────────────────────────── */ + +.fdd-toolbar { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 6px 10px; + display: flex; + align-items: center; + gap: 10px; + box-shadow: 0 1px 2px rgb(0 0 0 / 2%); + + &__spacer { + flex: 1; + } +} + +.fdd-search-box { + position: relative; + display: flex; + align-items: center; + width: 300px; + + &__icon { + position: absolute; + left: 10px; + color: #94a3b8; + font-size: 12px; + pointer-events: none; + } + + &__input { + width: 100%; + padding: 5px 30px 5px 28px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 12px; + font-family: inherit; + color: #0f172a; + background: #fff; + + &:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 2px rgb(37 99 235 / 15%); + } + } + + &__clear { + position: absolute; + right: 6px; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + padding: 2px 4px; + + &:hover { + color: #334155; + } + } +} + +.fdd-count-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11.5px; + color: #64748b; +} + +/* ── Buttons ────────────────────────────────────────────────────────────── */ + +.fdd-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + font-family: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + border: 1px solid #cbd5e1; + background: #fff; + color: #334155; + transition: all 0.15s ease; + + &:hover:not(:disabled) { + border-color: #2563eb; + color: #2563eb; + background: #f8fafc; + } + + &--slim { + padding: 4px 10px; + font-size: 11.5px; + } + + &--primary { + background: #2563eb; + color: #fff; + border-color: #2563eb; + + &:hover:not(:disabled) { + background: #1d4ed8; + color: #fff; + } + } + + &--action { + background: #2563eb; + color: #fff; + border-color: #2563eb; + padding: 4px 10px; + font-size: 11px; + border-radius: 5px; + + &:hover:not(:disabled) { + background: #1d4ed8; + color: #fff; + } + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.fdd-btn-icon { + width: 26px; + height: 26px; + border-radius: 5px; + border: 1px solid #e2e8f0; + background: #fff; + color: #64748b; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: #eff6ff; + color: #2563eb; + border-color: #93c5fd; + } +} + +/* ── Compact Table ──────────────────────────────────────────────────────── */ + +.fdd-table-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 1px 3px rgb(0 0 0 / 3%); +} + +.fdd-table-wrapper { + overflow-x: auto; +} + +.fdd-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 12px; + + th { + padding: 8px 10px; + background: #f1f5f9; + color: #475569; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 1px solid #e2e8f0; + white-space: nowrap; + } + + td { + padding: 7px 10px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + } + + .text-right { + text-align: right; + } + + .text-center { + text-align: center; + } +} + +.fdd-row { + cursor: pointer; + + &:hover { + background: #f8fafc; + } + + &:last-child td { + border-bottom: none; + } +} + +.fdd-acc-num { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + font-weight: 600; + color: #0f172a; +} + +.fdd-client-name { + font-weight: 600; + color: #0f172a; +} + +.fdd-product-tag { + font-size: 11px; + color: #475569; +} + +.fdd-date-cell { + display: flex; + flex-direction: column; + gap: 1px; + + .fdd-date-val { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + color: #0f172a; + } + + .fdd-date-sub { + font-size: 10.5px; + color: #64748b; + + &--overdue { + color: #dc2626; + font-weight: 700; + } + } +} + +.fdd-amount { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + + &--deposit { + color: #334155; + } + + &--maturity { + color: #2563eb; + font-weight: 700; + } +} + +.fdd-status-cell { + display: flex; + flex-direction: column; + gap: 2px; +} + +.fdd-stage { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 10.5px; + font-weight: 700; + width: fit-content; + + &--info { + background: #eff6ff; + color: #2563eb; + } + + &--warn { + background: #fffbeb; + color: #d97706; + } + + &--danger { + background: #fef2f2; + color: #dc2626; + } + + &--neutral { + background: #f1f5f9; + color: #475569; + } +} + +.fdd-grace-sub { + font-size: 10px; + color: #64748b; + + &--warning { + color: #dc2626; + font-weight: 600; + } +} + +.fdd-card.fdd-state-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 24px; + text-align: center; + font-size: 12px; + color: #64748b; + + .fdd-state-icon { + font-size: 24px; + color: #94a3b8; + margin-bottom: 8px; + } + + .fdd-spinner { + font-size: 20px; + color: #2563eb; + margin-bottom: 8px; + } + + .fdd-state-msg { + margin: 0 0 12px; + } +} diff --git a/src/app/fd-dashboard/fd-dashboard.component.ts b/src/app/fd-dashboard/fd-dashboard.component.ts new file mode 100644 index 000000000..a0070503f --- /dev/null +++ b/src/app/fd-dashboard/fd-dashboard.component.ts @@ -0,0 +1,273 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; + +/** Custom Modules */ +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** Custom Services */ +import { AccountsService } from '../accounts/accounts.service'; +import { FixedDepositsService } from '../deposits/fixed-deposits/fixed-deposits.service'; +import { toIsoDate } from 'app/core/utils/api-date'; + +/** Dashboard data + computation */ +import { + FD_MATURITY_POLICY, + FD_MATURITY_STAGES, + FdMaturityRow, + FdMaturityStage, + FdMaturitySummary, + buildMaturityRow, + maturitySummary, + sortMaturityRows +} from './fd-dashboard.compute'; +import { PaymentDialogComponent } from '../deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component'; + +/** + * FD Dashboard: every active Fixed Deposit positioned against its own maturity date. + * + * Read-only and computed, the same way the re-KYC expiry worklist is: the ladder (Matured / Due + * Soon / Upcoming / On Track) is worked out here from `maturityDate`, not stored anywhere, so this + * screen can never drift out of step with the account -- it always says what today's date implies. + * Data comes from the same `/fixeddepositaccounts` list Accounts > Fixed Deposit already uses; + * nothing new was added on the backend for this. + */ +@Component({ + selector: 'mifosx-fd-dashboard', + templateUrl: './fd-dashboard.component.html', + styleUrls: ['./fd-dashboard.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent + ] +}) +export class FdDashboardComponent implements OnInit { + private accountsService = inject(AccountsService); + private fixedDepositsService = inject(FixedDepositsService); + private router = inject(Router); + private dialog = inject(MatDialog); + + loading = false; + errorMessage = ''; + + rows: FdMaturityRow[] = []; + + /** Renew action state -- the dialog itself owns submit-in-progress/error display; this just guards + * against opening a second dialog for the same row while one is already up. */ + renewing: number | null = null; + + /** Filters */ + stageFilter: FdMaturityStage | '' = ''; + search = ''; + + readonly policy = FD_MATURITY_POLICY; + readonly stageMeta = FD_MATURITY_STAGES; + /** ON_TRACK is reachable only by clicking its own tile -- the default view stays on what needs + * attention (matured or maturing soon), matching why this screen exists. */ + readonly stageKeys: FdMaturityStage[] = [ + 'MATURED', + 'DUE_SOON', + 'UPCOMING', + 'ON_TRACK' + ]; + + ngOnInit(): void { + this.load(); + } + + /** + * Loads every active FD and re-derives the maturity ladder for each. + * + * Calls the dedicated maturity-dashboard endpoint (backend: FixedDepositMaturityDashboardApiResource), + * not the general Accounts > Fixed Deposit list endpoint -- that one's query is wide (six extra + * table joins for audit/currency/tax fields this screen never shows) and, until this feature + * added them, ran over unindexed columns. The dashboard endpoint already filters to ACTIVE FDs + * (deposit_type_enum/status_enum are fixed in its SQL, not a request parameter), so there's + * nothing to re-check client-side here. + */ + load(): void { + this.loading = true; + this.errorMessage = ''; + const today = toIsoDate(new Date().toISOString()); + + this.accountsService.getFixedDepositMaturityDashboard().subscribe({ + next: (res: any) => { + const items: any[] = Array.isArray(res) ? res : []; + + const mapped = items + .map((a: any) => this.mapAccount(a)) + .map((a) => buildMaturityRow(a, today)) + .filter((r): r is FdMaturityRow => !!r); + + this.rows = sortMaturityRows(mapped); + this.loading = false; + }, + error: (err: any) => { + this.rows = []; + this.loading = false; + this.errorMessage = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the FD maturity list.'; + } + }); + } + + /** Maps one raw `/fixeddepositaccounts/maturity-dashboard` list item onto what the ladder needs. */ + private mapAccount(a: any): { + id: number; + accountNo: string; + clientId?: number; + clientName?: string; + productName?: string; + savingsProductName?: string; + fdType?: string; + maturityDate: string; + depositAmount?: number; + maturityAmount?: number; + onClosureType?: number; + linkedAccountId?: number; + renewalGracePeriodDays?: number; + } { + return { + id: a?.id, + accountNo: a?.accountNo || '', + clientId: a?.clientId, + clientName: a?.clientName, + productName: a?.productName, + savingsProductName: a?.savingsProductName, + fdType: a?.fdType, + maturityDate: toIsoDate(a?.maturityDate), + depositAmount: a?.depositAmount, + maturityAmount: a?.maturityAmount, + onClosureType: a?.onClosureType, + linkedAccountId: a?.linkedAccountId, + renewalGracePeriodDays: a?.renewalGracePeriodDays + }; + } + + // ── Filtering ────────────────────────────────────────────────────────────── + + get visibleRows(): FdMaturityRow[] { + const needle = this.search.trim().toLowerCase(); + const stage = this.stageFilter; + return this.rows.filter((r) => { + if (stage) { + if (r.stage !== stage) { + return false; + } + } else if (r.stage === 'ON_TRACK') { + // No tile selected -- the default view is the worklist, not every active FD. + return false; + } + if (needle) { + const hay = `${r.accountNo} ${r.clientName}`.toLowerCase(); + if (!hay.includes(needle)) { + return false; + } + } + return true; + }); + } + + get summary(): FdMaturitySummary { + return maturitySummary(this.rows); + } + + resetFilters(): void { + this.stageFilter = ''; + this.search = ''; + } + + toggleStage(stage: FdMaturityStage): void { + this.stageFilter = this.stageFilter === stage ? '' : stage; + } + + // ── Navigation ───────────────────────────────────────────────────────────── + + openAccount(row: FdMaturityRow): void { + if (!row.clientId) { + return; + } + this.router.navigate([ + '/clients', + row.clientId, + 'fixed-deposits-accounts', + row.id, + 'general' + ]); + } + + // ── Renewal ──────────────────────────────────────────────────────────────── + + /** Renew is a single action on a matured, renewable row -- opens the same Payment dialog an account + * page's own Account Actions menu uses, scoped to just the Reinvest choices, with the closure date + * and gap-interest crediting decided from the grace window rather than picked here (see + * PaymentDialogComponent's `dashboardRenewal` mode). */ + openRenewDialog(row: FdMaturityRow): void { + if (!row.renewable || this.renewing != null) { + return; + } + this.renewing = row.id; + this.dialog + .open(PaymentDialogComponent, { + width: '720px', + maxWidth: '95vw', + data: { + accountId: row.id, + accountBalance: row.maturityAmount, + maturityDate: row.maturityDate, + dashboardRenewal: { row } + } + }) + .afterClosed() + .subscribe((result: { confirm: boolean } | undefined) => { + this.renewing = null; + if (result?.confirm) { + this.load(); + } + }); + } + + // ── Presentation ─────────────────────────────────────────────────────────── + + stageClass(stage: FdMaturityStage): string { + return `fdd-stage fdd-stage--${FD_MATURITY_STAGES[stage].tone}`; + } + + /** "12 days overdue" / "in 5 days" / "Matures today", as a branch officer would say it. */ + maturityText(row: FdMaturityRow): string { + if (row.daysOverdue > 0) { + return `${row.daysOverdue} day${row.daysOverdue === 1 ? '' : 's'} overdue`; + } + if (row.daysRemaining === 0) { + return 'Matures today'; + } + return `In ${row.daysRemaining} day${row.daysRemaining === 1 ? '' : 's'}`; + } + + /** "Renewable — 6 of 9 grace days left" / "Grace period (9d) passed 3 days ago" / "Renewable" (no + * grace configured) -- the sub-label under a matured, renewable row's stage badge. */ + graceStatusText(row: FdMaturityRow): string { + if (!row.renewalGracePeriodDays) { + return 'Renewable'; + } + const grace = row.renewalGracePeriodDays; + if (row.withinGraceWindow) { + const daysLeft = grace - row.daysOverdue; + return `Renewable — ${daysLeft} of ${grace} grace day${grace === 1 ? '' : 's'} left`; + } + const daysPastGrace = row.daysOverdue - grace; + return `Grace period (${grace}d) passed ${daysPastGrace} day${daysPastGrace === 1 ? '' : 's'} ago`; + } +} diff --git a/src/app/fd-dashboard/fd-dashboard.compute.ts b/src/app/fd-dashboard/fd-dashboard.compute.ts new file mode 100644 index 000000000..bc436192c --- /dev/null +++ b/src/app/fd-dashboard/fd-dashboard.compute.ts @@ -0,0 +1,240 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Where an active Fixed Deposit sits relative to its own maturity date. + * + * MATURED accounts are the ones staff actually need to act on -- the FD has run its course but is + * still ACTIVE in the core, meaning nobody has applied the maturity instruction yet (or the + * scheduled job hasn't reached it). DUE_SOON and UPCOMING exist so a branch can see what's coming + * before it lands, not just after. + */ +export type FdMaturityStage = 'MATURED' | 'DUE_SOON' | 'UPCOMING' | 'ON_TRACK'; + +/** Day-count thresholds, measured forward from today toward the maturity date. */ +export const FD_MATURITY_POLICY = { + /** Maturing within this many days counts as DUE_SOON. */ + dueSoonDays: 7, + /** Maturing within this many days (and not already DUE_SOON) counts as UPCOMING. */ + upcomingDays: 30 +}; + +export interface FdMaturityStageMeta { + stage: FdMaturityStage; + label: string; + /** Colour key the template maps to a badge/tile class. */ + tone: 'info' | 'warn' | 'danger' | 'neutral'; + summary: string; +} + +export const FD_MATURITY_STAGES: Record = { + MATURED: { + stage: 'MATURED', + label: 'Matured', + tone: 'danger', + summary: 'Past maturity date and still active -- the maturity instruction has not been applied yet.' + }, + DUE_SOON: { + stage: 'DUE_SOON', + label: `Maturing ≤ ${FD_MATURITY_POLICY.dueSoonDays} Days`, + tone: 'warn', + summary: 'Matures within the week.' + }, + UPCOMING: { + stage: 'UPCOMING', + label: `Maturing ≤ ${FD_MATURITY_POLICY.upcomingDays} Days`, + tone: 'info', + summary: 'Matures within the month.' + }, + ON_TRACK: { + stage: 'ON_TRACK', + label: 'On Track', + tone: 'neutral', + summary: 'Maturity date is further out.' + } +}; + +/** FD types the manual-renewal action applies to. SPECIAL is architecturally forced to Transfer to + * Savings server-side and can never be Reinvest-configured, so it's never renewable here. */ +const RENEWABLE_FD_TYPES = [ + 'SINGLE', + 'DOUBLE' +]; + +/** DepositAccountOnClosureType labels, for the "what will happen automatically" sub-label shown on a + * matured row that already carries an explicit instruction from account-opening. */ +const ON_CLOSURE_TYPE_LABELS: Record = { + 200: 'Transfer to Savings', + 300: 'Reinvest Principal + Interest', + 400: 'Reinvest Principal Only' +}; + +/** One active Fixed Deposit account, positioned on the maturity ladder. */ +export interface FdMaturityRow { + id: number; + accountNo: string; + clientId: number | null; + clientName: string; + productName: string; + maturityDate: string; + depositAmount: number | null; + maturityAmount: number | null; + /** Negative once the maturity date has passed. */ + daysRemaining: number; + /** 0 while not yet matured; how many days past maturity otherwise. */ + daysOverdue: number; + stage: FdMaturityStage; + meta: FdMaturityStageMeta; + /** Null/0 -- no buffer configured; renewing after the maturity date itself pays gap interest. */ + renewalGracePeriodDays: number | null; + linkedAccountId: number | null; + /** The account's own stored DepositAccountOnClosureType, raw -- null when none was chosen at + * account-opening. On a renewable row that DOES carry one (grace-period case: the account picked + * Reinvest, and is only waiting because the product also has a grace window), the Renew dialog + * pre-selects this rather than presenting a neutral, undecided choice -- the maker already decided + * what this FD should do, grace only changed when that gets carried out. */ + onClosureType: number | null; + /** True only when the account was opened with NO maturity instruction at all (the officer left + * Maturity Instructions blank at account-opening) -- that's the one case nothing else will ever + * act on this FD, so the Dashboard's manual Renew action is meaningful. An account that WAS given + * an explicit instruction (Transfer to Savings or either Reinvest option) auto-processes on its + * own via the nightly job, exactly as it always has -- the Dashboard only reports on those, it + * doesn't also offer to renew them. */ + isReinvestEligible: boolean; + /** True once matured AND eligible -- the only case where nothing has auto-processed it, so a + * manual action from the Dashboard is meaningful. */ + renewable: boolean; + /** Only meaningful when renewable -- true if still inside the grace window (backdated renewal, + * no gap interest), false if the window has passed (renews from today, gap interest applies). */ + withinGraceWindow: boolean; + /** Set on a matured row that is NOT renewable because it already carries an explicit instruction -- + * what it was configured to do at account-opening, shown so staff can see at a glance what tonight's + * job will do without opening the account. Null for a renewable row (nothing decided yet) or a row + * that isn't matured. */ + configuredInstructionLabel: string | null; +} + +/** Days between two ISO dates, positive when `later` is after `earlier`. */ +function daysBetween(earlier: string, later: string): number { + const a = new Date(`${earlier}T00:00:00Z`).getTime(); + const b = new Date(`${later}T00:00:00Z`).getTime(); + return Math.round((b - a) / 86400000); +} + +/** Which rung of the ladder an account is on, from how many days remain until maturity. */ +export function maturityStage(daysRemaining: number): FdMaturityStage { + if (daysRemaining <= 0) { + return 'MATURED'; + } + if (daysRemaining <= FD_MATURITY_POLICY.dueSoonDays) { + return 'DUE_SOON'; + } + if (daysRemaining <= FD_MATURITY_POLICY.upcomingDays) { + return 'UPCOMING'; + } + return 'ON_TRACK'; +} + +/** Builds one dashboard row from a raw `/fixeddepositaccounts` list item and today's ISO date. */ +export function buildMaturityRow( + fd: { + id: number; + accountNo: string; + clientId?: number; + clientName?: string; + savingsProductName?: string; + productName?: string; + fdType?: string; + maturityDate: string; + depositAmount?: number; + maturityAmount?: number; + onClosureType?: number; + linkedAccountId?: number; + renewalGracePeriodDays?: number; + }, + today: string +): FdMaturityRow | null { + if (!fd.maturityDate) { + // No maturity date means the account isn't fully assembled yet (e.g. a chart not applied) -- + // nothing sound to bucket it by, so it's left off the worklist rather than shown wrong. + return null; + } + const daysRemaining = daysBetween(today, fd.maturityDate); + const stage = maturityStage(daysRemaining); + // No grace configured reads as a 0-day buffer, not "not renewable" -- it only shrinks the backdate + // window, matching the backend's default (renewMaturedFixedDeposit treats a missing value as 0). + const gracePeriodDays = fd.renewalGracePeriodDays ?? 0; + // Mirrors updateMaturityDetails()'s own "will I auto-process this?" check exactly, inverted: the + // nightly job leaves an account alone (never touches it) only when it was opened with NO maturity + // instruction at all -- that's the one case the Dashboard's Renew action exists for. An account + // opened WITH an explicit instruction auto-processes tonight regardless of the product's renewal + // grace period (the grace window never delays it), so it's never renewable here either way. + const isReinvestEligible = + !!fd.fdType && RENEWABLE_FD_TYPES.includes(fd.fdType as string) && fd.onClosureType == null; + const renewable = stage === 'MATURED' && isReinvestEligible; + const withinGraceWindow = renewable && daysOverdueFor(daysRemaining) <= gracePeriodDays; + const configuredInstructionLabel = + stage === 'MATURED' && !renewable && fd.onClosureType != null + ? (ON_CLOSURE_TYPE_LABELS[fd.onClosureType] ?? null) + : null; + + return { + id: fd.id, + accountNo: fd.accountNo || '', + clientId: fd.clientId ?? null, + clientName: fd.clientName || '—', + productName: fd.productName || fd.savingsProductName || '—', + maturityDate: fd.maturityDate, + depositAmount: fd.depositAmount ?? null, + maturityAmount: fd.maturityAmount ?? null, + daysRemaining, + daysOverdue: daysOverdueFor(daysRemaining), + stage, + meta: FD_MATURITY_STAGES[stage], + renewalGracePeriodDays: gracePeriodDays, + linkedAccountId: fd.linkedAccountId ?? null, + onClosureType: fd.onClosureType ?? null, + isReinvestEligible, + renewable, + withinGraceWindow, + configuredInstructionLabel + }; +} + +function daysOverdueFor(daysRemaining: number): number { + return daysRemaining < 0 ? -daysRemaining : 0; +} + +export interface FdMaturitySummary { + total: number; + matured: number; + dueSoon: number; + upcoming: number; + onTrack: number; +} + +export function maturitySummary(rows: FdMaturityRow[]): FdMaturitySummary { + return { + total: rows.length, + matured: rows.filter((r) => r.stage === 'MATURED').length, + dueSoon: rows.filter((r) => r.stage === 'DUE_SOON').length, + upcoming: rows.filter((r) => r.stage === 'UPCOMING').length, + onTrack: rows.filter((r) => r.stage === 'ON_TRACK').length + }; +} + +/** Sorts the worklist so the most overdue, then the soonest-to-mature, rows come first. */ +export function sortMaturityRows(rows: FdMaturityRow[]): FdMaturityRow[] { + const rank: Record = { + MATURED: 0, + DUE_SOON: 1, + UPCOMING: 2, + ON_TRACK: 3 + }; + return [...rows].sort((a, b) => rank[a.stage] - rank[b.stage] || a.daysRemaining - b.daysRemaining); +} diff --git a/src/app/fd-dashboard/fd-dashboard.module.ts b/src/app/fd-dashboard/fd-dashboard.module.ts new file mode 100644 index 000000000..d09c71fe5 --- /dev/null +++ b/src/app/fd-dashboard/fd-dashboard.module.ts @@ -0,0 +1,22 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; +import { FdDashboardRoutingModule } from './fd-dashboard-routing.module'; +import { FdDashboardComponent } from './fd-dashboard.component'; + +@NgModule({ + imports: [ + SharedModule, + FdDashboardRoutingModule, + FdDashboardComponent + ] +}) +export class FdDashboardModule {} From 89c091671f0f460b8b5b511c02cb0673f9f19fb8 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 16:02:35 +0530 Subject: [PATCH 06/32] feat(fd): add Renewal Grace Period (Days) field to FD product Terms step Frontend half of the new per-product grace-period field: number input alongside the existing term fields, and stops forcing real default values (1 / the first period-frequency option) onto minDepositTerm/minDepositTermTypeId, two hidden fields nobody actually edits through this form -- left blank like their max-/in-multiples-of- siblings already correctly were, so a governed product edit's "Proposed changes" diff no longer showed these as changed on every unrelated edit. Co-Authored-By: Claude Sonnet 5 --- ...-deposit-product-terms-step.component.html | 29 +++++++++++++++++++ ...ed-deposit-product-terms-step.component.ts | 17 +++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.html index 239c749bd..504d06752 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.html +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.html @@ -34,6 +34,35 @@
+ +
+
+ + Renewal +
+
+
+ ℹ️ + + Leave blank for an FD on this product to keep renewing instantly, the same night it matures, as before. Set a + number of days and a matured "auto renew" FD instead waits on the FD Dashboard for that many days for staff to + renew it manually -- backdated to the original maturity date within that window, or from the day it is + actually renewed (with gap-period interest at the linked Savings account's rate) once the window has passed. + +
+
+ + Renewal Grace Period + + Days + @if (fixedDepositProductTermsForm.get('renewalGracePeriodDays').hasError('min')) { + Renewal Grace Period must be zero or greater + } + +
+
+
+
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts index 11061126a..5824a1591 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts @@ -74,7 +74,6 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges this.periodFrequencyData = this.fixedDepositProductsTemplate.periodFrequencyTypeOptions || []; const isEditing = !!this.fixedDepositProductsTemplate.id; - const defaultPeriodTypeId = this.periodFrequencyData[0]?.id ?? null; const defaultAveragePeriod = this.averagePeriodData[0]?.value || 'Monthly'; const savedEnumTypeId = this.fixedDepositProductsTemplate.interestCalculationType?.id || this.interestCalculationTypeData?.[0]?.id || 1; @@ -85,12 +84,17 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges : (this.interestCalculationTypeOptionData?.[0]?.id ?? null); this.fixedDepositProductTermsForm.patchValue({ - minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? 1, - minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? defaultPeriodTypeId, + // Hidden, unedited-by-anyone fields -- left blank (like their max-/in-multiples-of- siblings + // already correctly are) rather than forced to a real default, or every single product edit + // submitted a "real" value against a product that had none, and a checker's "Proposed changes" + // review showed these two as "changed" on every unrelated edit, never anything a maker touched. + minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? '', + minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? '', maxDepositTerm: this.fixedDepositProductsTemplate.maxDepositTerm ?? '', maxDepositTermTypeId: this.fixedDepositProductsTemplate.maxDepositTermType?.id ?? '', inMultiplesOfDepositTerm: this.fixedDepositProductsTemplate.inMultiplesOfDepositTerm ?? '', inMultiplesOfDepositTermTypeId: this.fixedDepositProductsTemplate.inMultiplesOfDepositTermType?.id ?? '', + renewalGracePeriodDays: this.fixedDepositProductsTemplate.renewalGracePeriodDays ?? '', minDepositAmount: this.fixedDepositProductsTemplate.minDepositAmount, maxDepositAmount: this.fixedDepositProductsTemplate.maxDepositAmount, nominalAnnualInterestRate: this.fixedDepositProductsTemplate.nominalAnnualInterestRate ?? 0, @@ -171,6 +175,13 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges maxDepositTermTypeId: [''], inMultiplesOfDepositTerm: [''], inMultiplesOfDepositTermTypeId: [''], + // Days after maturity a depositor can still walk in and have their FD renewed backdated to the + // original maturity date, at the currently active rate -- blank/0 keeps today's instant, + // same-night auto-renewal (see FD Dashboard for the manual renewal this enables). + renewalGracePeriodDays: [ + '', + Validators.min(0) + ], minDepositAmount: [ '', Validators.min(0) From 50b3542dfe340268605a57f01c12ca6c01846337 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 16:03:15 +0530 Subject: [PATCH 07/32] fix(products): show only genuinely-changed fields in the product-edit diff Three separate bugs in the checker's "Proposed changes" dialog, found while adding the Renewal Grace Period field above: - The "current" side resolved an enum field's value against the wrong payload key (preClosurePenalInterestOnTypeId, minDepositTermTypeId, etc. are exposed on the stored product as an object under a differently-named field, e.g. preClosurePenalInterestOnType.id) so it always read as blank -- these fields showed as changed on every single edit, never just the ones a maker actually touched. - Enum/option fields resolved their label against a naive `Options` key that doesn't match Fineract's actual (inconsistent) naming, so a genuinely changed field showed its raw numeric id instead of the option a maker picked. - Renewal Grace Period Days and other newer fields never appeared at all, because the maker-checker pending-edit mirror table didn't carry them (see the backend commit for that). Co-Authored-By: Claude Sonnet 5 --- .../product-edit-diff-dialog.component.ts | 56 +++++++++++++++---- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts index 43e1e7dea..c3970ff75 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts @@ -69,7 +69,14 @@ export class ProductEditDiffDialogComponent implements OnInit { currencyCode: 'currency.code', digitsAfterDecimal: 'currency.decimalPlaces', inMultiplesOf: 'currency.inMultiplesOf', - taxGroupId: 'taxGroup.id' + taxGroupId: 'taxGroup.id', + // Enum fields the stored product exposes as "Type": {id, value}", not "TypeId" -- + // without these the "current" side always read as blank, so these showed as changed on every + // edit even when the maker never touched them. + preClosurePenalInterestOnTypeId: 'preClosurePenalInterestOnType.id', + minDepositTermTypeId: 'minDepositTermType.id', + maxDepositTermTypeId: 'maxDepositTermType.id', + inMultiplesOfDepositTermTypeId: 'inMultiplesOfDepositTermType.id' }; /** @@ -78,6 +85,17 @@ export class ProductEditDiffDialogComponent implements OnInit { * `accountingMappingOptions` rather than a per-field `Options` key, so both sides of the * diff need this table to turn an account id into the account's name. */ + /** + * Fields whose option list isn't named after the field at all -- min/max/in-multiples-of deposit + * term type all resolve against one shared frequency list (Days/Weeks/Months/Years), not a list of + * their own, so the `Options` convention below never finds them. + */ + private static readonly OPTIONS_KEY_ALIASES: Record = { + minDepositTermTypeId: 'periodFrequencyTypeOptions', + maxDepositTermTypeId: 'periodFrequencyTypeOptions', + inMultiplesOfDepositTermTypeId: 'periodFrequencyTypeOptions' + }; + private static readonly GL_FIELD_TO_MAPPING: Record = { savingsReferenceAccountId: 'savingsReferenceAccount', savingsControlAccountId: 'savingsControlAccount', @@ -149,23 +167,37 @@ export class ProductEditDiffDialogComponent implements OnInit { /** * Enum/option fields arrive from the edit as a bare id, which reads as a meaningless number in the - * table. The product fetched with template=true carries the matching option list under a predictable - * `Options` key (accountingRule -> accountingRuleOptions), so the id is resolved back to the - * label the maker actually picked. Returns null when there is no such list to resolve against. + * table. The product fetched with template=true usually carries the matching option list under a + * predictable `Options` key (accountingRule -> accountingRuleOptions), so the id is resolved + * back to the label the maker actually picked. Two other shapes this also has to cover: + * - a field ending in "...TypeId" whose list drops the "Id" (preClosurePenalInterestOnTypeId -> + * preClosurePenalInterestOnTypeOptions), and + * - a field resolving against a shared list that isn't named after the field at all + * (OPTIONS_KEY_ALIASES). + * All three are tried in turn; returns null only when none of them find a match, so the id is + * shown as-is rather than silently wrong. */ private resolveOptionLabel(fieldKey: string, id: any, template: any): string | null { if (ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[fieldKey]) { return this.glAccountNamesById.get(String(id)) ?? null; } - const options = template?.[`${fieldKey}Options`]; - if (!Array.isArray(options)) { - return null; + const candidateKeys = [ + ProductEditDiffDialogComponent.OPTIONS_KEY_ALIASES[fieldKey], + `${fieldKey}Options`, + fieldKey.endsWith('Id') ? `${fieldKey.slice(0, -2)}Options` : null + ].filter((key): key is string => !!key); + + for (const optionsKey of candidateKeys) { + const options = template?.[optionsKey]; + if (!Array.isArray(options)) { + continue; + } + const match = options.find((option: any) => String(option?.id) === String(id)); + if (match) { + return match.value ?? match.name ?? match.code ?? null; + } } - const match = options.find((option: any) => String(option?.id) === String(id)); - if (!match) { - return null; - } - return match.value ?? match.name ?? match.code ?? null; + return null; } /** From a652ce1093c9e76e732a666d5c4c95ea488ecfb9 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 16:08:51 +0530 Subject: [PATCH 08/32] feat(fd): make Maturity Instructions optional at account-opening, pre-fill it on edit Maturity Instructions can now be left unselected when opening a SINGLE/DOUBLE/SPECIAL FD -- a new "-- Not selected (decide later via FD Dashboard) --" option, plus a clear button next to the field, since a maker had no way to go back to blank once something was picked. An account left this way is never touched by the nightly maturity job; what happens to it at maturity is decided later, manually, from the FD Dashboard's Renew action (see the backend/dashboard commits) -- an explicit choice here still auto-processes on its own exactly as before. Also fixes a real, separate pre-existing bug found in the same step: editing an existing account never read back its own already-saved Maturity Instructions/transfer-target choice -- the Edit screen always opened onto a blank picker even for an account that genuinely had one set, because it was being read from the wrong template object (the blank per-product default template refetched from the Product dropdown, not the actual account+template response that carries the account's own saved values). Co-Authored-By: Claude Sonnet 5 --- ...account-maturity-terms-step.component.html | 24 ++++++++-- ...t-account-maturity-terms-step.component.ts | 48 +++++++++++++++++-- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html index 17d6f00d7..bb548478e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html @@ -7,17 +7,29 @@ -->
-

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

+

{{ 'labels.heading.Maturity Instructions' | translate }}

{{ 'labels.inputs.Maturity Instructions' | translate }} + -- Not selected (decide later via FD Dashboard) -- @for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) { {{ maturityInstruction.value | translateKey: 'catalogs' }} } + @if (closureSettingsForm.value.maturityInstructionId) { + + } @if (closureSettingsForm.contains('transferToSavingsId')) { @@ -28,8 +40,8 @@ savings
-

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

- Maturity proceeds will be credited to this account +

{{ transferAccountCardTitle }}

+ {{ transferAccountCardSubtitle }}
@@ -69,7 +81,7 @@
} @else { - {{ 'labels.inputs.Transfer to savings account' | translate }} + {{ transferAccountCardTitle }} @for (account of savingsAccountsData; track account) { @@ -77,13 +89,15 @@ } - Transfer to savings account is required + {{ transferAccountCardTitle }} is required }
} +

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

+ {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts index bce8468a1..d4552f9d1 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts @@ -11,6 +11,7 @@ import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/cor import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatIcon } from '@angular/material/icon'; +import { MatIconButton } from '@angular/material/button'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, MatCheckbox, - MatIcon + MatIcon, + MatIconButton ] }) export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges { @@ -53,9 +55,12 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges preClosurePenalInterestOnTypeId: [''] }); this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => { - // 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both - // Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too. - if (value === 200) { + // 200 = Transfer to Savings -- the whole matured balance goes here. 400 = Reinvest Principal + // Only -- the principal rolls into a new FD, but the leftover interest still needs the same + // kind of target so it isn't left stranded on the closed account (a real gap the account used + // to silently hit before this field also asked for it here). Reinvest Principal + Interest + // (300) rolls the whole balance into the same account, so it never needs a target at all. + if (value === 200 || value === 400) { this.closureSettingsForm.addControl( 'transferToSavingsId', new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required) @@ -91,15 +96,48 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges }); } } + // Editing an existing account: its own already-saved Maturity Instructions choice lives on + // `fixedDepositsAccountTemplate` (the real account+template GET, FixedDepositAccountData's + // `onAccountClosure`) -- NOT on `fixedDepositsAccountProductTemplate`, which is only the blank + // per-product default template refetched from the Product dropdown's own productId (via + // getFixedDepositsAccountTemplate(clientId, productId)) and carries no account-specific data at + // all. This was never read from the right place before, so Edit always opened onto a blank + // picker even for an account that really does have an instruction saved. `.dirty` guards it the + // same way the penal-interest fields above do, so a maker's own in-progress edit here is never + // silently overwritten if this input fires again mid-edit. + if (this.fixedDepositsAccountTemplate && !this.closureSettingsForm.get('maturityInstructionId').dirty) { + this.closureSettingsForm + .get('maturityInstructionId') + ?.setValue(this.fixedDepositsAccountTemplate.onAccountClosure?.id ?? ''); + const transferControl = this.closureSettingsForm.get('transferToSavingsId'); + if (transferControl) { + transferControl.setValue( + this.fixedDepositsAccountTemplate.transferToSavingsId ?? this.linkedSavingsAccountId ?? '' + ); + } + } } - /** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */ + /** The linked-savings account for the transfer-to-savings / reinvest-principal-only-interest + * maturity instruction, once picked. */ get selectedTransferAccount(): any { const id = this.closureSettingsForm.value.transferToSavingsId; if (!id) return null; return this.savingsAccountsData.find((account: any) => account.id === id) ?? null; } + get transferAccountCardTitle(): string { + return this.closureSettingsForm.value.maturityInstructionId === 400 + ? 'Interest payout account' + : 'Transfer to savings account'; + } + + get transferAccountCardSubtitle(): string { + return this.closureSettingsForm.value.maturityInstructionId === 400 + ? 'The principal renews into a new FD; interest earned is paid out to this account.' + : 'Maturity proceeds will be credited to this account.'; + } + clearTransferAccount(): void { this.closureSettingsForm.get('transferToSavingsId')?.setValue(''); } From 1eaa8cc8cf99aa7fb0c042fb89406e89a9324b3c Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 16:12:45 +0530 Subject: [PATCH 09/32] feat(fd): make the Payment dialog the FD Dashboard's Renew action too, redesign it The Payment dialog (Transfer to Savings / Reinvest Full / Reinvest Principal Only / Partial Reinvest, works whether an FD is Active or Matured) already had a complete, correct backend implementation for every closure type -- including one this session found and fixed a real gap in on the backend side (Reinvest Principal Only silently stranded its leftover interest). Rather than duplicating that logic in a second, narrower dialog, the FD Dashboard's Renew action now opens this same dialog in a `dashboardRenewal` mode: every closure type is still available, but the Closed On Date is computed from the product's grace window instead of picked freely (backdated to the original maturity date within grace, today once it's passed, picker hidden so it can't be moved off that value), and submit() calls the new gap-interest pre-step endpoint first when past grace, before the same generic `payment` command every other caller already used. Also: full visual redesign (bigger dialog, gradient header, per-option accent colors, refined spacing) applied to both entry points since they're the same component now; removes the native "Close" action from a Matured FD's Account Actions menu, since what happens at maturity now goes through the nightly job or this dialog, not a separate close command that bypassed both. Co-Authored-By: Claude Sonnet 5 --- .../payment-dialog.component.html | 97 ++-- .../payment-dialog.component.scss | 519 +++++++++++------- .../payment-dialog.component.ts | 78 ++- .../fixed-deposit-account-view.component.ts | 2 +- .../fixed-deposits-buttons.config.ts | 9 +- src/environments/.env.ts | 4 +- src/main.scss | 13 + 7 files changed, 480 insertions(+), 242 deletions(-) diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html index 659f79528..34a510e7f 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html @@ -8,11 +8,24 @@
- -
-

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

-

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

+
+ +
+

+ {{ isDashboardRenewalMode ? 'Renew Fixed Deposit' : ('labels.heading.Payment' | translate) }} +

+

+ {{ + isDashboardRenewalMode + ? 'Renew ' + (dashboardRenewalRow?.accountNo ?? 'this matured FD') + '.' + : "Process this Fixed Deposit now -- works whether it's Active or Matured." + }} +

+
+
@@ -27,37 +40,46 @@
} @else { + @if (isDashboardRenewalMode) { +

+ + {{ dashboardRenewalTimingText }} +

+ } + @if (reinvestCap !== null && reinvestCap !== undefined) {
{{ - remainderIsApproximate - ? 'Amount available on the selected date' - : ('labels.inputs.Maturity Amount' | translate) + isDashboardRenewalMode || !remainderIsApproximate + ? ('labels.inputs.Maturity Amount' | translate) + : 'Amount available on the selected date' }} {{ reinvestCap | formatNumber }}
} - - {{ 'labels.inputs.Closed On Date' | translate }} - - - - @if (paymentForm.controls.closedOnDate.hasError('required')) { - - {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - + @if (!isDashboardRenewalMode) { + + {{ 'labels.inputs.Closed On Date' | translate }} + + + + @if (paymentForm.controls.closedOnDate.hasError('required')) { + + {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + }
@@ -162,10 +184,12 @@ } - - {{ 'labels.inputs.Note' | translate }} - - + @if (!isDashboardRenewalMode) { + + {{ 'labels.inputs.Note' | translate }} + + + } @if (submitError) {
- +
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss index 48967437c..a95aa35aa 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss @@ -6,291 +6,404 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -$navy: #0d1b2a; -$slate: #475569; -$muted: #64748b; -$border: #e2e8f0; -$soft: #f8fafc; -$blue: #1e40af; -$blue-soft: #eff6ff; -$green: #166534; -$green-soft: #f0fdf4; +/** + * Payment / Renewal Dialog Styles + * CBSNexora Core Banking System + */ .pmt { display: flex; flex-direction: column; - min-height: 320px; - max-height: 85vh; + height: 100%; + max-height: 92vh; + box-sizing: border-box; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: #0f172a; + border-radius: 16px; overflow: hidden; - margin: -24px; + box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%); } .pmt__header { - display: flex; - align-items: flex-start; - gap: 14px; - padding: 22px 28px 18px; - background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%); - color: #fff; - flex: 0 0 auto; -} - -.pmt__header-ico { - width: 40px; - height: 40px; - min-width: 40px; - border-radius: 10px; - background: rgb(255 255 255 / 15%); display: flex; align-items: center; - justify-content: center; - font-size: 18px; + justify-content: space-between; + gap: 16px; + padding: 26px 30px; + background: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 55%, #2563eb 100%); + color: #fff; + flex-shrink: 0; + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%); + pointer-events: none; + } + + &-left { + display: flex; + align-items: center; + gap: 16px; + position: relative; + z-index: 1; + } + + &-ico { + width: 48px; + height: 48px; + border-radius: 14px; + background: rgb(255 255 255 / 16%); + border: 1px solid rgb(255 255 255 / 25%); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: #fff; + flex-shrink: 0; + } + + &-text { + display: flex; + flex-direction: column; + } + + &-close { + position: relative; + z-index: 1; + background: rgb(255 255 255 / 14%); + border: none; + color: #fff; + width: 34px; + height: 34px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + + &:hover:not(:disabled) { + background: rgb(255 255 255 / 28%); + transform: rotate(90deg); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } } .pmt__title { margin: 0; - font-size: 19px; - font-weight: 700; + font-size: 20px; + font-weight: 800; + color: #fff; + line-height: 1.25; + letter-spacing: -0.01em; } .pmt__subtitle { - margin: 4px 0 0; - font-size: 12.5px; - color: rgb(255 255 255 / 82%); + margin: 5px 0 0; + font-size: 13px; + color: rgb(255 255 255 / 88%); line-height: 1.4; } .pmt__content { - padding: 22px 28px 8px !important; + padding: 26px 30px !important; overflow-y: auto; flex: 1 1 auto; + background: #fbfcfe; + + mat-form-field { + width: 100%; + } } .pmt__loading { display: flex; justify-content: center; align-items: center; - min-height: 260px; + min-height: 240px; } .pmt__stat { display: flex; flex-direction: column; - gap: 2px; - background: $blue-soft; - border: 1px solid #dbeafe; - border-radius: 10px; - padding: 12px 16px; + gap: 4px; + background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%); + border: 1px solid #bfdbfe; + border-radius: 14px; + padding: 18px 22px; margin-bottom: 18px; -} -.pmt__stat-label { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: $blue; -} + &-label { + font-size: 11.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #1d4ed8; + } -.pmt__stat-value { - font-size: 22px; - font-weight: 800; - color: $navy; - font-variant-numeric: tabular-nums; -} - -.pmt__date { - width: 100%; + &-value { + font-family: 'JetBrains Mono', 'DM Sans', monospace; + font-size: 28px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.01em; + } } .pmt__section-label { - font-size: 11.5px; + font-size: 12px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.05em; - color: $slate; - margin: 4px 0 10px; + letter-spacing: 0.06em; + color: #475569; + margin: 10px 0 12px; } +/* Action Options Grid - 2 Equal Columns, comfortably wide now that the dialog itself is wider */ .pmt__options { display: grid; - grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); - gap: 10px; - margin-bottom: 6px; + grid-template-columns: repeat(2, 1fr); + gap: 14px; + margin-bottom: 18px; + + @media (width <= 560px) { + grid-template-columns: 1fr; + } } .pmt__option { display: flex; align-items: flex-start; - gap: 10px; + gap: 14px; text-align: left; background: #fff; - border: 1.5px solid $border; - border-radius: 10px; - padding: 12px 14px; + border: 1.5px solid #e2e8f0; + border-radius: 14px; + padding: 16px; cursor: pointer; font: inherit; - transition: - border-color 0.15s, - background 0.15s, - box-shadow 0.15s; -} + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; -.pmt__option:hover { - border-color: #93c5fd; - background: $blue-soft; -} + &:hover { + border-color: #93c5fd; + background: #f8fafc; + box-shadow: 0 6px 16px rgb(15 23 42 / 6%); + transform: translateY(-1px); + } -.pmt__option--active { - border-color: $blue; - background: $blue-soft; - box-shadow: 0 0 0 1px $blue; -} + &--active { + border-color: #2563eb; + background: #eff6ff; + box-shadow: 0 0 0 1.5px #2563eb; -.pmt__option-ico { - width: 30px; - height: 30px; - min-width: 30px; - border-radius: 8px; - background: $soft; - color: $blue; - display: flex; - align-items: center; - justify-content: center; - font-size: 13px; -} + .pmt__option-ico { + background: #2563eb; + color: #fff; + border-color: #2563eb; + } + } -.pmt__option--active .pmt__option-ico { - background: $blue; - color: #fff; -} + &-ico { + width: 38px; + height: 38px; + min-width: 38px; + border-radius: 10px; + background: #f1f5f9; + border: 1px solid #e2e8f0; + color: #2563eb; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + flex-shrink: 0; + transition: all 0.2s ease; + } -.pmt__option-text { - display: flex; - flex-direction: column; - gap: 2px; -} + // Distinct accent per option while unselected, purely presentational -- helps the four cards read + // apart at a glance. Selecting a card always fills its icon blue (see &--active above), matching the + // blue selection border/check regardless of which option it is, rather than mixing selection-blue + // with the option's own accent. + &:not(.pmt__option--active):nth-child(2) .pmt__option-ico { + color: #7c3aed; + } -.pmt__option-title { - font-size: 13px; - font-weight: 700; - color: $navy; -} + &:not(.pmt__option--active):nth-child(3) .pmt__option-ico { + color: #0d9488; + } -.pmt__option-desc { - font-size: 11px; - color: $muted; - line-height: 1.35; -} + &:not(.pmt__option--active):nth-child(4) .pmt__option-ico { + color: #d97706; + } -.pmt__option:focus-visible { - outline: 2px solid $blue; - outline-offset: 2px; -} + &-text { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + } -.pmt__option-check { - margin-left: auto; - color: $blue; - font-size: 13px; + &-title { + font-size: 14px; + font-weight: 700; + color: #0f172a; + line-height: 1.3; + } + + &-desc { + font-size: 12px; + color: #64748b; + line-height: 1.4; + } + + &-check { + color: #2563eb; + font-size: 17px; + position: absolute; + top: 14px; + right: 14px; + } } .pmt__option-error { color: #b91c1c; font-size: 12px; - margin: 4px 0 14px; -} - -.pmt__error { - display: flex; - align-items: flex-start; - gap: 4px; - background: #fef2f2; - border: 1px solid #fecaca; - border-radius: 8px; - color: #b91c1c; - font-size: 12.5px; - line-height: 1.45; - padding: 10px 14px; - margin: 4px 0 8px; -} - -.pmt__hint { - display: flex; - align-items: flex-start; - gap: 4px; - background: $soft; - border: 1px solid $border; - border-radius: 8px; - color: $slate; - font-size: 12px; - line-height: 1.45; - padding: 9px 13px; - margin: 0 0 10px; -} - -.pmt__hint--warn { - background: #fffbeb; - border-color: #fde68a; - color: #92400e; + font-weight: 600; + margin: -10px 0 14px; } .pmt__split { display: flex; align-items: center; - gap: 12px; - background: $soft; + gap: 16px; + background: #f8fafc; border: 1px dashed #cbd5e1; - border-radius: 10px; - padding: 14px 16px 0; - margin: 4px 0 8px; + border-radius: 14px; + padding: 18px; + margin: 10px 0 18px; flex-wrap: wrap; + + &-input { + flex: 1 1 220px; + } + + &-arrow { + color: #94a3b8; + font-size: 17px; + } + + &-remainder { + display: flex; + flex-direction: column; + gap: 3px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 10px; + padding: 12px 16px; + flex: 1 1 220px; + + &-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #166534; + } + + &-value { + font-family: 'JetBrains Mono', 'DM Sans', monospace; + font-size: 19px; + font-weight: 800; + color: #166534; + } + } } -.pmt__split-input { - flex: 1 1 180px; - min-width: 160px; -} - -.pmt__split-arrow { - color: $muted; - font-size: 14px; - margin-bottom: 20px; -} - -.pmt__split-remainder { +.pmt__hint { display: flex; - flex-direction: column; - gap: 2px; - background: $green-soft; - border: 1px solid #bbf7d0; - border-radius: 8px; - padding: 8px 14px; - margin-bottom: 14px; - flex: 1 1 180px; - min-width: 160px; + align-items: flex-start; + gap: 10px; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-left: 4px solid #0284c7; + border-radius: 10px; + color: #0369a1; + font-size: 13px; + line-height: 1.5; + padding: 12px 16px; + margin: 0 0 18px; + + &--warn { + background: #fffbeb; + border-color: #fde68a; + border-left-color: #d97706; + color: #b45309; + } } -.pmt__split-remainder-label { - font-size: 10.5px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - color: $green; -} - -.pmt__split-remainder-value { - font-size: 16px; - font-weight: 800; - color: $green; - font-variant-numeric: tabular-nums; -} - -.pmt__content mat-form-field { - width: 100%; +.pmt__error { + display: flex; + align-items: flex-start; + gap: 10px; + background: #fef2f2; + border: 1px solid #fecaca; + border-left: 4px solid #ef4444; + border-radius: 10px; + color: #b91c1c; + font-size: 13px; + padding: 12px 16px; + margin: 10px 0; } .pmt__actions { - padding: 14px 28px 20px !important; - border-top: 1px solid $border; - flex: 0 0 auto; + padding: 18px 30px !important; + border-top: 1px solid #e2e8f0; + background: #fff; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + flex-shrink: 0; + + .pmt__btn-cancel { + border-radius: 10px; + font-weight: 600; + color: #475569; + } + + .pmt__btn-submit { + border-radius: 10px; + font-weight: 700; + padding: 0 26px; + height: 42px; + background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%); + color: #fff; + box-shadow: 0 8px 20px rgb(37 99 235 / 30%); + transition: all 0.2s ease; + + &:hover:not(:disabled) { + box-shadow: 0 10px 24px rgb(37 99 235 / 40%); + transform: translateY(-1px); + } + + &:disabled { + background: #cbd5e1; + box-shadow: none; + color: #64748b; + } + } } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts index cd433f83c..4e1cc8c39 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts @@ -27,12 +27,22 @@ import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FdMaturityRow } from 'app/fd-dashboard/fd-dashboard.compute'; /** * "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and * Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice, * Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the * entered amount renews on this same account, the rest transfers to a savings account picked here. + * + * Doubles as the FD Dashboard's Renew action when opened with `data.dashboardRenewal` set: same form, + * same full set of closure types (nothing removed -- Transfer to Savings and Partial Reinvest work from + * the Dashboard too, not just the two Reinvest options). The only two differences in that mode: the + * Closed On Date is computed from the FD's grace window instead of picked freely (backdated to the + * original maturity date within grace, today once it's passed -- the picker is hidden, not just + * defaulted, so it can't accidentally be moved off that date), and submit() first calls + * `creditGapInterestIfPastGrace` when past grace, crediting the gap-period interest (at the linked + * account's rate) onto the FD before the normal `payment` command runs and reads its balance. */ @Component({ selector: 'mifosx-payment-dialog', @@ -76,7 +86,13 @@ export class PaymentDialogComponent implements OnInit { }; dialogRef = inject>(MatDialogRef); - data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA); + data: { + accountId: number | string; + accountBalance?: number; + maturityDate?: any; + /** Present only when opened from the FD Dashboard's Renew action. */ + dashboardRenewal?: { row: FdMaturityRow }; + } = inject(MAT_DIALOG_DATA); private formBuilder = inject(UntypedFormBuilder); private fixedDepositsService = inject(FixedDepositsService); private settingsService = inject(SettingsService); @@ -93,6 +109,10 @@ export class PaymentDialogComponent implements OnInit { submitError: string = null; maxDate: Date; + /** Whether gap-interest still needs crediting before this submits -- only ever true once, right + * after open; cleared as soon as `submit()` has credited it, so a retried submit doesn't double-post. */ + private gapInterestPending = false; + ngOnInit(): void { this.maxDate = this.settingsService.businessDate; this.paymentForm = this.formBuilder.group({ @@ -106,6 +126,18 @@ export class PaymentDialogComponent implements OnInit { ], note: [''] }); + + const dashboardRow = this.dashboardRenewalRow; + if (dashboardRow) { + // Computed from the grace window, never picked -- the date field stays in the form (the generic + // submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its picker is + // hidden in this mode (see the template), so it can't be moved off this value. + this.paymentForm + .get('closedOnDate') + .setValue(dashboardRow.withinGraceWindow ? new Date(dashboardRow.maturityDate) : this.maxDate); + this.gapInterestPending = !dashboardRow.withinGraceWindow; + } + this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id)); // The cap depends on whether the chosen date is on or after maturity, so a date change can make an // already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date. @@ -136,6 +168,25 @@ export class PaymentDialogComponent implements OnInit { }); } + get isDashboardRenewalMode(): boolean { + return !!this.data.dashboardRenewal; + } + + get dashboardRenewalRow(): FdMaturityRow | null { + return this.data.dashboardRenewal?.row ?? null; + } + + /** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date in this + * mode, since the date itself is no longer visible to explain it. */ + get dashboardRenewalTimingText(): string { + const row = this.dashboardRenewalRow; + if (!row) return ''; + return row.withinGraceWindow + ? `Backdated to its original maturity date (${row.maturityDate}), at the currently active interest rate.` + : `This FD's grace period has passed -- gap-period interest (for the days since maturity) will be ` + + `credited at the linked account's rate first, then this renews from today.`; + } + /** * The amount being split, and the bound the entered partial amount is checked against. *

@@ -259,6 +310,31 @@ export class PaymentDialogComponent implements OnInit { } submit(): void { + if (this.gapInterestPending) { + this.submitting = true; + this.submitError = null; + this.fixedDepositsService.creditGapInterestIfPastGrace(this.data.accountId).subscribe({ + next: () => { + this.gapInterestPending = false; + this.submitGenericPayment(); + }, + error: (error: any) => { + this.submitting = false; + this.submitError = + error?.error?.defaultUserMessage ?? + error?.error?.errors?.[0]?.defaultUserMessage ?? + 'Could not credit gap-period interest. Please review the details and try again.'; + } + }); + return; + } + this.submitGenericPayment(); + } + + /** The same `command=payment` submission every non-Dashboard call to this dialog already used -- + * Dashboard-renewal mode reaches it too, once any gap-interest pre-step above has cleared, so every + * closure type behaves identically regardless of where the dialog was opened from. */ + private submitGenericPayment(): void { const formValue = { ...this.paymentForm.value }; const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts index fefdb323d..42f39f6c5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts @@ -276,7 +276,7 @@ export class FixedDepositAccountViewComponent implements OnInit { */ private openPaymentDialog(): void { const paymentDialogRef = this.dialog.open(PaymentDialogComponent, { - width: '640px', + width: '720px', maxWidth: '95vw', data: { accountId: this.fixedDepositsAccountData.id, diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts index 3146a730c..76abd062d 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts @@ -57,11 +57,12 @@ export class FixedDepositsButtonsConfiguration { ]; break; case 'Matured': + // 'Close' (the generic Fineract apply-maturity-instructions action) is deliberately left out + // here -- what happens to a matured FD is now handled either automatically by the nightly job + // (an account opened with an explicit instruction) or manually via the FD Dashboard's Renew + // action (an account left without one, backdated within grace / gap-interest after). Closing + // from here would go around both of those paths. this.buttonsArray = [ - { - name: 'Close', - icon: 'fa fa-arrow-right' - }, { name: 'Calculate Interest', icon: 'fa fa-table' diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 0ec45a382..f08a6bab3 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260921', - 'hash': '3c363d03' + 'version': '260923', + 'hash': '3f59cc6b' }, 'allow_switching_backend_instance': true }; diff --git a/src/main.scss b/src/main.scss index 16f91750d..c74b84479 100644 --- a/src/main.scss +++ b/src/main.scss @@ -55,6 +55,19 @@ box-shadow: none; } +// ===== Payment / Renewal Dialog Panel ===== +.pmt-dialog-panel .mat-mdc-dialog-container .mdc-dialog__surface { + padding: 0 !important; + border-radius: 16px !important; + overflow: hidden !important; + box-shadow: 0 20px 40px rgb(15 23 42 / 25%) !important; +} + +.pmt-dialog-panel .mat-mdc-dialog-content { + padding: 0 !important; + max-height: none !important; +} + .ipd-dialog .mdc-dialog__surface { border-radius: 20px !important; background: transparent !important; From 7b9a6394c8767ff13d63d31687c944c46e66f827 Mon Sep 17 00:00:00 2001 From: anshul Date: Thu, 24 Sep 2026 17:22:50 +0530 Subject: [PATCH 10/32] Implement organization maker-checker workflows --- package.json | 2 +- src/app/login/login.component.ts | 2 +- .../branch-profile.component.ts | 20 ++- .../institution-profile.component.ts | 13 +- .../create-payment-type.component.ts | 10 +- .../edit-payment-type.component.ts | 10 +- .../payment-types/payment-types.component.ts | 19 ++- .../working-days/working-days.component.ts | 10 +- .../apply-shares/apply-shares.component.html | 6 +- .../apply-collection-dialog.component.html | 6 +- .../gl-settings-dialog.component.ts | 10 +- .../sponsor-bank-list.component.ts | 24 +++- ...nfigure-maker-checker-tasks.component.html | 30 ++++- ...nfigure-maker-checker-tasks.component.scss | 29 +++++ ...configure-maker-checker-tasks.component.ts | 57 +++++++- .../pending-account.component.html | 101 +++++++++++++- .../pending-account.component.scss | 10 ++ .../pending-account.component.ts | 123 +++++++++++++++++- ...nding-savings-account-edits.component.html | 1 + ...nding-savings-account-edits.component.scss | 5 + ...pending-savings-account-edits.component.ts | 112 +++++++++++++--- ...account-edit-compare-dialog.component.html | 4 +- ...s-account-edit-compare-dialog.component.ts | 114 ++++++++++++++-- .../pending-user-edits.component.ts | 1 - .../user-edit-compare-dialog.component.html | 4 +- .../user-edit-compare-dialog.component.ts | 4 + .../checker-inbox-and-tasks.component.html | 20 --- src/app/tasks/maker-checker-entity-fetch.ts | 27 +++- src/app/tasks/tasks-routing.module.ts | 10 +- .../view-checker-inbox.component.ts | 93 ++++++++++++- 30 files changed, 787 insertions(+), 90 deletions(-) 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/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/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/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 d2f1315f5..16fdcda36 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 @@ -297,8 +297,14 @@ export class GlSettingsDialogComponent implements OnInit { ddLimitAmount: val.ddLimitAmount }) .subscribe({ - next: () => { - this.snackBar.open('Sponsor DD GL Mapping & GST Settings saved successfully!', 'Close', { duration: 3000 }); + next: (res: any) => { + // A save submitted for approval comes back HTTP 200 with rollbackTransaction: true - the + // mapping has not actually been applied yet, so this must not claim success the same way an + // immediate save does. + const message = res?.rollbackTransaction + ? 'Sponsor DD GL Mapping & GST Settings submitted for approval' + : 'Sponsor DD GL Mapping & GST Settings saved successfully!'; + this.snackBar.open(message, 'Close', { duration: res?.rollbackTransaction ? 4000 : 3000 }); this.dialogRef.close(true); }, error: (err) => { 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/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) { + + } +
+

{{ screenTitle }}

+
+

{{ screenSubtitle }}

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

Reserve rates

+

+ Rates are effective-dated rather than overwritten: a return filed for an earlier period keeps reporting the rate + that was actually in force then. Saving against a date that already has a rate edits it; a new date adds one. +

+ +
+ @for (type of ['CRR', 'SLR', 'SAVINGS_DEMAND']; track type) { +
+ + {{ type === 'SAVINGS_DEMAND' ? 'Savings demand share' : type }} in force today + + @if (currentRate(type); as rate) { + {{ rate.percentage }}% + @if (rate.seedDefault) { + + Placeholder — confirm the rate actually in force + + } @else { + since {{ rate.effectiveFrom }} + } + } @else { + Not set + {{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute + } +
+ } +
+ +
+ + Rate + + CRR — Form 1 + SLR — Form 9 + + Savings — demand share + + + + + Percentage (%) + + + + + Effective from + + + + +
+ + + + + + + + + + + + + @for (rate of rates; track rate.id) { + + + + + + + + } + +
RatePercentageEffective fromStatus
{{ rate.rateType }}{{ rate.percentage }}%{{ rate.effectiveFrom }} + @if (isFuture(rate)) { + Future-dated + } @else if (currentRate(rate.rateType)?.id === rate.id) { + In force + } @else { + Superseded + } + @if (rate.seedDefault) { + Placeholder + } + + +
+
+ } + + +
+

GL accounts behind each figure

+

+ Every role takes as many accounts as it needs — a bank holds current accounts at several other banks, and each + one counts toward what it maintains. Only postable accounts are listed: a header account's balance is the sum of + its children, so mapping both would count those children twice. +

+ + @if (unmappedCount) { +
+ {{ unmappedCount }} of {{ glRoles.length }} roles have no accounts mapped. Returns that depend on them will + report why they cannot compute instead of showing a figure. +
+ } + + + @if (showTabs) { +
+ @for (group of roleGroups; track group.name) { + + } +
+ } + + @for (role of visibleRoles; track role.configKey) { +
+
+
+ {{ roleLabel(role) }} + {{ role.configKey }} + {{ roleUsedBy(role) }} +
+ @if (isUnmapped(role)) { + Not mapped + } @else { + + {{ selection[role.configKey].length }} account{{ selection[role.configKey].length === 1 ? '' : 's' }} + + } +
+ +

{{ role.description }}

+ + + @if (selection[role.configKey]?.length) { + + + + + + @if (role.classified) { + + } + + + + + @for (mapping of selection[role.configKey]; track $index; let i = $index) { + + + + @if (role.classified) { + + } + + + } + +
GL accountPortion (%)Head
+ + + @for (o of availableAccounts(role, i); track o.id) { + {{ o.glCode }} · {{ o.name }} + } + + + + + + + + + + + @for (c of liabilityClasses; track c.value) { + {{ c.label }} + } + + + + +
+ } + + + @for (entry of unbalancedAccounts(role); track entry.glAccountId) { +
+ {{ glLabel(entry.glAccountId) }} is mapped at {{ entry.total }}% in total. + @if (entry.total < 100) { + {{ 100 - entry.total }}% of its balance will not appear on the return. + } @else { + It would be counted {{ entry.total }}% — more than the account holds. + } +
+ } + +
+ + + + + @if (!role.builtIn) { + + } +
+
+ } + + +
+ @if (!showAddRole) { + +

+ For anything this bank carries that the heads above do not name — a reserve, a tax payable, bills payable. + The return reads it from the same chart of accounts the balance sheet is built from. +

+ } @else { +
+ + Name + + + + + How the return uses it + + @for (u of roleUsages; track u.value) { + {{ u.label }} + } + + + + + Note (optional) + + + + + +
+ } +
+
+ } +
diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss new file mode 100644 index 000000000..4b597578c --- /dev/null +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss @@ -0,0 +1,475 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +$primary: #1353a0; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-900: #202124; +$green: #137333; +$amber: #b06000; +$red: #c5221f; + +.rrc-container { + max-width: 1040px; + margin: 0 auto; + padding: 20px 16px; +} + +.rrc-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 6px; + + &__title { + font-size: 18px; + font-weight: 600; + color: $gray-900; + margin: 0; + } +} + +.rrc-sub { + margin: 0 0 16px; + font-size: 12px; + color: $gray-500; + max-width: 820px; + line-height: 1.6; +} + +.rrc-back { + border-radius: 8px !important; + font-size: 12px !important; + font-weight: 600 !important; + height: 32px !important; + border-color: $border !important; + color: $gray-500 !important; + + i { + margin-right: 6px; + } + + &:hover { + color: $primary !important; + border-color: $primary !important; + } +} + +.rrc-alert { + padding: 10px 14px; + border-radius: 8px; + font-size: 12px; + margin-bottom: 14px; + + &--error { + background: #fce8e6; + color: $red; + } + + &--success { + background: #e6f4ea; + color: $green; + } + + &--warn { + background: #fef7e0; + color: $amber; + } +} + +.rrc-loading { + padding: 28px; + text-align: center; + color: $gray-500; + font-size: 13px; +} + +.rrc-card { + background: #fff; + border: 1px solid $border; + border-radius: 12px; + padding: 18px; + margin-bottom: 18px; + + &__title { + margin: 0 0 4px; + font-size: 15px; + font-weight: 600; + color: $gray-900; + } + + &__hint { + margin: 0 0 16px; + font-size: 12px; + color: $gray-500; + line-height: 1.6; + } +} + +// ── Rates ── + +.rrc-rate-now { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 18px; + + &__item { + flex: 1 1 220px; + background: $gray-50; + border: 1px solid $border; + border-radius: 10px; + padding: 12px 14px; + } + + &__label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-500; + margin-bottom: 4px; + } + + &__value { + font-size: 22px; + font-weight: 700; + color: $primary; + + &--none { + font-size: 15px; + color: $red; + } + } + + &__since { + display: block; + margin-top: 2px; + font-size: 11px; + color: $gray-500; + + &--warn { + color: $amber; + font-weight: 600; + } + } +} + +.rrc-rate-form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-bottom: 18px; + + &__type { + flex: 0 1 190px; + } + + &__pct { + flex: 0 1 150px; + } + + &__date { + flex: 0 1 190px; + } +} + +// ── Tables ── + +.rrc-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + font-weight: 600; + color: $gray-500; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 8px 10px; + border-bottom: 1px solid $border; + } + + td { + padding: 9px 10px; + border-bottom: 1px solid $gray-200; + color: $gray-900; + vertical-align: middle; + } + + tr:last-child td { + border-bottom: none; + } +} + +.rrc-num { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.rrc-key { + display: inline-block; + font-family: 'Roboto Mono', monospace; + font-size: 10.5px; + background: $gray-50; + border: 1px solid $border; + border-radius: 5px; + padding: 2px 6px; + color: $gray-500; +} + +.rrc-badge { + display: inline-block; + font-size: 10.5px; + font-weight: 600; + border-radius: 20px; + padding: 3px 10px; + white-space: nowrap; + + &--active, + &--mapped { + background: #e6f4ea; + color: $green; + } + + &--future { + background: #e8f0fe; + color: $primary; + } + + &--past { + background: $gray-50; + color: $gray-500; + } + + &--unmapped { + background: #fce8e6; + color: $red; + } + + // Shipped with the installation, never confirmed by this bank. + &--seed { + background: #fef7e0; + color: $amber; + margin-left: 6px; + } +} + +// ── GL roles ── + +// One tab per return, so a long role list is configured a return at a time. The tabs wrap rather +// than scroll: a return whose tab had scrolled out of sight would look like one that does not exist. +.rrc-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 16px 0 18px; + border-bottom: 1px solid $border; + padding-bottom: 12px; +} + +.rrc-tab { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border: 1px solid $border; + border-radius: 8px; + background: #fff; + font: inherit; + font-size: 13px; + font-weight: 600; + color: $gray-900; + cursor: pointer; + + &:hover { + background: $gray-50; + } + + &--active { + border-color: $primary; + background: rgba(19, 83, 160, 0.06); + color: $primary; + } + + &__badge { + font-size: 11px; + font-weight: 500; + padding: 1px 7px; + border-radius: 10px; + background: rgba(176, 96, 0, 0.12); + color: $amber; + + // A fully mapped return is worth stating as plainly as an incomplete one, so the tab row can + // be read at a glance instead of by elimination. + &--done { + background: rgba(19, 115, 51, 0.12); + color: $green; + } + } +} + +.rrc-role { + border: 1px solid $border; + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 12px; + + // An unmapped role stops a return computing, so it reads as a problem, not a blank. + &--unmapped { + border-color: #f5c6c4; + background: #fffafa; + } + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 4px; + } + + &__name { + font-size: 13.5px; + font-weight: 600; + color: $gray-900; + margin-right: 8px; + } + + &__usedby { + margin-left: 8px; + font-size: 11px; + color: $primary; + font-weight: 600; + } + + &__desc { + margin: 0 0 12px; + font-size: 12px; + color: $gray-500; + line-height: 1.6; + } + + &__controls { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + + &__select { + flex: 1 1 420px; + } + + &__chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; + } +} + +.rrc-remove { + min-width: 0 !important; + padding: 0 8px !important; + color: $gray-500 !important; + + &:hover { + color: $red !important; + } +} + +.rrc-chip { + font-size: 11px; + background: #e8f0fe; + color: $primary; + border-radius: 20px; + padding: 3px 10px; +} + +@media (max-width: 720px) { + .rrc-rate-form__type, + .rrc-rate-form__pct, + .rrc-rate-form__date, + .rrc-role__select { + flex: 1 1 100%; + } +} + +/* ── mapping rows ── + A mapping is an account, a portion of its balance, and the head that portion falls under, so it + is laid out as a table rather than as chips: the three parts of one row have to read together. */ +.rrc-map { + width: 100%; + border-collapse: collapse; + margin-top: 0.75rem; + + th { + text-align: left; + font-size: 0.72rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.55); + font-weight: 600; + padding: 0 0.5rem 0.25rem 0; + } + + td { + padding: 0.15rem 0.5rem 0.15rem 0; + vertical-align: middle; + } + + /* The portion and head columns hold short values; letting them take an equal share of the row + would squeeze the account name, which is the part that identifies the row. */ + &__narrow { + width: 8.5rem; + } + + &__actions { + width: 3rem; + text-align: right; + } + + &__field { + width: 100%; + } + + /* An incomplete row is marked where it is, rather than only by the disabled Save button, so it is + clear which row is holding the save back. */ + &__row--invalid td { + background: rgba(214, 106, 0, 0.06); + } +} + +.rrc-alert--inline { + margin: 0.5rem 0 0; + font-size: 0.82rem; +} + +/* ── adding a head ── */ +.rrc-add { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px dashed rgba(0, 0, 0, 0.12); + + &__form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + } + + &__desc { + flex: 1 1 16rem; + } +} diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts new file mode 100644 index 000000000..3499691c4 --- /dev/null +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts @@ -0,0 +1,485 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { catchError, forkJoin, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; + +/** + * One GL account pinned to a role: which account, how much of its balance the role takes, and - on a + * liability head - whether that portion is a demand or a time liability. + */ +export interface ReserveMapping { + glAccountId: number; + weightPercentage: number; + liabilityClass: string | null; +} + +/** + * Configuration behind RBI Form 1 (CRR) and Form 9 (SLR): the percentages RBI sets, and which GL + * accounts the returns read their figures from. + * + * Both halves sit on one screen because they are one decision. A percentage applies to a base, and + * an administrator who sets the rate but never maps the accounts produces a return that looks + * configured and reports nothing — so the screen states, per role, whether it is mapped at all. + * + * The accounts cannot be shipped as defaults: this platform seeds two mutually exclusive charts of + * accounts and real tenants match neither exactly, so cash on hand is 100101 on one chart and + * 101001 on another. Picking one here would read as zero on the charts it does not match. + */ +@Component({ + selector: 'mifosx-rbi-reserve-config', + templateUrl: './rbi-reserve-config.component.html', + styleUrls: ['./rbi-reserve-config.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class RbiReserveConfigComponent implements OnInit { + private organizationService = inject(OrganizationService); + + /** + * Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1 + * and Form 9, 'OSS1', 'OSS2'. Null shows every role. + * + * A screen per return rather than one screen for all of them: the four roles that drive CRR and + * the thirty that do not are separate decisions, made by different people at different times, + * and a single list makes each one harder to find than it is to make. + */ + protected readonly group: string | null = 'RESERVE'; + + /** Whether the CRR/SLR percentages belong on this screen. Only the reserve returns have rates. */ + protected readonly showRates: boolean = true; + + glRoles: any[] = []; + + /** The roles grouped by the return they feed, in backend order. */ + roleGroups: { name: string; label: string; roles: any[] }[] = []; + + /** Which group's roles are on screen. Unused when the screen is scoped to one group. */ + activeGroup = ''; + rates: any[] = []; + glAccountOptions: any[] = []; + + loading = true; + savingKey: string | null = null; + savingRate = false; + errorMsg = ''; + successMsg = ''; + + /** + * Draft of the per-role mapping, keyed by role. Committed on Save. + * + * A mapping is an account plus the portion of it the role takes and, on a liability head, which + * head that portion falls under. It is not a plain list of account ids because one account can + * appear twice under one role: RBI treats part of a savings balance as a demand liability and the + * rest as time, which is two rows against the same account. + */ + selection: Record = {}; + + /** The heads a liability can fall under, and the label each carries on screen. */ + readonly liabilityClasses = [ + { value: 'DEMAND', label: 'Demand' }, + { value: 'TIME', label: 'Time' } + ]; + + /** How a head an administrator adds is consumed by the return. */ + readonly roleUsages = [ + { value: 'LIABILITY', label: 'Liability — adds to the DTL base' }, + { value: 'ASSET_NET', label: 'Asset — netted off the base (NDTL)' }, + { value: 'MAINTAINED', label: 'Counts toward what is maintained' }, + { value: 'MEMO', label: 'Memo — shown but totals nothing' } + ]; + + /** New-head form. */ + newRole: { label: string; description: string; usage: string } = { label: '', description: '', usage: 'LIABILITY' }; + addingRole = false; + showAddRole = false; + + /** New-rate form. Defaults to today so the common case is one edit and Save. */ + newRate: { rateType: string; percentage: number | null; effectiveFrom: string } = { + rateType: 'CRR', + percentage: null, + effectiveFrom: new Date().toISOString().slice(0, 10) + }; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + forkJoin({ + config: this.organizationService.getRbiReserveConfig().pipe(catchError(() => of(null))), + template: this.organizationService.getRbiReserveConfigTemplate().pipe(catchError(() => of(null))) + }).subscribe(({ config, template }: any) => { + this.loading = false; + if (!config) { + this.errorMsg = 'Could not load RBI reserve configuration.'; + return; + } + this.applyConfig(config); + this.glAccountOptions = template?.glAccountOptions || []; + }); + } + + private applyConfig(config: any): void { + const allRoles = config.glRoles || []; + // Scoped to this screen's return before anything else, so every count, warning and Save on the + // page speaks about the roles the user can actually see. + this.glRoles = this.group ? allRoles.filter((r: any) => r.group === this.group) : allRoles; + this.rates = config.rates || []; + this.selection = {}; + for (const role of this.glRoles) { + // Read back what is stored rather than re-derived: a weight or a class the administrator set + // deliberately would otherwise be replaced by a default on the next save. + this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({ + glAccountId: a.glAccountId, + weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage, + liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null) + })); + } + this.rebuildGroups(); + } + + /** + * The roles split by the return they feed, so one screen can configure four returns without + * presenting thirty-four roles as a single list where the four that drive CRR are lost among + * the thirty that do not. + * + * Built from the group the backend states rather than from a key prefix: a role's grouping is + * part of what the return means, and duplicating that rule here would let the two drift. + */ + private rebuildGroups(): void { + const byGroup = new Map(); + for (const role of this.glRoles) { + const group = role.group || 'OTHER'; + if (!byGroup.has(group)) { + byGroup.set(group, { label: role.groupLabel || group, roles: [] }); + } + byGroup.get(group)!.roles.push(role); + } + this.roleGroups = [...byGroup.entries()].map(([ + name, + entry + ]) => ({ name, label: entry.label, roles: entry.roles })); + + // Keep the open tab if it still exists, so saving a role does not throw the user back to the + // first tab with their place lost. + if (!this.roleGroups.some((g) => g.name === this.activeGroup)) { + this.activeGroup = this.roleGroups[0]?.name ?? ''; + } + } + + /** Tabs only earn their place when the screen shows more than one return's roles. */ + get showTabs(): boolean { + return this.roleGroups.length > 1; + } + + /** Heading and blurb, overridden per return by the screens scoped to one. */ + get screenTitle(): string { + return 'RBI Reserve Configuration'; + } + + get screenSubtitle(): string { + return ( + 'The CRR and SLR percentages, and the GL accounts that RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly) ' + + 'read their figures from. A rate applies to a base — so a role left unmapped stops its return computing rather ' + + 'than reporting a nil figure that looks real.' + ); + } + + /** A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. */ + isUnmapped(role: any): boolean { + return !(this.selection[role.configKey] || []).length; + } + + get unmappedCount(): number { + return this.glRoles.filter((r) => this.isUnmapped(r)).length; + } + + /** Unmapped roles within one group, so each tab carries its own count. */ + unmappedIn(group: { roles: any[] }): number { + return group.roles.filter((r) => this.isUnmapped(r)).length; + } + + showGroup(name: string): void { + this.activeGroup = name; + } + + get visibleRoles(): any[] { + return this.roleGroups.find((g) => g.name === this.activeGroup)?.roles ?? []; + } + + /** True once the draft differs from what is stored, so Save is only offered when useful. */ + isDirty(role: any): boolean { + const key = (m: any) => `${m.glAccountId}/${m.liabilityClass ?? ''}/${+m.weightPercentage}`; + const stored = (role.accounts || []) + .map((a: any) => key({ ...a, weightPercentage: a.weightPercentage ?? 100 })) + .sort(); + const draft = (this.selection[role.configKey] || []).map(key).sort(); + return stored.length !== draft.length || stored.some((k: string, i: number) => k !== draft[i]); + } + + /** Adds an empty row for this role, for the administrator to pick an account into. */ + addMapping(role: any): void { + // Defaults to the whole balance, and to Time on a liability head. Understating demand is the + // conservative default: demand attracts the heavier treatment, so a forgotten class cannot make + // a bank look compliant when it is not. + this.selection[role.configKey] = [ + ...(this.selection[role.configKey] || []), + { glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null } + ]; + } + + removeMapping(role: any, index: number): void { + this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index); + } + + /** + * Accounts still selectable in a given row: everything not already used by ANOTHER row under the + * same head. The same account under a different head is left available, because a demand slice and + * a time slice of one savings account are two different figures, not a duplicate. + */ + availableAccounts(role: any, index: number): any[] { + const rows = this.selection[role.configKey] || []; + const taken = new Set( + rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`) + ); + const current = rows[index]; + return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`)); + } + + /** A row that cannot be saved - no account chosen, or a portion outside 0-100. */ + isRowInvalid(mapping: ReserveMapping): boolean { + const weight = +mapping.weightPercentage; + return !mapping.glAccountId || isNaN(weight) || weight < 0 || weight > 100; + } + + /** Whether any row of this role is incomplete, so Save is withheld rather than failing server-side. */ + hasInvalidRows(role: any): boolean { + return (this.selection[role.configKey] || []).some((m) => this.isRowInvalid(m)); + } + + /** + * The demand and time subtotals this role's rows declare, as a share of the balances behind them. + * + * Shown because a split is easy to get wrong in a way no single row looks wrong in: two rows + * against one account that together take 90% of it silently drop a tenth of the balance out of the + * base, and only the total reveals it. + */ + weightTotalFor(role: any, glAccountId: number): number { + return (this.selection[role.configKey] || []) + .filter((m) => m.glAccountId === glAccountId) + .reduce((sum, m) => sum + (+m.weightPercentage || 0), 0); + } + + /** Accounts split across more than one row whose portions do not add up to the whole balance. */ + unbalancedAccounts(role: any): { glAccountId: number; total: number }[] { + const rows = this.selection[role.configKey] || []; + const ids = [...new Set(rows.map((m) => m.glAccountId).filter((id) => !!id))]; + return ids + .map((id) => ({ glAccountId: id, total: this.weightTotalFor(role, id) })) + .filter((e) => Math.abs(e.total - 100) > 0.0001); + } + + saveRole(role: any): void { + this.savingKey = role.configKey; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .updateRbiReserveGlRole( + role.configKey, + (this.selection[role.configKey] || []).map((m) => ({ + glAccountId: m.glAccountId, + weightPercentage: +m.weightPercentage, + // Sent as null off a liability head, so a class left over from a head that was retyped + // cannot travel with a figure the split does not apply to. + liabilityClass: role.classified ? m.liabilityClass : null + })) + ) + .pipe( + catchError((err: any) => { + this.savingKey = null; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.savingKey = null; + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = `${this.roleLabel(role)} updated.`; + }); + } + + saveRate(): void { + if (this.newRate.percentage === null || !this.newRate.effectiveFrom) { + this.errorMsg = 'Enter a percentage and an effective date.'; + return; + } + this.savingRate = true; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .upsertRbiReserveRate(this.newRate.rateType, this.newRate.percentage, this.newRate.effectiveFrom) + .pipe( + catchError((err: any) => { + this.savingRate = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this rate.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.savingRate = false; + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = `${this.newRate.rateType} rate saved.`; + this.newRate.percentage = null; + }); + } + + deleteRate(rate: any): void { + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .deleteRbiReserveRate(rate.id) + .pipe( + catchError((err: any) => { + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not remove this rate.'; + return of(null); + }) + ) + .subscribe((res: any) => { + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = 'Rate removed.'; + }); + } + + /** The rate a return filed today would use — the latest one already in effect. */ + currentRate(rateType: string): any | null { + const today = new Date().toISOString().slice(0, 10); + const inForce = this.rates.filter((r) => r.rateType === rateType && r.effectiveFrom <= today); + return inForce.length ? inForce[0] : null; + } + + ratesFor(rateType: string): any[] { + return this.rates.filter((r) => r.rateType === rateType); + } + + /** A future-dated rate is on record but not yet applied — worth saying so explicitly. */ + isFuture(rate: any): boolean { + return rate.effectiveFrom > new Date().toISOString().slice(0, 10); + } + + /** + * A role's display name and how the return uses it, both as the backend states them. + * + * Taken from the response rather than from a table here: a role only exists because code + * computes something from it, and that code is what knows what it means. A copy in the screen + * would leave a newly added role showing as a raw key. + */ + roleLabel(role: any): string { + return role?.label || role?.configKey || ''; + } + + /** Which return each role feeds, so the consequence of leaving one unmapped is visible. */ + roleUsedBy(role: any): string { + return role?.usedBy || ''; + } + + glLabel(accountId: number): string { + const account = this.glAccountOptions.find((a) => a.id === accountId); + return account ? `${account.glCode} — ${account.name}` : `#${accountId}`; + } + + // ── heads an administrator adds ── + + /** + * Adds a head to this return. The bank's own chart decides what these are - a reserve, a tax + * payable, bills payable - so they are rows rather than a fixed list in code. + */ + addRole(): void { + if (!this.newRole.label.trim()) { + this.errorMsg = 'Give the head a name.'; + return; + } + this.addingRole = true; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .createRbiReserveRole({ + label: this.newRole.label.trim(), + description: this.newRole.description.trim(), + // Added to the return this screen configures, so a head cannot land on a form the + // administrator is not looking at. + group: this.group ?? this.activeGroup, + usage: this.newRole.usage + }) + .pipe( + catchError((err: any) => { + this.addingRole = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not add this head.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.addingRole = false; + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = `${this.newRole.label.trim()} added. Map its GL accounts below.`; + this.newRole = { label: '', description: '', usage: 'LIABILITY' }; + this.showAddRole = false; + }); + } + + /** + * Removes a head and everything mapped to it. Confirmed first: the figures it contributed leave + * the return with it, so a return filed after this will not agree with one filed before. + */ + deleteRole(role: any): void { + if (!confirm(`Remove "${this.roleLabel(role)}" and its mapped accounts? Returns will stop counting it.`)) { + return; + } + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .deleteRbiReserveRole(role.roleId) + .pipe( + catchError((err: any) => { + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not remove this head.'; + return of(null); + }) + ) + .subscribe((res: any) => { + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = 'Head removed.'; + }); + } + + /** How a head is consumed, in words, for the screen to state next to it. */ + usageLabel(usage: string): string { + return this.roleUsages.find((u) => u.value === usage)?.label ?? usage; + } +} diff --git a/src/app/organization/tds-config/tds-config.component.html b/src/app/organization/tds-config/tds-config.component.html new file mode 100644 index 000000000..ce237b314 --- /dev/null +++ b/src/app/organization/tds-config/tds-config.component.html @@ -0,0 +1,216 @@ + + +
+
+ +

TDS Configuration

+
+

+ Thresholds, rates and statutory references for TDS on deposit interest, per financial year, and the GL account + deducted tax is credited to. The TDS reports, the Form 121 register and each customer's Form 121 tab compute from + these figures only — a year not set up here is a year they refuse to compute. +

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

GL account

+

+ Only postable liability accounts are listed. Nothing is preselected: the right account differs per chart of + accounts. +

+ + @for (role of glRoles; track role.configKey) { +
+
+
+ {{ role.label }} + {{ role.configKey }} +
+ @if (role.glAccountId) { + {{ role.glCode }} · {{ role.glAccountName }} + } @else { + Not mapped + } +
+

{{ role.description }}

+
+ + GL account + + — Not mapped — + @for (o of glAccountOptions; track o.id) { + {{ o.glCode }} · {{ o.name }} + } + + + +
+
+ } +
+ + +
+

Financial years

+

+ One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep quoting + that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date. +

+ +
+ @for (y of years; track y.fy) { + + } + @if (isNew) { + + } @else { + + } +
+ + @if (draft) { + @if (draft.seedDefault && !isNew) { +
+ These figures were carried over from the values previously built into the application. Check them against + the Finance Act for FY {{ draft.fy }} and save to confirm. +
+ } + + @if (isNew) { +
+ + Financial year + + @if (!fyValid) { + Format: 2027-28 + } @else if (fyTaken) { + FY {{ draft.fy }} is already configured + } + +
+ } + +

Thresholds & rates

+
+ @for (f of rateFields; track f.key) { + + {{ f.label }} + + @if (f.hint) { + {{ f.hint }} + } + + } +
+ @if (noPanBelowRate) { +
The rate without PAN cannot be lower than the rate with PAN.
+ } + +

Declaration (Form 121 / 15G / 15H)

+
+ @for (f of declarationFields; track f.key) { + + {{ f.label }} + @if (f.kind === 'select') { + + @for (o of f.options; track o.value) { + {{ o.label }} + } + + } @else { + + } + @if (f.hint) { + {{ f.hint }} + } + + } +
+ +

Statutory references

+

Printed on the reports, certificates and returns for this year.

+
+ @for (f of statuteFields; track f.key) { + + {{ f.label }} + @if (f.kind === 'select') { + + @for (o of f.options; track o.value) { + {{ o.label }} + } + + } @else { + + } + + } +
+ +
+ + @if (isNew) { + + } @else if (years.length > 1) { + + } + @if (draft.updatedOn && !isNew) { + Last saved {{ draft.updatedOn | slice: 0 : 16 }} + } +
+ } @else { +
+ No financial year is configured. Every TDS screen is unable to compute until one is added. +
+ } +
+ } +
diff --git a/src/app/organization/tds-config/tds-config.component.scss b/src/app/organization/tds-config/tds-config.component.scss new file mode 100644 index 000000000..a6c0adc41 --- /dev/null +++ b/src/app/organization/tds-config/tds-config.component.scss @@ -0,0 +1,34 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* Layout on top of the RBI reserve configuration styles this screen shares. */ + +.tdc-section { + margin: 20px 0 10px; + font-size: 14px; + font-weight: 600; +} + +.tdc-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 14px 16px; + margin-bottom: 8px; +} + +.tdc-gl { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + + &__field { + flex: 1 1 320px; + max-width: 520px; + } +} diff --git a/src/app/organization/tds-config/tds-config.component.ts b/src/app/organization/tds-config/tds-config.component.ts new file mode 100644 index 000000000..91de87e37 --- /dev/null +++ b/src/app/organization/tds-config/tds-config.component.ts @@ -0,0 +1,275 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { catchError, forkJoin, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { + TdsConfigResponse, + TdsConfigService, + TdsFyConfigRecord, + TdsGlRole +} from 'app/letter-mgmt/certificates/form-15gh/tds-config.service'; + +/** One editable field of a financial year, as the form lays it out. */ +interface FieldDef { + key: keyof TdsFyConfigRecord; + label: string; + kind: 'amount' | 'percent' | 'int' | 'text' | 'select'; + hint?: string; + options?: { value: string; label: string }[]; +} + +/** + * TDS on deposit interest: the thresholds, rates and statutory references for each financial year, + * and the GL account deducted tax is credited to. + * + * The TDS reports, the Form 121 register and the customer's Form 121 tab all compute from what is + * saved here - nothing about the tax is compiled into the screens. A year missing here is a year + * those screens refuse to compute. + */ +@Component({ + selector: 'mifosx-tds-config', + templateUrl: './tds-config.component.html', + styleUrls: ['../rbi-reserve-config/rbi-reserve-config.component.scss', './tds-config.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class TdsConfigComponent implements OnInit { + private tdsConfig = inject(TdsConfigService); + + loading = true; + saving = false; + savingGl = false; + errorMsg = ''; + successMsg = ''; + + years: TdsFyConfigRecord[] = []; + glRoles: TdsGlRole[] = []; + glAccountOptions: { id: number; name: string; glCode: string }[] = []; + + /** Year on screen, as a working copy - committed on Save. */ + draft: TdsFyConfigRecord | null = null; + /** True while the draft is a year not yet saved. */ + isNew = false; + newFy = ''; + + /** Draft GL selection per role. */ + glSelection: Record = {}; + + readonly rateFields: FieldDef[] = [ + { key: 'thresholdGeneral', label: 'Threshold – general (₹)', kind: 'amount', hint: 'Payee below 60' }, + { key: 'thresholdSenior', label: 'Threshold – senior citizen (₹)', kind: 'amount', hint: '60 at any time in the year' }, + { key: 'ratePanPercentage', label: 'Rate – valid PAN (%)', kind: 'percent' }, + { key: 'rateNoPanPercentage', label: 'Rate – PAN missing / invalid (%)', kind: 'percent' }, + { key: 'lateDeductionPercentage', label: 'Late deduction interest (% per month)', kind: 'percent' }, + { key: 'lateDepositPercentage', label: 'Late deposit interest (% per month)', kind: 'percent' }, + { key: 'roundTo', label: 'Round TDS to nearest (₹)', kind: 'int', hint: '1 = nearest rupee' } + ]; + + readonly declarationFields: FieldDef[] = [ + { + key: 'declarationForm', + label: 'Declaration form', + kind: 'select', + options: [ + { value: '121', label: 'Form 121' }, + { value: '15G', label: 'Form 15G / 15H' } + ] + }, + { key: 'basicExemption', label: 'Basic exemption limit (₹)', kind: 'amount' }, + { key: 'nilTaxCeiling', label: 'Nil-tax income ceiling (₹)', kind: 'amount' }, + { + key: 'declarationStatementFrequency', + label: 'Declaration statement', + kind: 'select', + options: [ + { value: 'MONTHLY', label: 'Monthly' }, + { value: 'QUARTERLY', label: 'Quarterly' } + ] + }, + { key: 'declarationStatementDueDay', label: 'Statement due on day', kind: 'int', hint: 'Of the following month / quarter' } + ]; + + readonly statuteFields: FieldDef[] = [ + { + key: 'regime', + label: 'Governing Act', + kind: 'select', + options: [ + { value: 'ITA_2025', label: 'Income Tax Act, 2025' }, + { value: 'ITA_1961', label: 'Income-tax Act, 1961' } + ] + }, + { key: 'interestTdsSection', label: 'Section charging TDS on interest', kind: 'text' }, + { key: 'thresholdSection', label: 'Threshold section', kind: 'text' }, + { key: 'noPanSection', label: 'No-PAN higher rate section', kind: 'text' }, + { key: 'defaultSection', label: 'Default / late interest section', kind: 'text' }, + { key: 'declarationSection', label: 'Declaration section', kind: 'text' }, + { key: 'declarationRule', label: 'Declaration rule', kind: 'text' }, + { key: 'tdsStatementForm', label: 'Quarterly TDS return', kind: 'text' }, + { key: 'tdsStatementRule', label: 'TDS return rule', kind: 'text' }, + { key: 'certificateForm', label: 'TDS certificate', kind: 'text' } + ]; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + forkJoin({ + config: this.tdsConfig.load().pipe(catchError(() => of(null))), + template: this.tdsConfig.template().pipe(catchError(() => of(null))) + }).subscribe(({ config, template }) => { + this.loading = false; + if (!config) { + this.errorMsg = 'Could not load TDS configuration.'; + return; + } + this.glAccountOptions = template?.glAccountOptions || []; + this.apply(config, this.draft?.fy); + }); + } + + private apply(config: TdsConfigResponse, keepFy?: string): void { + this.years = config.years || []; + this.glRoles = config.glRoles || []; + this.glSelection = {}; + for (const role of this.glRoles) { + this.glSelection[role.configKey] = role.glAccountId; + } + const target = this.years.find((y) => y.fy === keepFy) ?? this.years[0] ?? null; + this.select(target); + } + + select(year: TdsFyConfigRecord | null): void { + this.draft = year ? { ...year } : null; + this.isNew = false; + } + + get unmappedGl(): TdsGlRole[] { + return this.glRoles.filter((r) => !r.glAccountId); + } + + /** The year after the latest configured one, e.g. '2027-28' after '2026-27'. */ + private nextFy(): string { + const latest = this.years[0]?.fy; + const start = latest ? Number(latest.slice(0, 4)) + 1 : new Date().getFullYear(); + return `${start}-${String(start + 1).slice(2)}`; + } + + /** + * Starts a new year from the latest one's figures. The law rarely changes all of them at once, so + * copying is the common case; nothing is saved until the administrator reviews and saves it. + */ + startNewYear(): void { + const base = this.years[0]; + this.newFy = this.nextFy(); + this.draft = base + ? { ...base, id: undefined, fy: this.newFy, seedDefault: false, updatedOn: null } + : ({ + fy: this.newFy, + regime: 'ITA_2025', + declarationForm: '121', + declarationStatementFrequency: 'MONTHLY', + roundTo: 1 + } as TdsFyConfigRecord); + this.isNew = true; + this.successMsg = ''; + } + + cancelNew(): void { + this.select(this.years[0] ?? null); + } + + get fyValid(): boolean { + const m = /^(\d{4})-(\d{2})$/.exec(this.draft?.fy ?? ''); + return !!m && (Number(m[1]) + 1) % 100 === Number(m[2]); + } + + get fyTaken(): boolean { + return this.isNew && this.years.some((y) => y.fy === this.draft?.fy); + } + + /** The no-PAN rate is a penalty rate; below the ordinary rate it would reward a missing PAN. */ + get noPanBelowRate(): boolean { + return !!this.draft && Number(this.draft.rateNoPanPercentage) < Number(this.draft.ratePanPercentage); + } + + get canSave(): boolean { + return !!this.draft && this.fyValid && !this.fyTaken && !this.noPanBelowRate && !this.saving; + } + + save(): void { + if (!this.draft || !this.canSave) { + return; + } + this.saving = true; + this.errorMsg = ''; + this.successMsg = ''; + const fy = this.draft.fy; + this.tdsConfig.saveYear(this.draft).subscribe({ + next: (res) => { + this.saving = false; + this.apply(res, fy); + this.successMsg = `FY ${fy} saved. Every TDS screen now computes with these figures.`; + }, + error: (err) => { + this.saving = false; + this.errorMsg = this.messageOf(err, `Could not save FY ${fy}.`); + } + }); + } + + deleteYear(): void { + if (!this.draft || this.isNew) { + return; + } + const fy = this.draft.fy; + if (!confirm(`Remove the TDS configuration for FY ${fy}? TDS screens will stop computing that year.`)) { + return; + } + this.tdsConfig.deleteYear(fy).subscribe({ + next: (res) => { + this.apply(res); + this.successMsg = `FY ${fy} removed.`; + }, + error: (err) => (this.errorMsg = this.messageOf(err, `Could not remove FY ${fy}.`)) + }); + } + + saveGl(role: TdsGlRole): void { + this.savingGl = true; + this.errorMsg = ''; + this.successMsg = ''; + this.tdsConfig.saveGl(role.configKey, this.glSelection[role.configKey] ?? null).subscribe({ + next: (res) => { + this.savingGl = false; + this.apply(res, this.draft?.fy); + this.successMsg = `${role.label} GL saved.`; + }, + error: (err) => { + this.savingGl = false; + this.errorMsg = this.messageOf(err, `Could not save ${role.label} GL.`); + } + }); + } + + isGlDirty(role: TdsGlRole): boolean { + return (this.glSelection[role.configKey] ?? null) !== (role.glAccountId ?? null); + } + + private messageOf(err: any, fallback: string): string { + return err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || fallback; + } +} diff --git a/src/app/products/current-products/create-current-product/create-current-product.component.html b/src/app/products/current-products/create-current-product/create-current-product.component.html index 6662df7d6..a1ca46345 100644 --- a/src/app/products/current-products/create-current-product/create-current-product.component.html +++ b/src/app/products/current-products/create-current-product/create-current-product.component.html @@ -50,6 +50,7 @@
diff --git a/src/app/products/current-products/current-product-stepper/current-product-accounting-step/current-product-accounting-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-accounting-step/current-product-accounting-step.component.ts index 252629bc5..7f1b8a8e0 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-accounting-step/current-product-accounting-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-accounting-step/current-product-accounting-step.component.ts @@ -127,15 +127,11 @@ export class CurrentProductAccountingStepComponent implements OnInit { private patchAccountingValues(mappings: any) { this.CurrentProductAccountingForm.patchValue({ - savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '', savingsControlAccountId: mappings.savingsControlAccount?.id || '', - transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '', - interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '', incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '', incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '', incomeFromInterestId: mappings.incomeFromInterest?.id || '', overdraftPortfolioControlId: mappings.overdraftPortfolioControl?.id || '', - writeOffAccountId: mappings.writeOffAccount?.id || '', feesReceivableAccountId: mappings.feeReceivableAccount?.id || '', penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '', interestPayableAccountId: mappings.interestPayableAccount?.id || '', @@ -267,14 +263,16 @@ export class CurrentProductAccountingStepComponent implements OnInit { }; if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) { - addControl('savingsReferenceAccountId', true); + // A current account pays no interest, has no write-off and does not use a transfers suspense account, so + // Interest On Savings, Write Off and Transfers In Suspense are not mapped. The keys stay savings-named + // because the backend's GL mapping reads them under those names. + // No Current Reference either: a current account's cash side posts to the branch cash GL (Financial + // Activity: Cash at Main Vault / Teller), the same one the cashier screen uses. + removeControl('savingsReferenceAccountId'); addControl('savingsControlAccountId', true); - addControl('transfersInSuspenseAccountId', true); - addControl('interestOnSavingsAccountId', true); addControl('incomeFromFeeAccountId', true); addControl('incomeFromPenaltyAccountId', true); addControl('incomeFromInterestId', true); - addControl('writeOffAccountId', true); addControl('overdraftPortfolioControlId', false); if (this.accounting.isAccrualAccountingRuleId(accountingRule)) { @@ -297,12 +295,9 @@ export class CurrentProductAccountingStepComponent implements OnInit { } else { removeControl('savingsReferenceAccountId'); removeControl('savingsControlAccountId'); - removeControl('transfersInSuspenseAccountId'); - removeControl('interestOnSavingsAccountId'); removeControl('incomeFromFeeAccountId'); removeControl('incomeFromPenaltyAccountId'); removeControl('incomeFromInterestId'); - removeControl('writeOffAccountId'); removeControl('overdraftPortfolioControlId'); removeControl('feesReceivableAccountId'); removeControl('penaltiesReceivableAccountId'); @@ -345,14 +340,6 @@ export class CurrentProductAccountingStepComponent implements OnInit { pillLabel: 'Asset', pillClass: 'green', rows: [ - { - key: 'savingsReferenceAccountId', - label: 'Savings Reference Account', - inputLabel: 'Savings reference account', - controlName: 'savingsReferenceAccountId', - options: this.assetAccountOptions, - required: true - }, { key: 'overdraftPortfolioControlId', label: 'Overdraft Portfolio Control', @@ -396,20 +383,12 @@ export class CurrentProductAccountingStepComponent implements OnInit { rows: [ { key: 'savingsControlAccountId', - label: 'Savings Control Account', - inputLabel: 'Savings control account', + label: 'Current Control Account', + inputLabel: 'Current control account', controlName: 'savingsControlAccountId', options: this.liabilityAccountOptions, required: true }, - { - key: 'transfersInSuspenseAccountId', - label: 'Transfers In Suspense Account', - inputLabel: 'Transfers in suspense account', - controlName: 'transfersInSuspenseAccountId', - options: this.liabilityAccountOptions, - required: true - }, { key: 'interestPayableAccountId', label: 'Interest Payable Account', @@ -430,32 +409,8 @@ export class CurrentProductAccountingStepComponent implements OnInit { otherMappingsControlName: 'liabilitiesOtherGlMappings', otherAccountOptions: this.liabilityAccountOptions }, - { - key: 'expenses', - title: 'Expenses', - pillLabel: 'Expense', - pillClass: 'amber', - rows: [ - { - key: 'interestOnSavingsAccountId', - label: 'Interest On Savings Account', - inputLabel: 'Interest on savings account', - controlName: 'interestOnSavingsAccountId', - options: this.expenseAccountOptions, - required: true - }, - { - key: 'writeOffAccountId', - label: 'Write Off Account', - inputLabel: 'Write off account', - controlName: 'writeOffAccountId', - options: this.expenseAccountOptions, - required: true - } - ], - otherMappingsControlName: 'expensesOtherGlMappings', - otherAccountOptions: this.expenseAccountOptions - }, + // No Expenses section: a current account pays no interest and has no write-off, so it maps no expense GL. + // The expensesOtherGlMappings control is kept, so an older product's saved expense mappings survive an edit. { key: 'income', title: 'Income', diff --git a/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html index c74008227..f01407d85 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html @@ -162,29 +162,16 @@ }}
+
+ Days in Year: + {{ CurrentProduct.interestCalculationDaysInYearType }} Days +
+
Auto Interest Post: {{ CurrentProduct.autoPost | yesNo }}
- @if ( - CurrentProduct.frequencyFixDay !== undefined && - CurrentProduct.frequencyFixDay !== null && - CurrentProduct.frequencyFixDay !== '' - ) { -
- Frequency (Fix Day): - {{ CurrentProduct.frequencyFixDay }} Days -
- } - - @if (CurrentProduct.lastInterestDate) { -
- Last Interest Date: - {{ CurrentProduct.lastInterestDate | dateFormat }} -
- } -

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

@@ -282,39 +269,58 @@
@if (CurrentProduct.allowOverdraft) { - @if (CurrentProduct.avgBalanceCalcPeriod) { -
- Avg Balance Calculation Period: - {{ CurrentProduct.avgBalanceCalcPeriod }} -
- } +
+ Maximum OD Limit: + ₹ {{ CurrentProduct.overdraftLimit | formatNumber }} +
- @if (CurrentProduct.validForPeriod) { + @if (CurrentProduct.odMinLimit) {
- Valid for Period: - {{ CurrentProduct.validForPeriod }} -
- } - - @if (CurrentProduct.overdraftLimit !== undefined && CurrentProduct.overdraftLimit !== '') { -
- OD Limit %: - {{ CurrentProduct.overdraftLimit }} -
- } - - @if ( - CurrentProduct.nominalAnnualInterestRateOverdraft !== undefined && - CurrentProduct.nominalAnnualInterestRateOverdraft !== '' - ) { -
- Interest on OD %: - {{ CurrentProduct.nominalAnnualInterestRateOverdraft }} + Minimum OD Limit: + ₹ {{ CurrentProduct.odMinLimit | formatNumber }}
}
- OD Account Closed with Zero Balance in Day Closing: + OD Security Type: + {{ CurrentProduct.odSecurityType | titlecase }} +
+ +
+ Sanction Validity: + {{ CurrentProduct.validForPeriod }} +
+ +
+ Excess Drawing Allowed: + {{ CurrentProduct.odExcessAllowed | yesNo }} +
+ + @if (CurrentProduct.odExcessAllowed) { +
+ Maximum Excess: + {{ CurrentProduct.odExcessLimitPercent }} % of limit +
+
+ Penal Charge on Excess: + {{ CurrentProduct.odPenalRate }} % p.a. +
+ } + +
+ OD Interest Rate: + {{ CurrentProduct.nominalAnnualInterestRateOverdraft }} % p.a. +
+ + @if (CurrentProduct.minOverdraftForInterestCalculation) { +
+ Minimum OD for Interest: + ₹ {{ CurrentProduct.minOverdraftForInterestCalculation | formatNumber }} +
+ } + +
+ End OD Once Fully Repaid: {{ CurrentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}
} @@ -453,15 +459,6 @@
Assets
-
- Savings reference account - {{ - formatGlAccountById( - CurrentProduct.savingsReferenceAccountId, - CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
Overdraft portfolio control {{ @@ -503,7 +500,7 @@
Liabilities
- Savings control account + Current control account {{ formatGlAccountById( CurrentProduct.savingsControlAccountId, @@ -511,15 +508,6 @@ ) }}
-
- Transfers in suspense account - {{ - formatGlAccountById( - CurrentProduct.transfersInSuspenseAccountId, - CurrentProductsTemplate?.accountingMappingOptions.liabilityAccountOptions - ) - }} -
Interest payable account {{ @@ -542,28 +530,6 @@ }
-
-
Expenses
-
- Interest on savings account - {{ - formatGlAccountById( - CurrentProduct.interestOnSavingsAccountId, - CurrentProductsTemplate?.accountingMappingOptions.expenseAccountOptions - ) - }} -
-
- Write off account - {{ - formatGlAccountById( - CurrentProduct.writeOffAccountId, - CurrentProductsTemplate?.accountingMappingOptions.expenseAccountOptions - ) - }} -
-
-
Income
diff --git a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.html index 11467b21d..1bc750acb 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.html @@ -110,72 +110,126 @@ Overdraft (OD) Facility
+ + @if (!CurrentProductSettingsForm.value.allowOverdraft) { +

+ Overdraft is not allowed on this product. Tick "Is Overdraft Allowed" on the Terms tab to set the OD limits. +

+ }
-
- - Is Overdraft Allowed - -
@if (CurrentProductSettingsForm.value.allowOverdraft) {
- OD Account Closed with Zero Balance in Day Closing + End OD Once Fully Repaid (One-time OD only)

- Automatically closes OD accounts when balance reaches zero during day-end closing. + At day-end, withdraws the OD sanction when the debit balance is cleared to zero. Only the OD ends, not + the account. Leave off for a regular OD: a customer who uses it and repays the same day would lose it.

}
- + @if (CurrentProductSettingsForm.value.allowOverdraft) {
-
- Average Balance Calculation in Period - - 6M - 1Y - 3Y - -
- -
- Valid for Period - - 3M - 1Y - 3Y - -
-
- OD Limit % + Maximum OD Limit (₹) + Highest per account + Enter the maximum OD limit - Interest on OD % - + Minimum OD Limit (₹) + + Lowest per account + @if (CurrentProductSettingsForm.get('odMinLimit')?.hasError('exceedsMax')) { + Cannot exceed the maximum OD limit + } @else { + Cannot be negative + }
+ +
+ OD Security Type + + @for (option of odSecurityTypeOptions; track option.value) { + {{ option.label }} + } + +

{{ selectedSecurityTypeHint }}

+
+ +
+ Sanction Validity + + @for (option of odValidityOptions; track option.value) { + {{ option.label }} + } + +
+ +
+ Excess Drawing (Over Limit) + + Allow withdrawal beyond the sanctioned limit + + @if (!CurrentProductSettingsForm.value.odExcessAllowed) { +

Off: a withdrawal that would cross the limit is refused.

+ } @else { +
+ + Maximum Excess (% of limit) + + Beyond this, refused + Enter above 0% and up to 25% + + + + Penal Charge on Excess (% p.a.) + + Only on the excess amount + Enter above 0% and up to 50% + +
+ @if (excessExample) { +

{{ excessExample }}

+ } + } +
+ +

+ Each account is sanctioned its own limit within this range, valid for the period above. The OD interest + rate is set on the Terms tab. +

}
diff --git a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts index de2e04d2d..da3435ff5 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts @@ -7,7 +7,7 @@ */ import { Component, OnInit, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { AbstractControl, UntypedFormGroup, UntypedFormBuilder, ValidationErrors, Validators } from '@angular/forms'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -40,6 +40,21 @@ export class CurrentProductSettingsStepComponent implements OnInit { { id: 'YEARS', value: 'value.years' } ]; + /** How long an OD sanction on this product runs before it must be renewed. */ + odValidityOptions = [ + { value: '3M', label: '3 Months' }, + { value: '6M', label: '6 Months' }, + { value: '1Y', label: '1 Year' }, + { value: '2Y', label: '2 Years' }, + { value: '3Y', label: '3 Years' } + ]; + + odSecurityTypeOptions = [ + { value: 'CLEAN', label: 'Clean', hint: 'No security needed' }, + { value: 'SECURED', label: 'Secured', hint: 'Against FD lien, property, etc.' }, + { value: 'BOTH', label: 'Both', hint: 'Decided at each sanction' } + ]; + constructor() { this.createCurrentProductSettingsForm(); this.setConditionalControls(); @@ -72,14 +87,15 @@ export class CurrentProductSettingsStepComponent implements OnInit { daysToEscheat: isEditing ? this.CurrentProductsTemplate.daysToEscheat || DORMANCY_DEFAULTS.daysToEscheat : DORMANCY_DEFAULTS.daysToEscheat, - // Overdraft fields + // Overdraft scheme (the OD interest rate lives on the Terms tab) allowOverdraft: isEditing ? (this.CurrentProductsTemplate.allowOverdraft ?? false) : false, overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '', - nominalAnnualInterestRateOverdraft: isEditing - ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft - : '', - avgBalanceCalcPeriod: isEditing ? this.CurrentProductsTemplate.avgBalanceCalcPeriod || '1Y' : '1Y', - validForPeriod: isEditing ? this.CurrentProductsTemplate.validForPeriod || '1Y' : '1Y', + odMinLimit: isEditing ? this.CurrentProductsTemplate.odMinLimit : '', + odSecurityType: isEditing ? this.CurrentProductsTemplate.odSecurityType || 'CLEAN' : 'CLEAN', + validForPeriod: this.validityOrDefault(isEditing ? this.CurrentProductsTemplate.validForPeriod : null), + odExcessAllowed: isEditing ? !!this.CurrentProductsTemplate.odExcessAllowed : false, + odExcessLimitPercent: isEditing ? this.CurrentProductsTemplate.odExcessLimitPercent : '', + odPenalRate: isEditing ? this.CurrentProductsTemplate.odPenalRate : '', odAccountClosedZeroBalanceInDayClosing: isEditing ? (this.CurrentProductsTemplate.odAccountClosedZeroBalanceInDayClosing ?? false) : false @@ -123,23 +139,123 @@ export class CurrentProductSettingsStepComponent implements OnInit { daysToInactive: [DORMANCY_DEFAULTS.daysToInactive], daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy], daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat], - // Overdraft (OD) Facility + // Overdraft (OD) Facility: the scheme an account's OD sanction is held to allowOverdraft: [false], - overdraftLimit: [ - '', - [Validators.min(0)] - ], - nominalAnnualInterestRateOverdraft: [ - '', - [Validators.min(0)] - ], - avgBalanceCalcPeriod: ['1Y'], + overdraftLimit: [''], + odMinLimit: [''], + odSecurityType: ['CLEAN'], validForPeriod: ['1Y'], + // Excess drawing past the sanctioned limit: off means it is refused + odExcessAllowed: [false], + odExcessLimitPercent: [''], + odPenalRate: [''], odAccountClosedZeroBalanceInDayClosing: [false] }); } + get selectedSecurityTypeHint(): string { + const selected = this.CurrentProductSettingsForm.get('odSecurityType')?.value; + return this.odSecurityTypeOptions.find((option) => option.value === selected)?.hint ?? ''; + } + + /** A worked example on the maximum limit, so the percentages read as rupees. */ + get excessExample(): string { + const value = this.CurrentProductSettingsForm.value; + const limit = Number(value.overdraftLimit); + const percent = Number(value.odExcessLimitPercent); + const penal = Number(value.odPenalRate); + if (!limit || !percent) return ''; + const excess = (limit * percent) / 100; + const inr = (n: number) => '₹' + n.toLocaleString('en-IN', { maximumFractionDigits: 2 }); + let text = `On a ${inr(limit)} limit, up to ${inr(excess)} more may be drawn.`; + if (penal) { + text += ` If ${inr(excess)} stays over the limit for a day, the penal charge is ${inr((excess * penal) / 100 / 365)}.`; + } + return text; + } + + private validityOrDefault(value: string | null): string { + return this.odValidityOptions.some((option) => option.value === value) ? value : '1Y'; + } + + /** The minimum sanction may not exceed the maximum. */ + private odMinWithinMax = (control: AbstractControl): ValidationErrors | null => { + const max = Number(this.CurrentProductSettingsForm?.get('overdraftLimit')?.value); + const min = Number(control.value); + if (control.value === '' || control.value === null || !max) return null; + return min > max ? { exceedsMax: true } : null; + }; + + private applyOverdraftValidators(allowed: boolean) { + const max = this.CurrentProductSettingsForm.get('overdraftLimit'); + const min = this.CurrentProductSettingsForm.get('odMinLimit'); + const security = this.CurrentProductSettingsForm.get('odSecurityType'); + const validity = this.CurrentProductSettingsForm.get('validForPeriod'); + if (allowed) { + max.setValidators([ + Validators.required, + Validators.min(1) + ]); + min.setValidators([ + Validators.min(0), + this.odMinWithinMax + ]); + security.setValidators([Validators.required]); + validity.setValidators([Validators.required]); + } else { + [ + max, + min, + security, + validity + ].forEach((control) => control.clearValidators()); + } + [ + max, + min, + security, + validity + ].forEach((control) => control.updateValueAndValidity({ emitEvent: false })); + this.applyExcessValidators(); + } + + /** The excess cap and penal rate are required only while both OD and excess drawing are on. */ + private applyExcessValidators() { + const form = this.CurrentProductSettingsForm; + const required = !!form.get('allowOverdraft').value && !!form.get('odExcessAllowed').value; + const percent = form.get('odExcessLimitPercent'); + const penal = form.get('odPenalRate'); + percent.setValidators( + required + ? [ + Validators.required, + Validators.min(0.01), + Validators.max(25) + ] + : [] + ); + penal.setValidators( + required + ? [ + Validators.required, + Validators.min(0.01), + Validators.max(50) + ] + : [] + ); + percent.updateValueAndValidity({ emitEvent: false }); + penal.updateValueAndValidity({ emitEvent: false }); + } + setConditionalControls() { + this.CurrentProductSettingsForm.get('allowOverdraft').valueChanges.subscribe((allowed: boolean) => + this.applyOverdraftValidators(!!allowed) + ); + this.CurrentProductSettingsForm.get('odExcessAllowed').valueChanges.subscribe(() => this.applyExcessValidators()); + this.CurrentProductSettingsForm.get('overdraftLimit').valueChanges.subscribe(() => + this.CurrentProductSettingsForm.get('odMinLimit').updateValueAndValidity({ emitEvent: false }) + ); + this.CurrentProductSettingsForm.get('enableLockinPeriod').valueChanges.subscribe((enabled: boolean) => { const freq = this.CurrentProductSettingsForm.get('lockinPeriodFrequency'); const type = this.CurrentProductSettingsForm.get('lockinPeriodFrequencyType'); @@ -168,10 +284,15 @@ export class CurrentProductSettingsStepComponent implements OnInit { if (!formValue.allowOverdraft) { delete formValue.overdraftLimit; - delete formValue.nominalAnnualInterestRateOverdraft; - delete formValue.avgBalanceCalcPeriod; + delete formValue.odMinLimit; + delete formValue.odSecurityType; delete formValue.validForPeriod; delete formValue.odAccountClosedZeroBalanceInDayClosing; + delete formValue.odExcessAllowed; + } + if (!formValue.allowOverdraft || !formValue.odExcessAllowed) { + delete formValue.odExcessLimitPercent; + delete formValue.odPenalRate; } return formValue; diff --git a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html index f2350d95a..910e31c4a 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html @@ -13,20 +13,76 @@ Interest On OD
- - Interest Posting Frequency * - - @for (item of interestPostingPeriodTypeData; track item) { - - {{ item.value | translateKey: 'catalogs' }} - - } - - - Interest Posting Frequency {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - + @if (allowOverdraftControl) { +
+ + Is Overdraft Allowed + +
+ } + @if (!isOverdraftAllowed) { +
+ ℹ️ + Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab. +
+ } + + - - - Frequency (Fix Day) - - Days - - - - Last Interest Date - - - -
-
- ⚠️ - Daily product-based interest accrual enabled — interest calculated EOD on daily closing balance. -
+ @if (isOverdraftAllowed) { +
+ ℹ️ + + Rule: daily OD interest = end-of-day debit balance × rate ÷ days in year, applied to the account at the + posting frequency. + +
+ }
diff --git a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts index aaab2e0aa..4decaae32 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts @@ -7,12 +7,21 @@ */ import { Component, OnInit, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { AbstractControl, UntypedFormControl, UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { Dates } from 'app/core/utils/dates'; -import { SettingsService } from 'app/settings/settings.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +/** Posting-period ids an OD's interest may be applied on: RBI requires monthly for advances; quarterly is the outer limit. */ +const OD_POSTING_PERIOD_IDS = [ + 4, + 5 +]; + +/** + * Interest on OD: the rate and rules an overdraft on this product is charged by. Interest is worked out daily on the + * end-of-day debit balance and applied to the account at the posting frequency. The fields apply only when the + * Settings tab allows an overdraft, so allowOverdraft (that tab's control) switches their validators on and off. + */ @Component({ selector: 'mifosx-current-product-terms-step', templateUrl: './current-product-terms-step.component.html', @@ -24,14 +33,18 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; }) export class CurrentProductTermsStepComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); - private dateUtils = inject(Dates); - private settingsService = inject(SettingsService); @Input() CurrentProductsTemplate: any; + /** The Settings tab's allowOverdraft control. */ + @Input() allowOverdraft: AbstractControl; CurrentProductTermsForm: UntypedFormGroup; interestPostingPeriodTypeData: any[] = []; + daysInYearData: Array<{ id: number; label: string }> = [ + { id: 365, label: '365 Days (Actual)' }, + { id: 360, label: '360 Days' } + ]; yesNoOptions: Array<{ value: boolean; label: string }> = [ { value: true, label: 'Yes' }, { value: false, label: 'No' } @@ -41,29 +54,59 @@ export class CurrentProductTermsStepComponent implements OnInit { this.createCurrentProductTermsForm(); } + get isOverdraftAllowed(): boolean { + return !!this.allowOverdraft?.value; + } + + /** + * The Settings tab's own control, so the box can be ticked here without going to that tab first; the two + * checkboxes are one control and always agree. + */ + get allowOverdraftControl(): UntypedFormControl | null { + return this.allowOverdraft instanceof UntypedFormControl ? this.allowOverdraft : null; + } + ngOnInit() { - this.interestPostingPeriodTypeData = this.CurrentProductsTemplate.interestPostingPeriodTypeOptions || []; + const postingOptions: any[] = this.CurrentProductsTemplate.interestPostingPeriodTypeOptions || []; + const odPostingOptions = postingOptions.filter((option: any) => OD_POSTING_PERIOD_IDS.includes(option.id)); + this.interestPostingPeriodTypeData = odPostingOptions.length ? odPostingOptions : postingOptions; this.setYesNoOptions(this.CurrentProductsTemplate?.yesNoOptions); const isEditing = !!this.CurrentProductsTemplate.id; + const defaultPosting = + this.interestPostingPeriodTypeData.find((option: any) => option.id === 4)?.id ?? + this.interestPostingPeriodTypeData[0]?.id ?? + ''; + const storedPosting = this.CurrentProductsTemplate.interestPostingPeriodType?.id; + const storedDaysInYear = this.CurrentProductsTemplate.interestCalculationDaysInYearType?.id; + const storedPostingOffered = this.interestPostingPeriodTypeData.some((option: any) => option.id === storedPosting); this.CurrentProductTermsForm.patchValue({ - interestPostingPeriodType: this.CurrentProductsTemplate.interestPostingPeriodType?.id || '', - autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true, - frequencyFixDay: isEditing ? (this.CurrentProductsTemplate.frequencyFixDay ?? 30) : 30, - lastInterestDate: this.parseDate(this.CurrentProductsTemplate.lastInterestDate) + interestPostingPeriodType: storedPostingOffered ? storedPosting : defaultPosting, + interestCalculationDaysInYearType: storedDaysInYear === 360 ? 360 : 365, + nominalAnnualInterestRateOverdraft: isEditing ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft : '', + minOverdraftForInterestCalculation: isEditing ? this.CurrentProductsTemplate.minOverdraftForInterestCalculation : '', + autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true }); + + // The Settings tab patches allowOverdraft in its own ngOnInit, after this one, so follow its changes as well as + // its current value. + this.applyOverdraftValidators(this.isOverdraftAllowed); + this.allowOverdraft?.valueChanges.subscribe((allowed: boolean) => this.applyOverdraftValidators(!!allowed)); } - private parseDate(dateValue: any): Date | null { - if (!dateValue) return null; - if (Array.isArray(dateValue)) { - return new Date(dateValue[0], dateValue[1] - 1, dateValue[2]); - } - if (dateValue instanceof Date) { - return dateValue; - } - return new Date(dateValue); + private applyOverdraftValidators(allowed: boolean) { + const rate = this.CurrentProductTermsForm.get('nominalAnnualInterestRateOverdraft'); + rate.setValidators( + allowed + ? [ + Validators.required, + Validators.min(0.01), + Validators.max(50) + ] + : [Validators.min(0)] + ); + rate.updateValueAndValidity({ emitEvent: false }); } private setYesNoOptions(options: any[]) { @@ -90,23 +133,25 @@ export class CurrentProductTermsStepComponent implements OnInit { '', Validators.required ], - autoPost: [true], - frequencyFixDay: [ - 30, + interestCalculationDaysInYearType: [365], + nominalAnnualInterestRateOverdraft: [ + '', [Validators.min(0)] ], - lastInterestDate: [''] + minOverdraftForInterestCalculation: [ + '', + [Validators.min(0)] + ], + autoPost: [true] }); } get CurrentProductTerms() { const formValue = { ...this.CurrentProductTermsForm.value }; - const dateFormat = this.settingsService.dateFormat; - - if (formValue.lastInterestDate instanceof Date) { - formValue.lastInterestDate = this.dateUtils.formatDate(formValue.lastInterestDate, dateFormat) || ''; + if (!this.isOverdraftAllowed) { + delete formValue.nominalAnnualInterestRateOverdraft; + delete formValue.minOverdraftForInterestCalculation; } - - return { ...formValue }; + return formValue; } } diff --git a/src/app/products/current-products/edit-current-product/edit-current-product.component.html b/src/app/products/current-products/edit-current-product/edit-current-product.component.html index 699f0bfce..8cd4d990e 100644 --- a/src/app/products/current-products/edit-current-product/edit-current-product.component.html +++ b/src/app/products/current-products/edit-current-product/edit-current-product.component.html @@ -50,6 +50,7 @@
diff --git a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html index c2904dd05..f68cdbf07 100644 --- a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html +++ b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html @@ -257,16 +257,10 @@
} - @if (currentProduct.frequencyFixDay) { + @if (currentProduct.interestCalculationDaysInYearType?.id) {
- Frequency Fix Day - {{ currentProduct.frequencyFixDay }} -
- } - @if (currentProduct.lastInterestDate) { -
- Last Interest Date - {{ currentProduct.lastInterestDate | dateFormat }} + Days in Year + {{ currentProduct.interestCalculationDaysInYearType.id }} Days
}
@@ -396,37 +390,57 @@ {{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }} - {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }} + ₹ {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }}
} @if (currentProduct.nominalAnnualInterestRateOverdraft) {
{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }} - {{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % + {{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % p.a.
} @if (currentProduct.overdraftLimit) {
{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }} - {{ currentProduct.overdraftLimit | formatNumber }} + ₹ {{ currentProduct.overdraftLimit | formatNumber }}
} @if (currentProduct.allowOverdraft) { - @if (currentProduct.avgBalanceCalcPeriod) { + @if (currentProduct.odMinLimit) {
- Average Balance Calculation Period - {{ currentProduct.avgBalanceCalcPeriod }} + Minimum OD Limit + ₹ {{ currentProduct.odMinLimit | formatNumber }} +
+ } + @if (currentProduct.odSecurityType) { +
+ OD Security Type + {{ currentProduct.odSecurityType | titlecase }}
} @if (currentProduct.validForPeriod) {
- Valid for Period + Sanction Validity {{ currentProduct.validForPeriod }}
} +
+ Excess Drawing Allowed + {{ currentProduct.odExcessAllowed | yesNo }} +
+ @if (currentProduct.odExcessAllowed) { +
+ Maximum Excess + {{ currentProduct.odExcessLimitPercent | formatNumber }} % of limit +
+
+ Penal Charge on Excess + {{ currentProduct.odPenalRate | formatNumber }} % p.a. +
+ } @if (currentProduct.odAccountClosedZeroBalanceInDayClosing !== undefined) {
- OD Account Closed with Zero Balance in Day Closing + End OD Once Fully Repaid {{ currentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}
} @@ -588,10 +602,34 @@

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

- + +
+
+ Type + {{ currentProduct.accountingRule?.value || 'Cash' }} +
+
+ Cash GL (deposit / withdrawal) + Branch cash GL — Financial Activity: Cash at Main Vault / Teller +
+ @for (gl of currentAccountingRows; track gl.label) { +
+ {{ gl.label }} + + @if (gl.account) { + ({{ gl.account.glCode }}) {{ gl.account.name }} + } @else { + — + } + +
+ } +
diff --git a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts index 40c634e85..3b9f19df2 100644 --- a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts +++ b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts @@ -22,7 +22,6 @@ import { MatRowDef, MatRow } from '@angular/material/table'; -import { ViewSavingsAccountingDetailsComponent } from '../../../../shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; import { YesnoPipe } from '../../../../pipes/yesno.pipe'; @@ -47,7 +46,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatHeaderRow, MatRowDef, MatRow, - ViewSavingsAccountingDetailsComponent, DateFormatPipe, FormatNumberPipe, YesnoPipe @@ -102,4 +100,20 @@ export class CurrentProductGeneralTabComponent { isCashOrAccrualAccounting(): boolean { return this.accounting.isCashOrAccrualAccounting(this.currentProduct.accountingRule); } + + /** The GLs a current product maps, by the names the Accounting tab gives them. */ + get currentAccountingRows(): Array<{ label: string; account: any }> { + const m = this.currentProduct?.accountingMappings || {}; + const rows = [ + { label: 'Current Control (Liability)', account: m.savingsControlAccount }, + { label: 'Overdraft Portfolio Control (Asset)', account: m.overdraftPortfolioControl }, + { label: 'Income From Fees', account: m.incomeFromFeeAccount }, + { label: 'Income From Penalties', account: m.incomeFromPenaltyAccount }, + { label: 'Income From Interest', account: m.incomeFromInterest } + ]; + if (this.currentProduct?.isDormancyTrackingActive) { + rows.push({ label: 'Escheat Liability', account: m.escheatLiabilityAccount }); + } + return rows; + } } diff --git a/src/app/rbi-circulars/oss-live/oss-return.base.ts b/src/app/rbi-circulars/oss-live/oss-return.base.ts new file mode 100644 index 000000000..3ed065606 --- /dev/null +++ b/src/app/rbi-circulars/oss-live/oss-return.base.ts @@ -0,0 +1,426 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { inject } from '@angular/core'; +import { Observable, catchError, of } from 'rxjs'; +import * as ExcelJS from 'exceljs'; +import { RbiCircularsService } from '../rbi-circulars.service'; +import { + addBanner, + boxAllCells, + downloadWorkbook, + newWorkbook, + solidFill, + XL +} from '../oss-workbook'; + +/** One GL account behind a return line. */ +export interface OssAccount { + glAccountId: number; + glCode: string; + name: string; + /** Null when the account is pinned but not countable — see `excluded`. */ + balance: number | null; + /** Set when the account is still mapped but left out: disabled, or a header. */ + excluded?: 'disabled' | 'header'; +} + +/** One line of a return, as the backend computes it. */ +export interface OssLine { + label: string; + /** Null on derived rows and headcounts — they are not mapped to accounts. */ + roleKey: string | null; + configured: boolean; + /** Null when the role has no accounts mapped. Never 0 in that case. */ + amount: number | null; + accounts: OssAccount[]; + /** Derived row, summed from the roles it names. */ + subtotal?: boolean; + /** Headcount rather than a rupee amount. */ + count?: boolean; + /** Roles a subtotal is summed from. */ + components?: string[]; +} + +/** Which sheet of the return is on screen. */ +export type OssTab = 'general' | 'figures' | 'signatory'; + +/** A `label -> value` row on the General Information sheet. */ +export interface OssGeneralInfoRow { + label: string; + value: string; + /** False when the particular is not maintained in the institution profile. */ + configured: boolean; +} + +export interface OssSignatory { + heading: string; + name: string; + designation: string; + email: string; + telephoneOffice: string; + place: string; + date: string; +} + +/** The whole return, as the backend computes it. */ +export interface OssReturn { + returnCode: string; + returnName: string; + reportingFrequency: string; + generalInfo: OssGeneralInfoRow[]; + quarter: { label: string; startDate: string; endDate: string }; + officeId: number | null; + officeName: string; + signatories: OssSignatory[]; + basis: { asOnDate?: string; fromDate?: string; toDate?: string; note: string }; + computable: boolean; + unmetRequirements: string[]; + // OSS 1 only. + liabilities?: OssLine[]; + assets?: OssLine[]; + totalLiabilities?: number; + totalAssets?: number; + balanced?: boolean; + imbalance?: number; + // OSS 2 only. + earnings?: OssLine[]; + expenses?: OssLine[]; + totalIncome?: number; + totalExpenditure?: number; + operatingProfit?: number; +} + +/** + * Shared behaviour for the live OSS returns — OSS 1 (RAL) and OSS 2 (RoE). + * + * Nothing is computed in the browser. Every figure, and the bank's own particulars, come from one + * endpoint that reads them from the ledger and the institution profile; re-deriving anything here + * would produce a second set of numbers that could disagree with the filed return. + */ +export abstract class OssReturnBase { + protected rbiService = inject(RbiCircularsService); + + /** Which return to fetch. */ + protected abstract fetch(quarterEndDate: string): Observable; + + /** Filename stem for the Excel export. */ + protected abstract readonly exportStem: string; + + /** Where this return's own GL roles are configured. */ + abstract readonly configRoute: string; + abstract readonly configLinkLabel: string; + + /** 'OSS 1' / 'OSS 2', for the heading. */ + abstract readonly returnShortName: string; + + /** + * The one fact a filer needs before reading any figure — whether the return can be filed as it + * stands. Rendered as HTML so a figure inside it can be emphasised. + */ + abstract get headline(): string; + + /** Whether the headline states a problem, which decides how the banner reads. */ + abstract get headlineIsBad(): boolean; + + /** Sheets of the return, in the order RBI prints them. */ + readonly tabs: { id: OssTab; label: string; icon: string }[] = [ + { id: 'general', label: 'General Information', icon: 'info-circle' }, + { id: 'figures', label: 'Figures', icon: 'university' }, + { id: 'signatory', label: 'Signatory', icon: 'check-circle' } + ]; + + activeTab: OssTab = 'general'; + + showTab(tab: OssTab): void { + this.activeTab = tab; + } + + /** Bank particulars for the header, read back from the General Information sheet. */ + private info(label: string): string { + return this.data?.generalInfo.find((g) => g.label === label)?.value ?? ''; + } + + get bankName(): string { + return this.info('Bank Name'); + } + + get bankCode(): string { + return this.info('Bank Code'); + } + + /** The signatory block as caption/value pairs, in the order every OSS return prints them. */ + signatoryFields(signatory: OssSignatory): { caption: string; value: string }[] { + return [ + { caption: 'Name', value: signatory.name }, + { caption: 'Designation', value: signatory.designation }, + { caption: 'e-mail ID', value: signatory.email }, + { caption: 'Tele. No. (O)', value: signatory.telephoneOffice }, + { caption: 'Place', value: signatory.place }, + { caption: 'Date', value: signatory.date } + ]; + } + + data: OssReturn | null = null; + loading = true; + errorMsg = ''; + generating = false; + + /** Any date in the quarter being reported; the backend resolves it to that quarter. */ + quarterDate = new Date().toISOString().slice(0, 10); + + toastMsg = ''; + toastVisible = false; + private toastTimer: any; + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.fetch(this.quarterDate) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute this return from the ledger.'; + return of(null); + }) + ) + .subscribe((res: OssReturn | null) => { + this.loading = false; + this.data = res; + }); + } + + onDateChange(): void { + this.load(); + } + + /** Jump to the quarter containing today. */ + resetToToday(): void { + this.quarterDate = new Date().toISOString().slice(0, 10); + this.load(); + } + + // ── presentation helpers ── + + amount(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + count(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return value.toLocaleString('en-IN'); + } + + protected toast(msg: string): void { + this.toastMsg = msg; + this.toastVisible = true; + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); + } + + // ── Excel export ── + + /** + * Exports the return as it stands: the same figures the screen shows, the basis, and the + * account-level breakdown, so a reviewer can trace any total back to the accounts behind it. + */ + async exportWorkbook(): Promise { + if (!this.data) { + return; + } + this.generating = true; + try { + const wb = newWorkbook(); + this.buildGeneralInfoSheet(wb); + this.buildFiguresSheet(wb); + this.buildSignatorySheet(wb); + + await downloadWorkbook(wb, `${this.exportStem}_${this.data.quarter.endDate}.xlsx`); + this.toast('Workbook downloaded.'); + } finally { + this.generating = false; + } + } + + private buildGeneralInfoSheet(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('General Information'); + sheet.columns = [ + { width: 34 }, + { width: 52 }]; + addBanner(sheet, 'General Information', 'B'); + + const start = sheet.rowCount + 1; + for (const row of this.data!.generalInfo) { + const r = sheet.addRow([ + row.label, + row.value + ]); + r.getCell(1).font = { bold: true }; + r.getCell(1).fill = solidFill(XL.keyCell); + // A particular that is not maintained is flagged rather than left looking filled in. + if (!row.configured) { + r.getCell(2).font = { italic: true, color: { argb: 'FFB06000' } }; + } + } + const scope = sheet.addRow([ + 'Office scope', + this.data!.officeName + ]); + scope.getCell(1).font = { bold: true }; + scope.getCell(1).fill = solidFill(XL.keyCell); + boxAllCells(sheet, start, sheet.rowCount, 2); + } + + /** Each section of the return, with its account-level breakdown under every mapped line. */ + protected abstract sections(): { title: string; unit: string; lines: OssLine[] }[]; + + /** Derived totals printed under the figures, in the order the return states them. */ + protected abstract totals(): { label: string; value: number | null | undefined }[]; + + private buildFiguresSheet(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('Figures'); + sheet.columns = [ + { width: 66 }, + { width: 22 }]; + addBanner(sheet, `${this.data!.returnName} — ${this.data!.quarter.label}`, 'B'); + + const noteRow = sheet.addRow([this.data!.basis.note]); + sheet.mergeCells(noteRow.number, 1, noteRow.number, 2); + noteRow.alignment = { wrapText: true }; + noteRow.font = { size: 9, italic: true }; + sheet.addRow([]); + + for (const section of this.sections()) { + const start = sheet.rowCount + 1; + const head = sheet.addRow([ + section.title, + section.unit + ]); + head.font = { bold: true }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + for (const line of section.lines) { + const row = sheet.addRow([ + line.label, + line.configured ? line.amount : 'Not configured' + ]); + row.getCell(1).alignment = { wrapText: true }; + row.getCell(2).numFmt = line.count ? '#,##0' : '#,##0.00'; + row.getCell(2).alignment = { horizontal: 'right' }; + if (line.subtotal) { + row.font = { bold: true }; + row.getCell(2).fill = solidFill(XL.derivedBlue); + } + + // An excluded account is named with its reason rather than dropped: the workbook is what + // gets filed, so it must not read as complete when the figure is short by it. + for (const account of line.accounts) { + const sub = sheet.addRow([ + ` ${account.glCode} — ${account.name}${account.excluded ? ` (excluded — ${account.excluded})` : ''}`, + account.excluded ? 'Excluded' : account.balance + ]); + sub.font = { size: 9, color: { argb: account.excluded ? 'FFB06000' : 'FF666666' } }; + sub.getCell(2).numFmt = '#,##0.00'; + sub.getCell(2).alignment = { horizontal: 'right' }; + } + } + boxAllCells(sheet, start, sheet.rowCount, 2); + sheet.addRow([]); + } + + const totalsStart = sheet.rowCount + 1; + for (const total of this.totals()) { + const row = sheet.addRow([ + total.label, + total.value ?? null + ]); + row.font = { bold: true }; + row.getCell(2).numFmt = '#,##0.00'; + row.getCell(2).alignment = { horizontal: 'right' }; + row.getCell(2).fill = solidFill(XL.derivedBlue); + } + boxAllCells(sheet, totalsStart, sheet.rowCount, 2); + + // Caveats travel with the workbook, not only the screen: a return with unmapped roles is + // arithmetically right for what is mapped and still must not be filed as complete. + if (this.data!.unmetRequirements.length) { + sheet.addRow([]); + for (const reason of this.data!.unmetRequirements) { + const row = sheet.addRow([`Check before filing: ${reason}`]); + sheet.mergeCells(row.number, 1, row.number, 2); + row.font = { bold: true, size: 9 }; + row.alignment = { wrapText: true }; + } + } + + sheet.views = [{ state: 'frozen', ySplit: 3 }]; + } + + private buildSignatorySheet(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('Signatory'); + sheet.columns = [ + { width: 26 }, + { width: 52 }]; + addBanner(sheet, 'Signatory Information', 'B'); + + for (const signatory of this.data!.signatories) { + const start = sheet.rowCount + 1; + const heading = sheet.addRow([signatory.heading]); + sheet.mergeCells(heading.number, 1, heading.number, 2); + heading.font = { bold: true }; + heading.getCell(1).fill = solidFill(XL.headerGrey); + + for (const [ + caption, + value + ] of [ + [ + 'Name', + signatory.name + ], + [ + 'Designation', + signatory.designation + ], + [ + 'e-mail ID', + signatory.email + ], + [ + 'Tele. No. (O)', + signatory.telephoneOffice + ], + [ + 'Place', + signatory.place + ], + [ + 'Date', + signatory.date + ] + ]) { + const row = sheet.addRow([ + caption, + value || 'Not set in Institution Profile' + ]); + row.getCell(1).font = { bold: true }; + row.getCell(1).fill = solidFill(XL.keyCell); + if (!value) { + row.getCell(2).font = { italic: true, color: { argb: 'FFB06000' } }; + } + } + boxAllCells(sheet, start, sheet.rowCount, 2); + sheet.addRow([]); + } + } +} diff --git a/src/app/rbi-circulars/oss-live/oss-return.component.html b/src/app/rbi-circulars/oss-live/oss-return.component.html new file mode 100644 index 000000000..e087afcb7 --- /dev/null +++ b/src/app/rbi-circulars/oss-live/oss-return.component.html @@ -0,0 +1,200 @@ + + +
+ @if (loading) { +
Computing from the ledger…
+ } @else if (errorMsg) { +
+ + {{ errorMsg }} +
+ } @else if (data) { + +
+
+ +
+

{{ returnShortName }} — {{ data.returnName }}

+
+ {{ bankName }} · Bank Code {{ bankCode }} · Return Code + {{ data.returnCode }} · Quarter ending {{ data.quarter.endDate }} +
+
+
+
+ {{ data.quarter.label }} + + + +
+
+ + +
+ + +
+ + +
+ @for (t of tabs; track t.id) { + + } +
+ + + @if (data.unmetRequirements.length) { +
+ } + + + @if (activeTab === 'general') { +
+
General Information
+
+ + + @for (item of data.generalInfo; track item.label) { + + + + + } + + + + + +
{{ item.label }}{{ item.value }}
Office scope{{ data.officeName }}
+
+

{{ data.basis.note }}

+
+ } + + + @if (activeTab === 'figures') { +
+ @for (section of sections(); track section.title) { +
{{ section.title }}
+
+ + + + + + + + + @for (line of section.lines; track line.label) { + + + + + + @for (account of line.accounts; track account.glAccountId) { + + + + + } + } + + @for (total of totals(); track total.label) { + + + + + } + +
{{ section.title }}{{ section.unit }}
{{ line.label }} + @if (!line.configured) { + Not configured + } @else if (line.count) { + {{ count(line.amount) }} + } @else { + {{ amount(line.amount) }} + } +
+ {{ account.glCode }} + {{ account.name }} + @if (account.excluded) { + excluded — {{ account.excluded }} + } + {{ amount(account.balance) }}
{{ total.label }}{{ amount(total.value) }}
+
+ } +
+ } + + + @if (activeTab === 'signatory') { +
+
Signatory Information
+ @for (signatory of data.signatories; track signatory.heading) { +
+
{{ signatory.heading }}
+ + + @for (field of signatoryFields(signatory); track field.caption) { + + + + + } + +
{{ field.caption }} + {{ field.value || 'Not set in Institution Profile' }} +
+
+ } +
+ } + } + + @if (toastVisible) { +
{{ toastMsg }}
+ } +
diff --git a/src/app/rbi-circulars/oss-live/oss1-live.component.ts b/src/app/rbi-circulars/oss-live/oss1-live.component.ts new file mode 100644 index 000000000..c7275be99 --- /dev/null +++ b/src/app/rbi-circulars/oss-live/oss1-live.component.ts @@ -0,0 +1,106 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Observable } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OssLine, OssReturnBase } from './oss-return.base'; + +/** + * OSS 1 — Return on Assets and Liabilities (RAL), return code R070. + * + * The bank's balance sheet restated on RBI's line structure, every figure read from the ledger as + * at the quarter end. Liabilities must equal assets; where they do not, the return says so rather + * than hiding the difference, because an imbalance is a real condition of the books that the filer + * has to see before filing. + */ +@Component({ + selector: 'mifosx-oss1-live', + templateUrl: './oss-return.component.html', + styleUrls: ['../oss-return.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + FormsModule + ] +}) +export class Oss1LiveComponent extends OssReturnBase implements OnInit { + protected readonly exportStem = 'RBI_OSS1_RAL'; + readonly configRoute = '/organization/oss1-gl-config'; + readonly configLinkLabel = 'Open OSS 1 GL Configuration'; + readonly returnShortName = 'OSS 1'; + + /** + * Whether the two sides balance — the first thing to check on a balance sheet, and the reason + * the difference is stated rather than absorbed. + */ + get headlineIsBad(): boolean { + return this.data?.balanced === false; + } + + get headline(): string { + if (!this.data) { + return ''; + } + const liabilities = this.amount(this.data.totalLiabilities); + const assets = this.amount(this.data.totalAssets); + if (!this.headlineIsBad) { + return ( + `Total Liabilities ₹${liabilities} · ` + + `Total Assets ₹${assets} · the two sides agree` + ); + } + return ( + `Total Liabilities ₹${liabilities} · ` + + `Total Assets ₹${assets} · ` + + `OUT OF BALANCE by ₹${this.amount(Math.abs(this.data.imbalance ?? 0))} — ` + + 'resolve before filing' + ); + } + + protected fetch(quarterEndDate: string): Observable { + return this.rbiService.getOss1(quarterEndDate); + } + + ngOnInit(): void { + this.load(); + } + + protected sections(): { title: string; unit: string; lines: OssLine[] }[] { + if (!this.data) { + return []; + } + return [ + { title: 'Liabilities', unit: 'Rs in Thousands', lines: this.data.liabilities ?? [] }, + { title: 'Assets', unit: 'Rs in Thousands', lines: this.data.assets ?? [] } + ]; + } + + protected totals(): { label: string; value: number | null | undefined }[] { + if (!this.data) { + return []; + } + const totals: { label: string; value: number | null | undefined }[] = [ + { label: 'Total Liabilities', value: this.data.totalLiabilities }, + { label: 'Total Assets', value: this.data.totalAssets } + ]; + // Stated only when the two sides disagree: printing a nil difference on every balanced return + // would train a reviewer to skip the line that matters when it is not nil. + if (this.data.balanced === false) { + totals.push({ label: 'Difference (Assets − Liabilities)', value: this.data.imbalance }); + } + return totals; + } + + /** True when the two sides of the balance sheet disagree. */ + get isImbalanced(): boolean { + return this.data?.balanced === false; + } +} diff --git a/src/app/rbi-circulars/oss-live/oss2-live.component.ts b/src/app/rbi-circulars/oss-live/oss2-live.component.ts new file mode 100644 index 000000000..7e3ba8f90 --- /dev/null +++ b/src/app/rbi-circulars/oss-live/oss2-live.component.ts @@ -0,0 +1,88 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Observable } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OssLine, OssReturnBase } from './oss-return.base'; + +/** + * OSS 2 — Return on Earnings (RoE), return code R071. + * + * The bank's profit and loss account on RBI's line structure. Unlike OSS 1 these lines report the + * movement over the quarter rather than a closing balance: the same accounts read as balances + * would carry forward every earlier quarter's income as well. + */ +@Component({ + selector: 'mifosx-oss2-live', + templateUrl: './oss-return.component.html', + styleUrls: ['../oss-return.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + FormsModule + ] +}) +export class Oss2LiveComponent extends OssReturnBase implements OnInit { + protected readonly exportStem = 'RBI_OSS2_RoE'; + readonly configRoute = '/organization/oss2-gl-config'; + readonly configLinkLabel = 'Open OSS 2 GL Configuration'; + readonly returnShortName = 'OSS 2'; + + /** A loss is worth flagging; it is a result to explain, not merely a number to read. */ + get headlineIsBad(): boolean { + return (this.data?.operatingProfit ?? 0) < 0; + } + + get headline(): string { + if (!this.data) { + return ''; + } + const result = this.data.operatingProfit ?? 0; + return ( + `Total Income ₹${this.amount(this.data.totalIncome)} · ` + + `Total Expenditure ₹${this.amount(this.data.totalExpenditure)} · ` + + `${result >= 0 ? 'Operating Profit' : 'Operating Loss'} ` + + `₹${this.amount(Math.abs(result))} for the quarter` + ); + } + + protected fetch(quarterEndDate: string): Observable { + return this.rbiService.getOss2(quarterEndDate); + } + + ngOnInit(): void { + this.load(); + } + + protected sections(): { title: string; unit: string; lines: OssLine[] }[] { + if (!this.data) { + return []; + } + return [ + { title: 'Earnings', unit: 'Rs in Thousands', lines: this.data.earnings ?? [] }, + { title: 'Expenditure', unit: 'Rs in Thousands', lines: this.data.expenses ?? [] } + ]; + } + + protected totals(): { label: string; value: number | null | undefined }[] { + if (!this.data) { + return []; + } + return [ + { label: 'Total Income', value: this.data.totalIncome }, + { label: 'Total Expenditure', value: this.data.totalExpenditure }, + { + label: (this.data.operatingProfit ?? 0) >= 0 ? 'Operating Profit' : 'Operating Loss', + value: this.data.operatingProfit + } + ]; + } +} diff --git a/src/app/rbi-circulars/oss-return.scss b/src/app/rbi-circulars/oss-return.scss index ade2b179c..5b4f2989b 100644 --- a/src/app/rbi-circulars/oss-return.scss +++ b/src/app/rbi-circulars/oss-return.scss @@ -567,3 +567,92 @@ td.gl-diff { grid-template-columns: 1fr; } } + +/* ── Live OSS returns (OSS 1, OSS 2) ──────────────────────────────────────── + These read their figures from the ledger rather than from a filed workbook, so they carry two + things a static return does not: a note on which accounts a line came from, and a statement of + what is not mapped. */ + +/* Caveats about the return as a whole: lines with nothing behind them. Sits above the sheets + because a blank line is a fact about the return, not about the sheet it happens to be on. */ +.oss-caveat { + background: var(--o-amber-l); + border: 1px solid #e3d27a; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 14px; + color: var(--o-amber-d); + font-size: 12.5px; +} + +.oss-caveat-h { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.oss-caveat-lead { + margin: 6px 0 0; + line-height: 1.6; + opacity: 0.9; +} + +.oss-caveat-list { + margin: 8px 0 8px 18px; + padding: 0; + + li { + margin-bottom: 4px; + } +} + +.oss-caveat-link { + display: inline-block; + margin-top: 4px; + color: var(--o-blue); + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +/* A figure the return could not produce, or an account left out of one. Marked rather than merely + blank, so it reads as deliberately absent instead of as a nil the bank asserted. */ +.oss-unset { + color: var(--o-amber-d); + font-style: italic; + font-weight: 500; +} + +/* The GL accounts behind a line, indented under it. */ +.oss-subline { + td { + font-size: 11.5px; + color: var(--o-g500); + } + + &--excluded td { + color: var(--o-amber-d); + } +} + +.oss-gl-code { + font-family: 'Roboto Mono', monospace; + font-size: 10.5px; + background: var(--o-g50); + border: 1px solid var(--o-g200); + border-radius: 4px; + padding: 1px 5px; + margin-right: 6px; +} + +/* How the figures on a sheet were arrived at — the dates they were read on. */ +.oss-note { + margin: 10px 2px 0; + font-size: 11.5px; + color: var(--o-g500); + line-height: 1.6; +} diff --git a/src/app/rbi-circulars/rbi-circulars-routing.module.ts b/src/app/rbi-circulars/rbi-circulars-routing.module.ts index 045ce0f06..791827a72 100644 --- a/src/app/rbi-circulars/rbi-circulars-routing.module.ts +++ b/src/app/rbi-circulars/rbi-circulars-routing.module.ts @@ -26,16 +26,30 @@ const routes: Routes = [ data: { title: 'AML Rules Master', breadcrumb: 'AML Rules Master' } }, { + // OSS 1 computed live from the ledger. Lazy — the ExcelJS workbook builder should not weigh + // down the eager bundle. path: 'rbi/oss1', - // Lazy — the ExcelJS workbook builder should not weigh down the eager bundle. - loadComponent: () => import('./oss1-ral/oss1-ral.component').then((m) => m.Oss1RalComponent), + loadComponent: () => import('./oss-live/oss1-live.component').then((m) => m.Oss1LiveComponent), data: { title: 'OSS 1 — Return on Assets and Liabilities', breadcrumb: 'OSS 1 (RAL)' } }, { path: 'rbi/oss2', - loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent), + loadComponent: () => import('./oss-live/oss2-live.component').then((m) => m.Oss2LiveComponent), data: { title: 'OSS 2 — Return on Earnings', breadcrumb: 'OSS 2 (RoE)' } }, + { + // The filed workbooks as they stood before the returns were computed from the ledger. Kept + // reachable so a figure on a live return can be checked against what was actually filed for + // an earlier quarter; they read from static data and do not move. + path: 'rbi/oss1-filed', + loadComponent: () => import('./oss1-ral/oss1-ral.component').then((m) => m.Oss1RalComponent), + data: { title: 'OSS 1 — As filed (static)', breadcrumb: 'OSS 1 (as filed)' } + }, + { + path: 'rbi/oss2-filed', + loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent), + data: { title: 'OSS 2 — As filed (static)', breadcrumb: 'OSS 2 (as filed)' } + }, { path: 'rbi/share-capital', // Lazy for the same reason as the OSS returns — the ExcelJS workbook builder should not @@ -48,6 +62,18 @@ const routes: Routes = [ path: 'rbi/oss3', loadComponent: () => import('./oss3-raq/oss3-raq.component').then((m) => m.Oss3RaqComponent), data: { title: 'OSS 3 — Return on Asset Quality', breadcrumb: 'OSS 3 (RAQ-UCB)' } + }, + { + // CRR return, filed twice a month. Lazy like the other returns — it pulls in ExcelJS. + path: 'rbi/form1', + loadComponent: () => import('./reserve-return/form1-crr.component').then((m) => m.Form1CrrComponent), + data: { title: 'Form 1 — Cash Reserve Ratio', breadcrumb: 'Form 1 (CRR)' } + }, + { + // SLR return, filed at the end of each month. + path: 'rbi/form9', + loadComponent: () => import('./reserve-return/form9-slr.component').then((m) => m.Form9SlrComponent), + data: { title: 'Form 9 — Statutory Liquidity Ratio', breadcrumb: 'Form 9 (SLR)' } } ]) ]; diff --git a/src/app/rbi-circulars/rbi-circulars.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts index 33446834c..6558ba987 100644 --- a/src/app/rbi-circulars/rbi-circulars.service.ts +++ b/src/app/rbi-circulars/rbi-circulars.service.ts @@ -60,4 +60,75 @@ export class RbiCircularsService { getShareCapitalReturnYears(): Observable { return this.http.get('/shareproducts/capital-return/financial-years'); } + + /** + * Form 1 — CRR, computed live from the ledger for the fortnight the reporting date falls in. + * + * The DTL base and the balances maintained are read as at different dates: the base lags by 45 + * days (snapped back to a reporting Friday), the holdings are as at the reporting date itself. + * The response's `basis` block states both, so the lag is auditable rather than implied. + * + * @param reportingDate yyyy-MM-dd. Defaults to the current business date on the backend. + * @param officeId Optional — scopes every figure to one office. + */ + getForm1(reportingDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (reportingDate) { + params = params.set('reportingDate', reportingDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http.get('/rbireservereturn/form1', { params }); + } + + /** + * Form 9 — SLR, computed live on the month-end position. Any date within a month resolves to that + * month's end, since the return reports the position at close. + */ + getForm9(reportingDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (reportingDate) { + params = params.set('reportingDate', reportingDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http.get('/rbireservereturn/form9', { params }); + } + + /** + * OSS 1 — Return on Assets and Liabilities, computed live from the ledger. + * + * Every figure is a closing balance as at the quarter end; the bank's own particulars come from + * the institution profile. Any date inside a quarter resolves to that quarter, so a caller never + * has to know which day a quarter ends on. + * + * @param quarterEndDate yyyy-MM-dd — any date in the quarter. Defaults to the business date. + * @param officeId Optional — scopes every figure to one office. + */ + getOss1(quarterEndDate?: string, officeId?: number): Observable { + return this.http.get('/ossreturn/oss1', { params: this.ossParams(quarterEndDate, officeId) }); + } + + /** + * OSS 2 — Return on Earnings, computed live from the ledger. + * + * Earnings lines report the movement over the quarter rather than a closing balance, so the same + * accounts read as balances would carry forward every earlier quarter's income as well. + */ + getOss2(quarterEndDate?: string, officeId?: number): Observable { + return this.http.get('/ossreturn/oss2', { params: this.ossParams(quarterEndDate, officeId) }); + } + + private ossParams(quarterEndDate?: string, officeId?: number): HttpParams { + let params = new HttpParams(); + if (quarterEndDate) { + params = params.set('quarterEndDate', quarterEndDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return params; + } } diff --git a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts new file mode 100644 index 000000000..2db6c5c69 --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts @@ -0,0 +1,41 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ReserveReturnBase } from './reserve-return.base'; + +/** + * RBI Form 1 — Cash Reserve Ratio, filed twice a month. + * + * The return compares the CRR required on an earlier fortnight's Demand and Time Liabilities + * against the cash and bank balances held today. The two sides are deliberately read on different + * dates — see the basis panel on the screen, which states both. + */ +@Component({ + selector: 'mifosx-form1-crr', + templateUrl: './reserve-return.component.html', + styleUrls: ['./reserve-return.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class Form1CrrComponent extends ReserveReturnBase implements OnInit { + protected readonly exportStem = 'RBI_Form1_CRR'; + + protected fetch(reportingDate: string): Observable { + return this.rbiService.getForm1(reportingDate); + } + + ngOnInit(): void { + this.load(); + } +} diff --git a/src/app/rbi-circulars/reserve-return/form9-slr.component.ts b/src/app/rbi-circulars/reserve-return/form9-slr.component.ts new file mode 100644 index 000000000..4dee07ffe --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/form9-slr.component.ts @@ -0,0 +1,41 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ReserveReturnBase } from './reserve-return.base'; + +/** + * RBI Form 9 — Statutory Liquidity Ratio, filed at the end of the month. + * + * Unlike Form 1 there is no lag: both the liability base and the SLR-eligible investments are read + * as at month end, so the return states the position actually held at close. Any date the user + * picks resolves to that month's end. + */ +@Component({ + selector: 'mifosx-form9-slr', + templateUrl: './reserve-return.component.html', + styleUrls: ['./reserve-return.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class Form9SlrComponent extends ReserveReturnBase implements OnInit { + protected readonly exportStem = 'RBI_Form9_SLR'; + + protected fetch(reportingDate: string): Observable { + return this.rbiService.getForm9(reportingDate); + } + + ngOnInit(): void { + this.load(); + } +} diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts new file mode 100644 index 000000000..65642760c --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts @@ -0,0 +1,474 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { inject } from '@angular/core'; +import { Observable, catchError, of } from 'rxjs'; +import * as ExcelJS from 'exceljs'; +import { RbiCircularsService } from '../rbi-circulars.service'; +import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook'; + +/** One configured figure on the return, with the accounts it was derived from. */ +export interface ReserveLine { + label: string; + configKey: string; + configured: boolean; + asOnDate: string; + amount: number | null; + /** + * The accounts the figure was derived from. `balance` is null and `excluded` set when an account + * is still pinned to the role but no longer countable — disabled, or turned into a header whose + * balance would double-count its children. + */ + accounts: ReserveLineAccount[]; +} + +/** + * One account behind a line. `accountBalance` is what the ledger holds; `balance` is the portion + * this head takes of it. The two differ whenever `weightPercentage` is under 100 — RBI treats part + * of a savings balance as a demand liability and the rest as time, so the same account can appear + * twice under one head with a different share each time. + */ +export interface ReserveLineAccount { + glAccountId: number; + glCode: string; + name: string; + /** The account's own balance, before the head's share is applied. */ + accountBalance?: number | null; + /** The share of that balance this head takes. 100 means the whole of it. */ + weightPercentage?: number | null; + /** DEMAND or TIME on a liability head; null elsewhere. */ + liabilityClass?: string | null; + balance: number | null; + excluded?: 'disabled' | 'header'; +} + +/** One row of the Memorandum or an Annexure — a stated figure, entering no total. */ +export interface SectionRow { + label: string; + configKey: string; + configured: boolean; + amount: number | null; + accounts: { glAccountId: number; glCode: string; name: string; balance: number }[]; +} + +/** + * Compliance across the whole fortnight. + * + * CRR is not met by the closing day's balance: the full requirement must be held on average over the + * fortnight AND a daily minimum on every single day of it, so a bank that ran empty for thirteen days + * and topped up on the last one fails on the daily test while passing on the closing position. + */ +export interface FortnightMaintenance { + days: { date: string; amount: number }[]; + dayCount: number; + average: number; + lowest: number; + lowestOn: string | null; + /** False while the fortnight is still running, so the average is of the days elapsed. */ + fortnightComplete: boolean; + requiredAverage: number; + dailyMinimumPercent: number; + dailyMinimumAmount: number; + averageMet: boolean; + dailyMinimumMet: boolean; + averageShortfall: number; +} + +/** The whole return, as the backend computes it. */ +export interface ReserveReturn { + formCode: string; + formName: string; + frequency: string; + reportingDate: string; + rateType: string; + ratePercentage: number | null; + /** True while the rate is still the placeholder shipped with the installation. */ + rateIsSeedDefault: boolean; + basis: { + lagDays?: number; + rawLagDate?: string; + /** The nominal reporting Friday, before any shift off a holiday. */ + reportingFriday?: string; + dtlAsOnDate: string; + holdingsAsOnDate: string; + /** True when that Friday was a holiday and the position was taken a working day earlier. */ + shiftedForHoliday?: boolean; + note: string; + }; + lines: ReserveLine[]; + /** Form A's Memorandum and its two Annexures — sections of the return, not lines on it. */ + memorandum?: SectionRow[]; + annexureA?: SectionRow[]; + annexureB?: SectionRow[]; + /** Compliance across the fortnight rather than on the reporting date alone. Form 1 only. */ + maintenance?: FortnightMaintenance; + computable: boolean; + dtlAmount?: number; + requiredAmount?: number; + maintainedAmount?: number; + surplusOrShortfall?: number; + maintainedPercentage?: number | null; + compliant?: boolean; + unmetRequirements: string[]; + // Form 1 only. + fortnight?: number; + fortnightLabel?: string; + fortnightStart?: string; + fortnightEnd?: string; + // Form 9 only. + requestedDate?: string; + monthStart?: string; +} + +/** + * Shared behaviour for the two reserve returns — Form 1 (CRR) and Form 9 (SLR). + * + * Both are the same statement: a percentage of Demand and Time Liabilities must be maintained in a + * defined class of assets, and the return compares what is required against what is held. They + * differ only in which figures make up the maintained side and on which dates each side is read, + * so everything except the fetch and the Excel layout lives here. + * + * Nothing is computed in the browser. The figures come from the ledger through one endpoint that + * already aggregates them; re-deriving totals here would produce a second set of numbers that could + * disagree with the filed return. + */ +export abstract class ReserveReturnBase { + protected rbiService = inject(RbiCircularsService); + + /** Which return to fetch. */ + protected abstract fetch(reportingDate: string): Observable; + + /** Filename stem for the Excel export. */ + protected abstract readonly exportStem: string; + + data: ReserveReturn | null = null; + loading = true; + errorMsg = ''; + generating = false; + + /** The reporting date the user is looking at. Editable, so past periods can be reviewed. */ + reportingDate = new Date().toISOString().slice(0, 10); + + toastMsg = ''; + toastVisible = false; + private toastTimer: any; + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.fetch(this.reportingDate) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute this return from the ledger.'; + return of(null); + }) + ) + .subscribe((res: ReserveReturn | null) => { + this.loading = false; + this.data = res; + }); + } + + onDateChange(): void { + this.load(); + } + + /** Jump to the period containing today. */ + resetToToday(): void { + this.reportingDate = new Date().toISOString().slice(0, 10); + this.load(); + } + + // ── presentation helpers ── + + /** + * The titled sections Form A carries alongside the main statement, in the order the format states + * them. Each is skipped by the template when it has no rows, so Form 9 — which has none — is + * unaffected. + */ + get namedSections(): { title: string; hint: string; rows: SectionRow[] }[] { + if (!this.data) { + return []; + } + return [ + { + title: 'Memorandum to Form A', + hint: 'Stated alongside the return. These figures are reported, not added into the CRR requirement.', + rows: this.data.memorandum ?? [] + }, + { + title: 'Annexure A — Foreign currency liabilities and assets', + hint: 'Reported in INR equivalent at the applicable reference rate. Nil where the bank has no foreign-currency business.', + rows: this.data.annexureA ?? [] + }, + { + title: 'Annexure B — Investment details', + hint: 'Investments split between approved and non-approved securities, shares, debentures and bonds.', + rows: this.data.annexureB ?? [] + } + ]; + } + + get isShortfall(): boolean { + return !!this.data?.computable && this.data.compliant === false; + } + + /** Magnitude of the gap, so the sign lives in the label rather than in the number. */ + get shortfallAmount(): number { + return Math.abs(this.data?.surplusOrShortfall ?? 0); + } + + /** + * True when a head takes only part of an account, so the printed figure is not that account's + * balance. Anything at or above 100, or unset, is the whole balance and needs no explanation. + */ + isPartial(account: ReserveLineAccount): boolean { + const weight = account.weightPercentage; + return weight !== null && weight !== undefined && weight < 100 && account.accountBalance != null; + } + + amount(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + percent(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return `${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}%`; + } + + protected toast(msg: string): void { + this.toastMsg = msg; + this.toastVisible = true; + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); + } + + // ── Excel export ── + + /** + * Exports the return as filed: the same figures the screen shows, plus the basis dates and the + * account-level breakdown, so a reviewer can trace any total back to the accounts behind it. + */ + async exportWorkbook(): Promise { + if (!this.data) { + return; + } + this.generating = true; + try { + const wb = newWorkbook(); + const sheet = wb.addWorksheet(this.data.formCode.replace('_', ' ')); + sheet.columns = [ + { width: 52 }, + { width: 22 }, + { width: 20 }]; + + addBanner(sheet, `${this.data.formName} — ${this.data.frequency}`, 'C'); + + let start = sheet.rowCount + 1; + this.addKeyValue(sheet, 'Reporting date', this.data.reportingDate); + if (this.data.fortnightLabel) { + this.addKeyValue(sheet, 'Fortnight', `${this.data.fortnightLabel} (${this.data.fortnightStart} to ${this.data.fortnightEnd})`); + } + this.addKeyValue(sheet, 'DTL base as at', this.data.basis.dtlAsOnDate); + this.addKeyValue(sheet, 'Balances maintained as at', this.data.basis.holdingsAsOnDate); + if (this.data.basis.rawLagDate) { + this.addKeyValue(sheet, `Lag (${this.data.basis.lagDays} days) falls on`, this.data.basis.rawLagDate); + } + this.addKeyValue( + sheet, + `${this.data.rateType} rate applied`, + this.data.ratePercentage === null + ? 'Not set' + : `${this.data.ratePercentage}%${this.data.rateIsSeedDefault ? ' (placeholder — not confirmed)' : ''}` + ); + boxAllCells(sheet, start, sheet.rowCount, 2); + sheet.addRow([]); + + // Basis note, spelled out so the export is self-explanatory away from the screen. + const noteRow = sheet.addRow([this.data.basis.note]); + sheet.mergeCells(noteRow.number, 1, noteRow.number, 3); + noteRow.alignment = { wrapText: true }; + noteRow.font = { size: 9, italic: true }; + sheet.addRow([]); + + // Figures. + start = sheet.rowCount + 1; + const head = sheet.addRow([ + 'Particulars', + 'As at', + 'Amount (INR)' + ]); + head.font = { bold: true }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + for (const line of this.data.lines) { + const row = sheet.addRow([ + line.label, + line.asOnDate, + line.configured ? line.amount : 'Not configured' + ]); + row.getCell(1).alignment = { wrapText: true }; + row.getCell(3).numFmt = '#,##0.00'; + row.getCell(3).alignment = { horizontal: 'right' }; + + // Account-level breakdown, indented under its line. An excluded account is named with its + // reason rather than dropped, so the workbook cannot read as complete when the figure is + // short by it — the file is what gets filed, so the caveat has to travel in it. + for (const account of line.accounts) { + // A partial share is named on the row too: without it the figure reads as the account's + // own balance and cannot be reconciled back to the ledger. + const portion = this.isPartial(account) + ? ` (${account.weightPercentage}% of ${this.amount(account.accountBalance)})` + : ''; + const sub = sheet.addRow([ + ` ${account.glCode} — ${account.name}${portion}${account.excluded ? ` (excluded — ${account.excluded})` : ''}`, + account.liabilityClass ?? '', + account.excluded ? 'Excluded' : account.balance + ]); + sub.font = { size: 9, color: { argb: account.excluded ? 'FFB06000' : 'FF666666' } }; + sub.getCell(3).numFmt = '#,##0.00'; + sub.getCell(3).alignment = { horizontal: 'right' }; + } + } + + if (this.data.computable) { + const required = sheet.addRow([ + `${this.data.rateType} required (${this.data.ratePercentage}% of DTL)`, + '', + this.data.requiredAmount ?? null + ]); + const maintained = sheet.addRow([ + `${this.data.rateType} maintained`, + '', + this.data.maintainedAmount ?? null + ]); + const gap = sheet.addRow([ + this.data.compliant ? 'Surplus' : 'Shortfall', + '', + this.data.surplusOrShortfall ?? null + ]); + for (const row of [ + required, + maintained, + gap + ]) { + row.font = { bold: true }; + row.getCell(3).numFmt = '#,##0.00'; + row.getCell(3).alignment = { horizontal: 'right' }; + row.getCell(3).fill = solidFill(XL.derivedBlue); + } + } + boxAllCells(sheet, start, sheet.rowCount, 3); + + // Fortnight maintenance, which is what CRR compliance actually turns on. A workbook showing + // only the closing position would let a fortnight that breached the daily floor look clean. + if (this.data.maintenance) { + const m = this.data.maintenance; + sheet.addRow([]); + const head = sheet.addRow([ + 'Maintenance over the fortnight', + '', + '' + ]); + head.font = { bold: true }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + const start = sheet.rowCount + 1; + this.addKeyValue(sheet, 'Average maintained', this.amount(m.average)); + this.addKeyValue(sheet, 'Required average', this.amount(m.requiredAverage)); + this.addKeyValue(sheet, 'Average test', m.averageMet ? 'Met' : 'NOT MET'); + this.addKeyValue(sheet, `Daily floor (${m.dailyMinimumPercent}% of requirement)`, this.amount(m.dailyMinimumAmount)); + this.addKeyValue(sheet, 'Lowest day', `${this.amount(m.lowest)} on ${m.lowestOn}`); + this.addKeyValue(sheet, 'Daily minimum test', m.dailyMinimumMet ? 'Met' : 'NOT MET'); + this.addKeyValue(sheet, 'Days counted', `${m.dayCount}${m.fortnightComplete ? ' (full fortnight)' : ' (to reporting date)'}`); + boxAllCells(sheet, start, sheet.rowCount, 2); + + sheet.addRow([]); + const dayHead = sheet.addRow([ + 'Date', + 'Maintained (INR)', + 'Against floor' + ]); + dayHead.font = { bold: true }; + dayHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + const dayStart = sheet.rowCount; + for (const day of m.days) { + const row = sheet.addRow([ + day.date, + day.amount, + day.amount < m.dailyMinimumAmount ? 'Below floor' : 'OK' + ]); + row.getCell(2).numFmt = '#,##0.00'; + row.getCell(2).alignment = { horizontal: 'right' }; + } + boxAllCells(sheet, dayStart, sheet.rowCount, 3); + } + + // The Memorandum and Annexures, each under its own heading. + for (const section of this.namedSections) { + if (!section.rows.length) { + continue; + } + sheet.addRow([]); + const title = sheet.addRow([ + section.title, + '', + '' + ]); + title.font = { bold: true }; + title.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + const start = sheet.rowCount; + for (const row of section.rows) { + const excelRow = sheet.addRow([ + row.label, + '', + row.configured ? row.amount : 'Not configured' + ]); + excelRow.getCell(1).alignment = { wrapText: true }; + excelRow.getCell(3).numFmt = '#,##0.00'; + excelRow.getCell(3).alignment = { horizontal: 'right' }; + } + boxAllCells(sheet, start, sheet.rowCount, 3); + } + + // Caveats travel with the workbook, not only the screen: a return computed on the placeholder + // rate is arithmetically right and still must not be filed, and the file is what gets sent on. + if (this.data.unmetRequirements.length) { + sheet.addRow([]); + const prefix = this.data.computable ? 'Check before filing' : 'Not computed'; + for (const reason of this.data.unmetRequirements) { + const row = sheet.addRow([`${prefix}: ${reason}`]); + sheet.mergeCells(row.number, 1, row.number, 3); + row.font = { bold: true, size: 9 }; + row.alignment = { wrapText: true }; + } + } + + await downloadWorkbook(wb, `${this.exportStem}_${this.data.reportingDate}.xlsx`); + this.toast('Workbook downloaded.'); + } finally { + this.generating = false; + } + } + + private addKeyValue(sheet: ExcelJS.Worksheet, label: string, value: string): void { + const row = sheet.addRow([ + label, + value + ]); + row.getCell(1).font = { bold: true }; + row.getCell(1).fill = solidFill(XL.keyCell); + } +} diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html new file mode 100644 index 000000000..5dcc3af53 --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html @@ -0,0 +1,320 @@ + + +
+ @if (loading) { +
Computing from the ledger…
+ } @else if (errorMsg) { +
{{ errorMsg }}
+ } @else if (data) { + +
+
+

{{ data.formName }}

+ {{ data.frequency }} + @if (data.fortnightLabel) { + + {{ data.fortnightLabel }} · {{ data.fortnightStart }} to {{ data.fortnightEnd }} + + } @else { + Month ended {{ data.reportingDate }} + } +
+ +
+ + Reporting date + + + + +
+
+ + + @if (data.unmetRequirements.length) { +
+ + {{ data.computable ? 'Check this before filing.' : 'This return cannot be computed yet.' }} + +
    + @for (reason of data.unmetRequirements; track reason) { +
  • {{ reason }}
  • + } +
+ + Open RBI Reserve Configuration → + +
+ } + + +
+
+
+ DTL base as at + {{ data.basis.dtlAsOnDate }} +
+
+
+ Balances maintained as at + {{ data.basis.holdingsAsOnDate }} +
+ @if (data.basis.rawLagDate) { +
+ {{ data.basis.lagDays }}-day lag falls on + {{ data.basis.rawLagDate }} +
+ } +
+

{{ data.basis.note }}

+
+ + + @if (data.computable) { +
+ +
+ NDTL base + {{ amount(data.ndtlAmount) }} + + DTL {{ amount(data.dtlAmount) }} less {{ amount(data.interBankAssets) }} · as at + {{ data.basis.dtlAsOnDate }} + +
+
+ {{ data.rateType }} rate + {{ data.ratePercentage }}% + @if (data.rateIsSeedDefault) { + Placeholder — not confirmed + } @else { + in force on {{ data.reportingDate }} + } +
+
+ Required + {{ amount(data.requiredAmount) }} + {{ data.ratePercentage }}% of NDTL +
+
+ Maintained + {{ amount(data.maintainedAmount) }} + {{ percent(data.maintainedPercentage) }} of NDTL +
+
+ {{ isShortfall ? 'Shortfall' : 'Surplus' }} + {{ amount(shortfallAmount) }} + {{ isShortfall ? 'Below requirement' : 'Above requirement' }} +
+
+ } + + +
+ + + + + + + + + + + @for (line of data.lines; track $index) { + + + + + + + @for (account of line.accounts; track account.glAccountId) { + + + + + + } + } + + @if (data.computable) { + + + + + + + + + + + + + + + + } + +
ParticularsAs atAmount (INR)
+ {{ line.label }} + @if (!line.configured) { + Not configured + } + {{ line.asOnDate }} + @if (line.configured) { + {{ amount(line.amount) }} + } @else { + — + } +
+ {{ account.glCode }} + {{ account.name }} + + @if (account.excluded) { + excluded — {{ account.excluded }} + } + + @if (isPartial(account)) { + + {{ account.weightPercentage }}% of {{ amount(account.accountBalance) }} + + } + + @if (account.liabilityClass) { + {{ account.liabilityClass | titlecase }} + } + {{ amount(account.balance) }}
{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL){{ amount(data.requiredAmount) }}
{{ data.rateType }} maintained{{ amount(data.maintainedAmount) }}
{{ isShortfall ? 'Shortfall' : 'Surplus' }}{{ amount(shortfallAmount) }}
+
+ + + @if (data.maintenance; as m) { +
+

+ Maintenance over the fortnight + @if (!m.fortnightComplete) { + In progress — {{ m.dayCount }} days so far + } +

+

+ CRR is reckoned over the whole fortnight, not on one day: the full requirement must be held + on average, and at least {{ m.dailyMinimumPercent }}% of it on every single day. +

+ +
+
+ Average maintained + {{ amount(m.average) }} + + {{ m.averageMet ? 'Meets' : 'Below' }} required {{ amount(m.requiredAverage) }} + +
+
+ Lowest day + {{ amount(m.lowest) }} + + on {{ m.lowestOn }} · floor {{ amount(m.dailyMinimumAmount) }} + +
+
+ Days counted + {{ m.dayCount }} + {{ m.fortnightComplete ? 'Full fortnight' : 'To reporting date' }} +
+
+ + + + + + + + + + + @for (day of m.days; track day.date) { + + + + + + } + +
DateMaintained (INR)Against floor
{{ day.date }}{{ amount(day.amount) }} + @if (day.amount < m.dailyMinimumAmount) { + Below floor + } @else { + OK + } +
+
+ } + + + @for (section of namedSections; track section.title) { + @if (section.rows.length) { +
+

{{ section.title }}

+

{{ section.hint }}

+ + + + + + + + + @for (row of section.rows; track row.configKey) { + + + + + } + +
ParticularsAmount (INR)
+ {{ row.label }} + @if (!row.configured) { + Not configured + } + + @if (row.configured) { + {{ amount(row.amount) }} + } @else { + — + } +
+
+ } + } + } + + @if (toastVisible) { +
{{ toastMsg }}
+ } +
diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.scss b/src/app/rbi-circulars/reserve-return/reserve-return.component.scss new file mode 100644 index 000000000..6577a52db --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.scss @@ -0,0 +1,491 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +$primary: #1353a0; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-900: #202124; +$green: #137333; +$amber: #b06000; +$red: #c5221f; + +.rr-container { + max-width: 1120px; + margin: 0 auto; + padding: 20px 16px; +} + +.rr-loading { + padding: 36px; + text-align: center; + color: $gray-500; + font-size: 13px; +} + +.rr-alert { + padding: 12px 16px; + border-radius: 8px; + font-size: 12.5px; + margin-bottom: 16px; + line-height: 1.6; + + &--error { + background: #fce8e6; + color: $red; + } + + &--warn { + background: #fef7e0; + color: $amber; + } +} + +.rr-unmet { + margin: 8px 0 8px 18px; + padding: 0; + + li { + margin-bottom: 4px; + } + + // Sets up the list that follows: without it, a blank line reads as an error rather than as a + // head the bank has nothing under. + &__lead { + margin: 6px 0 0; + font-size: 12px; + font-weight: 400; + line-height: 1.6; + opacity: 0.9; + } + + &__link { + display: inline-block; + margin-top: 4px; + font-weight: 600; + color: $primary; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +// ── Header ── + +.rr-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 16px; + + &__title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: $gray-900; + } + + &__freq { + display: inline-block; + margin-top: 4px; + margin-right: 10px; + font-size: 10.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + background: #e8f0fe; + color: $primary; + border-radius: 20px; + padding: 3px 10px; + } + + &__period { + font-size: 12px; + color: $gray-500; + } + + &__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + + i { + margin-right: 6px; + } + } +} + +.rr-date { + width: 180px; +} + +// ── Basis ── + +.rr-basis { + background: $gray-50; + border: 1px solid $border; + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 16px; + + &__title { + margin: 0 0 12px; + font-size: 13px; + font-weight: 700; + color: $gray-900; + } + + &__row { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + } + + // The General Information particulars. A grid rather than a stack: eleven one-line facts read as + // a block to scan, not as a list to work down, and auto-fit keeps them legible at any width. + &__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px 20px; + } + + &__item { + min-width: 0; + + &--muted { + opacity: 0.65; + } + } + + &__label { + display: block; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-500; + } + + &__value { + font-size: 14px; + font-weight: 600; + color: $gray-900; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; + + // A particular nobody has maintained yet, shown as such rather than as a filled-in blank. + &--unset { + font-weight: 500; + font-style: italic; + color: $amber; + } + } + + &__arrow { + color: $gray-500; + font-size: 12px; + } + + &__note { + margin: 10px 0 0; + font-size: 11.5px; + color: $gray-500; + line-height: 1.6; + } +} + +// ── Summary stats ── + +.rr-summary { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 16px; +} + +.rr-stat { + flex: 1 1 180px; + background: #fff; + border: 1px solid $border; + border-radius: 10px; + padding: 12px 14px; + + &__label { + display: block; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-500; + margin-bottom: 4px; + } + + &__value { + display: block; + font-size: 19px; + font-weight: 700; + color: $gray-900; + font-variant-numeric: tabular-nums; + } + + &__sub { + display: block; + margin-top: 2px; + font-size: 11px; + color: $gray-500; + + &--warn { + color: $amber; + font-weight: 600; + } + } + + // The rate is still the shipped placeholder — the figures below it are only as good as it is. + &--provisional { + border-color: #fde68a; + background: #fffdf5; + } + + // A shortfall is a regulatory breach, so it reads as one rather than as another number. + &--bad { + border-color: #f5c6c4; + background: #fffafa; + + .rr-stat__value { + color: $red; + } + } + + &--good { + border-color: #ceead6; + background: #fafffb; + + .rr-stat__value { + color: $green; + } + } +} + +// ── Table ── + +.rr-card { + background: #fff; + border: 1px solid $border; + border-radius: 12px; + padding: 6px 16px 14px; + overflow-x: auto; +} + +.rr-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + font-weight: 600; + color: $gray-500; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 10px 10px; + border-bottom: 1px solid $border; + white-space: nowrap; + } + + td { + padding: 9px 10px; + border-bottom: 1px solid $gray-200; + color: $gray-900; + } + + &__num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } +} + +.rr-line { + &__label { + font-weight: 600; + } + + &__date { + color: $gray-500; + font-size: 11.5px; + white-space: nowrap; + } + + &--unconfigured { + background: #fffafa; + } +} + +.rr-subline { + td { + padding-top: 5px; + padding-bottom: 5px; + font-size: 11.5px; + color: $gray-500; + border-bottom: none; + } + + td:first-child { + padding-left: 26px; + } + + &__code { + font-family: 'Roboto Mono', monospace; + font-size: 10.5px; + background: $gray-50; + border: 1px solid $border; + border-radius: 4px; + padding: 1px 5px; + margin-right: 6px; + } + + // Only part of the account is taken, so the figure alongside is not its balance. + &__portion { + margin-left: 8px; + font-size: 10.5px; + font-weight: 600; + color: $amber; + background: #fef7e0; + border-radius: 20px; + padding: 1px 8px; + white-space: nowrap; + } + + &__class { + margin-left: 6px; + font-size: 10px; + font-weight: 600; + color: $primary; + background: #e8f0fe; + border-radius: 20px; + padding: 1px 8px; + } + + // An account pinned to the role but not in the total. Marked rather than merely blank, so it + // reads as deliberately left out instead of as an account that happens to be nil. + &--excluded td { + color: $amber; + } + + &__excluded { + font-size: 10.5px; + font-style: italic; + margin-left: 8px; + color: $amber; + } +} + +.rr-total { + td { + font-weight: 700; + background: #e8f0fe; + border-bottom: 1px solid #d2e3fc; + } + + &--bad td { + background: #fce8e6; + color: $red; + border-bottom-color: #f5c6c4; + } +} + +.rr-dash { + color: $gray-500; +} + +// ── Sections: Memorandum, Annexures, fortnight maintenance ── + +.rr-card--section { + margin-top: 16px; + padding: 16px; +} + +.rr-section { + &__title { + margin: 0 0 4px; + font-size: 15px; + font-weight: 600; + color: $gray-900; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + &__hint { + margin: 0 0 14px; + font-size: 12px; + color: $gray-500; + line-height: 1.6; + } +} + +// A day below the daily floor is a breach on its own, even where the average passes. +.rr-day--short td { + background: #fffafa; +} + +.rr-ok { + font-size: 11px; + color: $green; + font-weight: 600; +} + +.rr-badge { + display: inline-block; + margin-left: 8px; + font-size: 10px; + font-weight: 600; + border-radius: 20px; + padding: 2px 8px; + + &--unmapped { + background: #fce8e6; + color: $red; + } + + // The fortnight is still running, so the average is of the days elapsed so far. + &--partial { + background: #fef7e0; + color: $amber; + } +} + +.rr-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: $gray-900; + color: #fff; + font-size: 12.5px; + padding: 10px 18px; + border-radius: 8px; + z-index: 1000; +} + +@media (max-width: 720px) { + .rr-header__actions { + width: 100%; + } + + .rr-date { + flex: 1 1 100%; + width: auto; + } +} diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index 4a6fe1a06..a7db00b1d 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -12,6 +12,7 @@ import { Routes, RouterModule } from '@angular/router'; /** Routing Imports */ import { Route } from '../core/route/route.service'; +import { tdsConfigResolver } from '../letter-mgmt/certificates/form-15gh/tds-config.service'; /** Custom Components */ import { ReportsComponent } from './reports.component'; @@ -84,12 +85,14 @@ const routes: Routes = [ { path: 'tds-interest', data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' }, + resolve: { tdsConfig: tdsConfigResolver }, loadComponent: () => import('../tds-reports/tds-interest-report.component').then((m) => m.TdsInterestReportComponent) }, { path: 'tds-remittance', data: { title: 'TDS Remitted to Government', breadcrumb: 'TDS Remitted to Government' }, + resolve: { tdsConfig: tdsConfigResolver }, loadComponent: () => import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent) }, 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 d2f1315f5..979ab26b0 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 @@ -287,7 +287,11 @@ export class GlSettingsDialogComponent implements OnInit { this.sponsorDDService .upsertGlMapping({ sponsorBankId: 0, - ddPayableGlAccountId: val.ddPayableGlAccountId, + // DD Payable is set per sponsor bank (Organization > Sponsor Banks) and is the only source + // DD posting reads, so this screen no longer owns it. We still round-trip whatever the row + // already had -- so an existing value is preserved -- but a tenant that never set it (NULL) + // now saves fine instead of hitting "Sponsor DD Payable GL account is required". + ddPayableGlAccountId: this.mapping?.ddPayableGlAccountId ?? null, chequeClearingGlAccountId: val.chequeClearingGlAccountId, cashGlAccountId: val.cashGlAccountId, chargesIncomeGlAccountId: val.chargesIncomeGlAccountId, diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index e811f563a..bb0c52a1e 100644 --- a/src/app/sponsor-dd/sponsor-dd.service.ts +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -172,7 +172,8 @@ export class SponsorDDService { upsertGlMapping(data: { sponsorBankId: number; - ddPayableGlAccountId: number; + // DD Payable GL is configured per sponsor bank now, not on this screen, so it is optional here. + ddPayableGlAccountId?: number | null; chequeClearingGlAccountId?: number; cashGlAccountId?: number; chargesIncomeGlAccountId?: number; diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts index 66c862d59..32f43e8d3 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts @@ -94,7 +94,9 @@ export class DepositRequestsComponent implements OnInit { const today = new Date(this.settingsService.businessDate); today.setHours(0, 0, 0, 0); this.maxDate = today; - this.applyPreset('today'); + // A pending request must never be hidden by date: submitted_at is wall-clock time, and the business + // date "today" can lag it, so opening on Today showed an empty queue. Open on All instead. + this.applyPreset('all'); } load(): void { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts index 335c88610..043fa4536 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts @@ -63,7 +63,7 @@ export class DisbursementRequestsComponent implements OnInit { fromDate: Date | null = null; toDate: Date | null = null; maxDate!: Date; - activePreset = 'today'; + activePreset = 'all'; loading = false; loaded = false; @@ -86,7 +86,9 @@ export class DisbursementRequestsComponent implements OnInit { const today = new Date(this.settingsService.businessDate); today.setHours(0, 0, 0, 0); this.maxDate = today; - this.applyPreset('today'); + // A pending request must never be hidden by date: submitted_at is wall-clock time, and the business + // date "today" can lag it, so opening on Today showed an empty queue. Open on All instead. + this.applyPreset('all'); } load(): void { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts index 03f420097..06d17b53d 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts @@ -138,7 +138,7 @@ export class PendingClientEditsComponent implements OnInit { fromDate: Date | null = null; toDate: Date | null = null; maxDate!: Date; - activePreset = 'today'; + activePreset = 'all'; readonly datePresets = [ { key: 'today', label: 'Today' }, { key: 'yesterday', label: 'Yesterday' }, @@ -157,7 +157,9 @@ export class PendingClientEditsComponent implements OnInit { today.setHours(0, 0, 0, 0); this.maxDate = today; this.loadOptions(); - this.applyPreset('today'); + // A pending request must never be hidden by date: submitted_at is wall-clock time, and the business + // date "today" can lag it, so opening on Today showed an empty queue. Open on All instead. + this.applyPreset('all'); } private loadOptions(): void { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.ts index 9585f8e29..330d2b0ea 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.ts @@ -87,7 +87,9 @@ export class WithdrawalRequestsComponent implements OnInit { const today = new Date(this.settingsService.businessDate); today.setHours(0, 0, 0, 0); this.maxDate = today; - this.applyPreset('today'); + // A pending request must never be hidden by date: submitted_at is wall-clock time, and the business + // date "today" can lag it, so opening on Today showed an empty queue. Open on All instead. + this.applyPreset('all'); } load(): void { diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html index 27cd9f12e..9244384a1 100644 --- a/src/app/tds-reports/tds-interest-report.component.html +++ b/src/app/tds-reports/tds-interest-report.component.html @@ -24,6 +24,13 @@
+ @if (loadError) { +
+ +
{{ loadError }}
+
+ } +
diff --git a/src/app/tds-reports/tds-interest-report.component.ts b/src/app/tds-reports/tds-interest-report.component.ts index 0796c1a17..41c4ef137 100644 --- a/src/app/tds-reports/tds-interest-report.component.ts +++ b/src/app/tds-reports/tds-interest-report.component.ts @@ -15,7 +15,7 @@ import * as ExcelJS from 'exceljs'; import { BranchScopeService } from '../core/branch-scope.service'; import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data'; import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { setTdsDeductees, TDS_FYS } from './tds-reports-data'; +import { setTdsDeductees, tdsFys } from './tds-reports-data'; import { TdsReportsService } from './tds-reports.service'; import { NEAR_THRESHOLD_BAND, @@ -41,7 +41,8 @@ export class TdsInterestReportComponent implements OnInit { private tdsService = inject(TdsReportsService); readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO'); - readonly financialYears = TDS_FYS; + /** Filled by the route resolver from Organization › TDS Configuration. */ + readonly financialYears = tdsFys(); readonly quarterLabel = QUARTER_LABEL; readonly nearBand = NEAR_THRESHOLD_BAND; readonly quarters: Quarter[] = [ @@ -52,7 +53,7 @@ export class TdsInterestReportComponent implements OnInit { ]; filter: TdsReportFilter = { - fy: TDS_FYS[0], + fy: tdsFys()[0] ?? '', branchCode: '', quarter: 0, band: '', @@ -93,6 +94,10 @@ export class TdsInterestReportComponent implements OnInit { loadError = ''; ngOnInit(): void { + if (!this.financialYears.length) { + this.loadError = 'No financial year is configured for TDS. Set one up under Organization › TDS Configuration.'; + return; + } // A branch login sees only its own book and cannot widen the scope. const own = this.scope.ownBranchCode; if (own) { @@ -116,20 +121,29 @@ export class TdsInterestReportComponent implements OnInit { next: (deductees) => { this.loading = false; setTdsDeductees(deductees); - this.summaryCacheKey = ''; - this.cachedSummary = null; + this.invalidateCaches(); }, error: (err: any) => { this.loading = false; setTdsDeductees([]); - this.summaryCacheKey = ''; - this.cachedSummary = null; + this.invalidateCaches(); this.loadError = err?.error?.defaultUserMessage || 'Could not load deposit interest. The figures below cannot be shown.'; } }); } + /** + * Both caches are keyed on the filter alone, so a fresh load must drop them together. Clearing + * only the summary left the KPIs counting the new data while the tables kept the empty list + * cached on the first render, before the load returned. + */ + private invalidateCaches(): void { + this.rowsCacheKey = ''; + this.summaryCacheKey = ''; + this.cachedSummary = null; + } + /** * A different year needs a different set of interest credits, so the year re-reads from the * platform rather than re-filtering the set already in hand. diff --git a/src/app/tds-reports/tds-remittance-report.component.ts b/src/app/tds-reports/tds-remittance-report.component.ts index 5a1b9ce66..9a1892cf0 100644 --- a/src/app/tds-reports/tds-remittance-report.component.ts +++ b/src/app/tds-reports/tds-remittance-report.component.ts @@ -14,7 +14,7 @@ import * as ExcelJS from 'exceljs'; import { BranchScopeService } from '../core/branch-scope.service'; import { FY_TAX_CONFIG, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TDS_AS_AT, TDS_FYS } from './tds-reports-data'; +import { TDS_AS_AT, tdsFys } from './tds-reports-data'; import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } from './tds-reports.compute'; @Component({ @@ -30,11 +30,12 @@ import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } fro export class TdsRemittanceReportComponent { private scope = inject(BranchScopeService); - readonly financialYears = TDS_FYS; + /** Filled by the route resolver from Organization › TDS Configuration. */ + readonly financialYears = tdsFys(); readonly quarterLabel = QUARTER_LABEL; readonly asAt = TDS_AS_AT; - fy = TDS_FYS[0]; + fy = tdsFys()[0] ?? ''; exporting = false; /** diff --git a/src/app/tds-reports/tds-reports-data.ts b/src/app/tds-reports/tds-reports-data.ts index f5c639c6e..a92ba644a 100644 --- a/src/app/tds-reports/tds-reports-data.ts +++ b/src/app/tds-reports/tds-reports-data.ts @@ -20,17 +20,17 @@ import { GL_BRANCHES } from '../gl-reports/gl-reports-data'; import { + configuredFys, Declaration, Form15Kind, InterestCredit, TdsAccountInput } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -/** The years the seed covers. 2025-26 is complete; 2026-27 is in progress. */ -export const TDS_FYS = [ - '2026-27', - '2025-26' -]; +/** The years the reports can show: every year configured on Organization › TDS Configuration. */ +export function tdsFys(): string[] { + return configuredFys(); +} /** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */ export const TDS_AS_AT = '2026-08-01'; @@ -206,7 +206,7 @@ export function tdsChallans(deductedByQuarter: Record = {}; - for (const fy of TDS_FYS) { + for (const fy of Object.keys(deductedByQuarter)) { const y = Number(fy.slice(0, 4)); const months: Record = { 1: `${y}-06`, diff --git a/src/app/tds-reports/tds-reports.compute.spec.ts b/src/app/tds-reports/tds-reports.compute.spec.ts index b128afe9c..f3b16c811 100644 --- a/src/app/tds-reports/tds-reports.compute.spec.ts +++ b/src/app/tds-reports/tds-reports.compute.spec.ts @@ -7,7 +7,8 @@ */ import { isTdsApplicable, tdsDeducteeOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TDS_AS_AT, TDS_FYS, depositDueDate, tdsDeductees } from './tds-reports-data'; +import { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture'; +import { TDS_AS_AT, depositDueDate, tdsDeductees, tdsFys } from './tds-reports-data'; import { NEAR_THRESHOLD_BAND, TdsReportFilter, @@ -19,6 +20,8 @@ import { tdsRemittanceRows } from './tds-reports.compute'; +installTdsConfigFixture(); + /** The complete year in the seed — the one a return would be filed for. */ const CLOSED_FY = '2025-26'; @@ -75,7 +78,7 @@ describe('the reports follow the Act that governed each year', () => { }); it('keeps the age-based threshold band in both years', () => { - for (const fy of TDS_FYS) { + for (const fy of tdsFys()) { const rows = tdsInterestRowsForFy(fy); expect(rows.some((r) => r.band === 'SENIOR')).toBe(true); expect(rows.some((r) => r.band === 'GENERAL')).toBe(true); @@ -240,7 +243,7 @@ describe('remittance schedule', () => { }); describe('the 26Q reconciliation control', () => { - it.each(TDS_FYS)('balances for FY %s', (fy) => { + it.each(tdsFys())('balances for FY %s', (fy) => { const s = tds26qSummary(fy, TDS_AS_AT); // Deducted must equal deposited plus what is still within its due date. // Anything else means tax has gone missing between the ledger and the diff --git a/src/app/tds-reports/tds-reports.compute.ts b/src/app/tds-reports/tds-reports.compute.ts index afaa47d2a..022aeb982 100644 --- a/src/app/tds-reports/tds-reports.compute.ts +++ b/src/app/tds-reports/tds-reports.compute.ts @@ -27,6 +27,7 @@ import { declarationFormFor, formKindFor, fyOf, + fyTaxConfigVersion, isSeniorInFy, isTdsApplicable, isWellFormedPan, @@ -118,7 +119,8 @@ let rowCache: TdsInterestRow[] = []; export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { // Keyed on the data set as well as the year: the rows are memoised, and a fresh load would // otherwise keep serving the previous customer's figures under the same financial year. - const key = fy + '|' + tdsDeducteesVersion; + // The tax configuration too: an administrator's save must not be hidden behind rows built on the old rates. + const key = fy + '|' + tdsDeducteesVersion + '|' + fyTaxConfigVersion; if (rowCacheKey === key) { return rowCache; } diff --git a/src/app/tds-reports/tds-reports.service.ts b/src/app/tds-reports/tds-reports.service.ts index b8d1f4b07..8856cc8b7 100644 --- a/src/app/tds-reports/tds-reports.service.ts +++ b/src/app/tds-reports/tds-reports.service.ts @@ -13,6 +13,7 @@ import { Observable, map } from 'rxjs'; /** Custom Helpers */ import { toIsoDate } from '../core/utils/api-date'; +import { configuredFys } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; import { TdsDeductee } from './tds-reports-data'; /** @@ -77,14 +78,11 @@ export class TdsReportsService { return {}; } const out: Record = {}; - // Applied to the years the reports can show. Without an acceptance date on the record the - // declaration is treated as operative from the start of each year rather than mid-year. - for (const year of [ - 2025, - 2026 - ]) { - const fy = `FY ${year}-${String(year + 1).slice(2)}`; - out[fy] = { kind, acceptedOn: `${year}-04-01`, fy }; + // Applied to every configured year. Without an acceptance date on the record the declaration is + // treated as operative from the start of each year rather than mid-year. Keyed '2026-27', the + // form the computation looks it up by - an 'FY 2026-27' key was never found. + for (const fy of configuredFys()) { + out[fy] = { kind, acceptedOn: `${fy.slice(0, 4)}-04-01`, fy }; } return out; } diff --git a/src/environments/.env.ts b/src/environments/.env.ts index f08a6bab3..44f95bdbb 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260923', - 'hash': '3f59cc6b' + 'version': '260924', + 'hash': 'a6e62201' }, 'allow_switching_backend_instance': true }; From 5ee0a3fc53a285d8264e91437577ffc005db72a7 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 18:20:11 +0530 Subject: [PATCH 12/32] feat(fd): show gap-interest renewal preview in Payment dialog before confirming a late renewal Fetches current balance, gap days since maturity, the linked savings account's rate, and the resulting gap-interest amount and projected new balance, and displays them above the dashboard-renewal timing text -- so staff see exactly what a late renewal is about to credit before confirming anything. Read-only; failure to load the preview doesn't block the renewal itself, since the existing timing text already explains the action. Co-Authored-By: Claude Sonnet 5 --- .../payment-dialog.component.html | 38 ++++++++++++ .../payment-dialog.component.scss | 62 +++++++++++++++++++ .../payment-dialog.component.ts | 19 ++++++ .../fixed-deposits/fixed-deposits.service.ts | 7 +++ 4 files changed, 126 insertions(+) diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html index 34a510e7f..870c04a21 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html @@ -45,6 +45,44 @@ {{ dashboardRenewalTimingText }}

+ + @if (renewalPreviewLoading) { +

+ + Calculating gap-period interest... +

+ } @else if (renewalPreview && !renewalPreview.withinGrace) { + @if (!renewalPreview.linkedAccountId) { + + } @else { +
+
+ Current Balance + {{ renewalPreview.currentBalance | formatNumber }} +
+
+ Gap Days ({{ renewalPreview.maturityDate }} → today) + {{ renewalPreview.gapDays }} +
+
+ Savings Account Interest Rate + {{ renewalPreview.linkedAccountRate }}% +
+
+ Gap-Period Interest to be Credited + + {{ renewalPreview.gapInterestAmount | formatNumber }} +
+
+ New Balance for the Renewed Term + {{ renewalPreview.projectedBalance | formatNumber }} +
+
+ } + } } @if (reinvestCap !== null && reinvestCap !== undefined) { diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss index a95aa35aa..c6c8920ad 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss @@ -164,6 +164,68 @@ } } +.pmt__preview { + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 14px; + padding: 4px 18px; + margin: 0 0 18px; + + &-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 0; + border-bottom: 1px solid #f1f5f9; + + &:last-child { + border-bottom: none; + } + + &--highlight { + .pmt__preview-value { + color: #0d9488; + font-weight: 800; + } + } + + &--total { + margin: 0 -18px; + padding: 14px 18px; + background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%); + border-bottom: none; + border-radius: 0 0 12px 12px; + + .pmt__preview-label { + color: #1d4ed8; + font-weight: 700; + } + + .pmt__preview-value { + color: #0f172a; + font-size: 18px; + font-weight: 800; + } + } + } + + &-label { + font-size: 12.5px; + color: #64748b; + font-weight: 600; + } + + &-value { + font-family: 'JetBrains Mono', 'DM Sans', monospace; + font-size: 14px; + font-weight: 700; + color: #0f172a; + } +} + .pmt__section-label { font-size: 12px; font-weight: 700; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts index 4e1cc8c39..789d1e902 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts @@ -113,6 +113,12 @@ export class PaymentDialogComponent implements OnInit { * after open; cleared as soon as `submit()` has credited it, so a retried submit doesn't double-post. */ private gapInterestPending = false; + /** Read-only numbers (current balance, linked account's rate, gap days, gap-interest amount, + * projected new balance) fetched once on open in dashboard-renewal mode, shown above the Action + * cards so staff see exactly what a late renewal is about to credit before confirming anything. */ + renewalPreview: any = null; + renewalPreviewLoading = false; + ngOnInit(): void { this.maxDate = this.settingsService.businessDate; this.paymentForm = this.formBuilder.group({ @@ -136,6 +142,19 @@ export class PaymentDialogComponent implements OnInit { .get('closedOnDate') .setValue(dashboardRow.withinGraceWindow ? new Date(dashboardRow.maturityDate) : this.maxDate); this.gapInterestPending = !dashboardRow.withinGraceWindow; + + this.renewalPreviewLoading = true; + this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({ + next: (preview: any) => { + this.renewalPreview = preview; + this.renewalPreviewLoading = false; + }, + // Non-fatal -- the plain timing text above still explains what will happen even if the + // numeric breakdown couldn't be fetched; only the numbers are missing, not the action itself. + error: () => { + this.renewalPreviewLoading = false; + } + }); } this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id)); diff --git a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts index 79976fe80..e5af75631 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts @@ -174,6 +174,13 @@ export class FixedDepositsService { return this.http.post(`/fixeddepositaccounts/${accountId}/credit-gap-interest`, {}); } + /** Read-only -- what a Renew is about to do (current balance, linked account's rate, gap days, + * gap-interest amount, projected new balance), shown in the Payment dialog before anything is + * confirmed. Posts nothing; safe to call as often as the dialog needs. */ + getFdRenewalPreview(accountId: string | number): Observable { + return this.http.get(`/fixeddepositaccounts/${accountId}/renewal-preview`); + } + /** * @param fixedDepositAccount Fixed Deposit Account * @returns {Observable} From 90f26d32b76e13f52652b9a44ca40c64140e3fa8 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 12:41:07 +0530 Subject: [PATCH 13/32] fix(products): restore default minDepositTerm on FD product create Removing the forced default on this hidden, backend-mandatory field fixed a false "changed" diff on product edit, but also broke plain product create -- there's no saved value yet for a brand-new product, so it went in blank and the backend rejected it as missing. Now only skips the default when editing an existing product (which always has its own saved value); create still defaults to 1, same as before that diff-dialog fix. Co-Authored-By: Claude Sonnet 5 --- ...ixed-deposit-product-terms-step.component.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts index 5824a1591..d1abb4ad6 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts @@ -74,6 +74,7 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges this.periodFrequencyData = this.fixedDepositProductsTemplate.periodFrequencyTypeOptions || []; const isEditing = !!this.fixedDepositProductsTemplate.id; + const defaultPeriodTypeId = this.periodFrequencyData[0]?.id ?? null; const defaultAveragePeriod = this.averagePeriodData[0]?.value || 'Monthly'; const savedEnumTypeId = this.fixedDepositProductsTemplate.interestCalculationType?.id || this.interestCalculationTypeData?.[0]?.id || 1; @@ -84,12 +85,16 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges : (this.interestCalculationTypeOptionData?.[0]?.id ?? null); this.fixedDepositProductTermsForm.patchValue({ - // Hidden, unedited-by-anyone fields -- left blank (like their max-/in-multiples-of- siblings - // already correctly are) rather than forced to a real default, or every single product edit - // submitted a "real" value against a product that had none, and a checker's "Proposed changes" - // review showed these two as "changed" on every unrelated edit, never anything a maker touched. - minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? '', - minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? '', + // Hidden, unedited-by-anyone fields. On EDIT, left as the product's own saved value with no + // forced fallback (like their max-/in-multiples-of- siblings already correctly are), or a + // checker's "Proposed changes" review showed these as changed on every unrelated edit, never + // anything a maker touched. On CREATE there is no saved value yet, and the backend requires + // minDepositTerm -- default it (and its unit) here same as before, since the field is hidden + // and no one gets a chance to fill it in themselves. + minDepositTerm: isEditing ? (this.fixedDepositProductsTemplate.minDepositTerm ?? '') : 1, + minDepositTermTypeId: isEditing + ? (this.fixedDepositProductsTemplate.minDepositTermType?.id ?? '') + : defaultPeriodTypeId, maxDepositTerm: this.fixedDepositProductsTemplate.maxDepositTerm ?? '', maxDepositTermTypeId: this.fixedDepositProductsTemplate.maxDepositTermType?.id ?? '', inMultiplesOfDepositTerm: this.fixedDepositProductsTemplate.inMultiplesOfDepositTerm ?? '', From 6cbb31b5c620b7634978cec826c0074a3c162ce0 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 14:12:44 +0530 Subject: [PATCH 14/32] fix(fd): show the target account number for gap-period interest in the Renew preview The preview panel said interest would go to "the linked Savings account" without saying which one. Now shows its actual account number, both as its own row and in the highlighted "paid to" line. Co-Authored-By: Claude Sonnet 5 --- .../payment-dialog.component.html | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html index 870c04a21..646c9f4f9 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html @@ -61,7 +61,7 @@ } @else {
- Current Balance + FD Maturity Balance (renews as-is below) {{ renewalPreview.currentBalance | formatNumber }}
@@ -69,18 +69,26 @@ {{ renewalPreview.gapDays }}
- Savings Account Interest Rate + Deposit Account Link + {{ renewalPreview.linkedAccountNo }} +
+
+ Its Interest Rate {{ renewalPreview.linkedAccountRate }}%
-
- Gap-Period Interest to be Credited +
+ Gap-Period Interest → paid to {{ renewalPreview.linkedAccountNo }} + {{ renewalPreview.gapInterestAmount | formatNumber }}
-
- New Balance for the Renewed Term - {{ renewalPreview.projectedBalance | formatNumber }} -
+

+ + This gap interest was earned at the linked account's own rate, not the FD's -- it is credited straight + to account {{ renewalPreview.linkedAccountNo }} (the FD's Deposit Account Link), separately from + whatever you choose below. The FD itself renews from its own maturity balance, unchanged. +

} } } From 4ca08f6ee98a9b6c0435a13471c84c4442f9d72a Mon Sep 17 00:00:00 2001 From: anshul Date: Mon, 28 Sep 2026 12:55:42 +0530 Subject: [PATCH 15/32] feat: add new standalone components for system and organization management modules --- .../manage-currencies.component.ts | 26 ++++++++++++++-- .../create-fund/create-fund.component.ts | 8 +++++ .../account-number-preferences.component.html | 7 ++++- ...ate-account-number-preference.component.ts | 10 ++++++ ...dit-account-number-preference.component.ts | 8 +++++ ...iew-account-number-preference.component.ts | 16 ++++++++-- .../create-code/create-code.component.ts | 10 ++++++ .../codes/edit-code/edit-code.component.ts | 16 ++++++++++ .../codes/view-code/view-code.component.html | 2 +- .../codes/view-code/view-code.component.ts | 31 ++++++++++++++++++- .../edit-configuration.component.ts | 12 ++++++- .../global-configurations-tab.component.ts | 28 +++++++++++------ .../entity-to-entity-mapping.component.ts | 22 ++++++++++++- .../edit-amazon-s3.component.ts | 8 +++++ .../email/edit-email/edit-email.component.ts | 8 +++++ .../edit-notification.component.ts | 8 +++++ .../sms/edit-sms/edit-sms.component.ts | 8 +++++ .../create-data-table.component.ts | 10 ++++++ .../edit-data-table.component.ts | 8 +++++ .../view-data-table.component.ts | 10 +++++- .../create-hook/create-hook.component.ts | 10 ++++++ .../edit-hook/edit-hook.component.ts | 8 +++++ .../view-hook/view-hook.component.ts | 10 +++++- .../workflow-jobs/workflow-jobs.component.ts | 10 +++++- .../create-report/create-report.component.ts | 11 ++++++- .../edit-report/edit-report.component.ts | 10 +++++- .../view-report/view-report.component.ts | 10 +++++- .../create-survey/create-survey.component.ts | 8 +++++ .../edit-survey/edit-survey.component.ts | 8 +++++ .../manage-surveys.component.ts | 20 ++++++++++-- .../manage-user-agreement.component.ts | 14 +++++++-- .../user-agreement.service.ts | 4 +-- 32 files changed, 347 insertions(+), 32 deletions(-) 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/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/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/entity-to-entity-mapping/entity-to-entity-mapping.component.ts b/src/app/system/entity-to-entity-mapping/entity-to-entity-mapping.component.ts index 69f514ee1..2d72b347a 100644 --- a/src/app/system/entity-to-entity-mapping/entity-to-entity-mapping.component.ts +++ b/src/app/system/entity-to-entity-mapping/entity-to-entity-mapping.component.ts @@ -45,6 +45,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { DateFormatPipe } from '../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Entity to Entity Mapping Component @@ -81,6 +82,7 @@ export class EntityToEntityMappingComponent implements OnInit { private dialog = inject(MatDialog); private settingsService = inject(SettingsService); private translateService = inject(TranslateService); + private alertService = inject(AlertService); /** Stores entity to entity mapping data */ entityMappings: string[] = []; @@ -392,6 +394,12 @@ export class EntityToEntityMappingComponent implements OnInit { newMappingData.dateFormat = dateFormat; newMappingData.locale = this.settingsService.language.code; this.systemService.createMapping(this.relId, newMappingData).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Entity to entity mapping creation has been submitted for approval.' + }); + } this.showFilteredData(); }); } @@ -413,6 +421,12 @@ export class EntityToEntityMappingComponent implements OnInit { newMappingData.dateFormat = dateFormat; newMappingData.locale = this.settingsService.language.code; this.systemService.editMapping(this.mapIdToEdit, newMappingData).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Entity to entity mapping update has been submitted for approval.' + }); + } this.showFilteredData(); }); } @@ -427,7 +441,13 @@ export class EntityToEntityMappingComponent implements OnInit { }); deleteNoteDialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.systemService.deleteMapping(id).subscribe(() => { + this.systemService.deleteMapping(id).subscribe((deleteResponse: any) => { + if (deleteResponse?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Entity to entity mapping deletion has been submitted for approval.' + }); + } this.showFilteredData(); }); } diff --git a/src/app/system/external-services/amazon-s3/edit-amazon-s3/edit-amazon-s3.component.ts b/src/app/system/external-services/amazon-s3/edit-amazon-s3/edit-amazon-s3.component.ts index 92f2f5c4d..15e021afd 100644 --- a/src/app/system/external-services/amazon-s3/edit-amazon-s3/edit-amazon-s3.component.ts +++ b/src/app/system/external-services/amazon-s3/edit-amazon-s3/edit-amazon-s3.component.ts @@ -16,6 +16,7 @@ import { SystemService } from 'app/system/system.service'; import { MatButton, MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Edit Amazon S3 Component. @@ -35,6 +36,7 @@ export class EditAmazonS3Component implements OnInit { private systemService = inject(SystemService); private route = inject(ActivatedRoute); private router = inject(Router); + private alertService = inject(AlertService); /** Amazon S3 Configuration data */ amazonS3ConfigurationData: any; @@ -95,6 +97,12 @@ export class EditAmazonS3Component implements OnInit { this.systemService .updateExternalConfiguration('S3', this.amazonS3ConfigurationForm.value) .subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Amazon S3 configuration update has been submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/system/external-services/email/edit-email/edit-email.component.ts b/src/app/system/external-services/email/edit-email/edit-email.component.ts index c2678319b..8a2495851 100644 --- a/src/app/system/external-services/email/edit-email/edit-email.component.ts +++ b/src/app/system/external-services/email/edit-email/edit-email.component.ts @@ -17,6 +17,7 @@ import { MatButton, MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatCheckbox } from '@angular/material/checkbox'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Edit Email Configuration Component. @@ -37,6 +38,7 @@ export class EditEmailComponent implements OnInit { private systemService = inject(SystemService); private route = inject(ActivatedRoute); private router = inject(Router); + private alertService = inject(AlertService); /** Password input field type. */ passwordInputType: string; @@ -110,6 +112,12 @@ export class EditEmailComponent implements OnInit { this.systemService .updateExternalConfiguration('SMTP', this.emailConfigurationForm.value) .subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Email configuration update has been submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/system/external-services/notification/edit-notification/edit-notification.component.ts b/src/app/system/external-services/notification/edit-notification/edit-notification.component.ts index d7d9219f6..29edffcb5 100644 --- a/src/app/system/external-services/notification/edit-notification/edit-notification.component.ts +++ b/src/app/system/external-services/notification/edit-notification/edit-notification.component.ts @@ -14,6 +14,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } /** Custom Services */ import { SystemService } from 'app/system/system.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Edit Notification Configuration Component. @@ -31,6 +32,7 @@ export class EditNotificationComponent implements OnInit { private systemService = inject(SystemService); private route = inject(ActivatedRoute); private router = inject(Router); + private alertService = inject(AlertService); /** Notification Configuration data */ notificationConfigurationData: any; @@ -85,6 +87,12 @@ export class EditNotificationComponent implements OnInit { this.systemService .updateExternalConfiguration('NOTIFICATION', this.notificationConfigurationForm.value) .subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Notification configuration update has been submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/system/external-services/sms/edit-sms/edit-sms.component.ts b/src/app/system/external-services/sms/edit-sms/edit-sms.component.ts index 250bc4bd7..0c068ec15 100644 --- a/src/app/system/external-services/sms/edit-sms/edit-sms.component.ts +++ b/src/app/system/external-services/sms/edit-sms/edit-sms.component.ts @@ -14,6 +14,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } /** Custom Services */ import { SystemService } from 'app/system/system.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Edit SMS Configuration Component. @@ -31,6 +32,7 @@ export class EditSMSComponent implements OnInit { private systemService = inject(SystemService); private route = inject(ActivatedRoute); private router = inject(Router); + private alertService = inject(AlertService); /** SMS Configuration data */ smsConfigurationData: any; @@ -89,6 +91,12 @@ export class EditSMSComponent implements OnInit { this.systemService .updateExternalConfiguration('SMS', this.smsConfigurationForm.value) .subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'SMS configuration update has been submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/system/manage-data-tables/create-data-table/create-data-table.component.ts b/src/app/system/manage-data-tables/create-data-table/create-data-table.component.ts index a516aaebc..13c4a9241 100644 --- a/src/app/system/manage-data-tables/create-data-table/create-data-table.component.ts +++ b/src/app/system/manage-data-tables/create-data-table/create-data-table.component.ts @@ -48,6 +48,7 @@ import { MatButton, MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Create Data Table Component. @@ -86,6 +87,7 @@ export class CreateDataTableComponent implements OnInit, AfterViewInit { private configurationWizardService = inject(ConfigurationWizardService); private popoverService = inject(PopoverService); private translateService = inject(TranslateService); + private alertService = inject(AlertService); /** Data Table Form */ dataTableForm: UntypedFormGroup; @@ -296,6 +298,14 @@ export class CreateDataTableComponent implements OnInit, AfterViewInit { delete payload.entitySubType; } this.systemService.createDataTable(payload).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Data table creation has been submitted for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + return; + } if (this.configurationWizardService.showDatatablesForm) { this.configurationWizardService.showDatatablesForm = false; this.openDialog(); diff --git a/src/app/system/manage-data-tables/edit-data-table/edit-data-table.component.ts b/src/app/system/manage-data-tables/edit-data-table/edit-data-table.component.ts index 69d959f5f..45301bd67 100644 --- a/src/app/system/manage-data-tables/edit-data-table/edit-data-table.component.ts +++ b/src/app/system/manage-data-tables/edit-data-table/edit-data-table.component.ts @@ -42,6 +42,7 @@ import { MatButton, MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Edit Data Table Component. @@ -77,6 +78,7 @@ export class EditDataTableComponent implements OnInit { private router = inject(Router); private dialog = inject(MatDialog); private translateService = inject(TranslateService); + private alertService = inject(AlertService); /** Data Table Form. */ dataTableForm: UntypedFormGroup; @@ -471,6 +473,12 @@ export class EditDataTableComponent implements OnInit { this.systemService .updateDataTable(this.dataTableChangesData, this.dataTableData.registeredTableName) .subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Data table update has been submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/system/manage-data-tables/view-data-table/view-data-table.component.ts b/src/app/system/manage-data-tables/view-data-table/view-data-table.component.ts index c365ff86d..8298020e3 100644 --- a/src/app/system/manage-data-tables/view-data-table/view-data-table.component.ts +++ b/src/app/system/manage-data-tables/view-data-table/view-data-table.component.ts @@ -36,6 +36,7 @@ import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.co import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * View Data Table Component @@ -69,6 +70,7 @@ export class ViewDataTableComponent implements OnInit { private router = inject(Router); private dialog = inject(MatDialog); private translateService = inject(TranslateService); + private alertService = inject(AlertService); public datatables = inject(Datatables); /** Data Table Data */ @@ -145,7 +147,13 @@ export class ViewDataTableComponent implements OnInit { }); deleteDataTableDialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.systemService.deleteDataTable(this.dataTableData.registeredTableName).subscribe(() => { + this.systemService.deleteDataTable(this.dataTableData.registeredTableName).subscribe((deleteResponse: any) => { + if (deleteResponse?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Data table deletion has been submitted for approval.' + }); + } this.router.navigate(['/system/data-tables'], { relativeTo: this.route }); }); } diff --git a/src/app/system/manage-hooks/create-hook/create-hook.component.ts b/src/app/system/manage-hooks/create-hook/create-hook.component.ts index 417474eac..4e11c6957 100644 --- a/src/app/system/manage-hooks/create-hook/create-hook.component.ts +++ b/src/app/system/manage-hooks/create-hook/create-hook.component.ts @@ -38,6 +38,7 @@ import { MatCheckbox } from '@angular/material/checkbox'; import { MatButton, MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Create Hook Component. @@ -73,6 +74,7 @@ export class CreateHookComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private dialog = inject(MatDialog); private translateService = inject(TranslateService); + private alertService = inject(AlertService); /** Hooks Template Data. */ hooksTemplateData: any; @@ -253,6 +255,14 @@ export class CreateHookComponent implements OnInit { } }; this.systemService.createHook(hook).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Hook creation has been submitted for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + return; + } this.router.navigate( [ '../', diff --git a/src/app/system/manage-hooks/edit-hook/edit-hook.component.ts b/src/app/system/manage-hooks/edit-hook/edit-hook.component.ts index ecccf47c6..1befa88af 100644 --- a/src/app/system/manage-hooks/edit-hook/edit-hook.component.ts +++ b/src/app/system/manage-hooks/edit-hook/edit-hook.component.ts @@ -44,6 +44,7 @@ import { MatDivider } from '@angular/material/divider'; import { MatButton, MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Edit Hook Component. @@ -83,6 +84,7 @@ export class EditHookComponent implements OnInit { private translateService = inject(TranslateService); private snackBar = inject(MatSnackBar); private cdr = inject(ChangeDetectorRef); + private alertService = inject(AlertService); @NgModule({ imports: [ @@ -286,6 +288,12 @@ export class EditHookComponent implements OnInit { } }; this.systemService.updateHook(this.hookData.id, hook).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Hook update has been submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/system/manage-hooks/view-hook/view-hook.component.ts b/src/app/system/manage-hooks/view-hook/view-hook.component.ts index 38c248f07..44987c257 100644 --- a/src/app/system/manage-hooks/view-hook/view-hook.component.ts +++ b/src/app/system/manage-hooks/view-hook/view-hook.component.ts @@ -20,6 +20,7 @@ import { SystemService } from '../../system.service'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * View Hook Component. @@ -40,6 +41,7 @@ export class ViewHookComponent { private systemService = inject(SystemService); private router = inject(Router); private translateService = inject(TranslateService); + private alertService = inject(AlertService); /** Hook Data. */ hookData: any; @@ -67,7 +69,13 @@ export class ViewHookComponent { }); deleteHookDialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.systemService.deleteHook(this.hookData.id).subscribe(() => { + this.systemService.deleteHook(this.hookData.id).subscribe((deleteResponse: any) => { + if (deleteResponse?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Hook deletion has been submitted for approval.' + }); + } this.router.navigate(['/system/hooks']); }); } diff --git a/src/app/system/manage-jobs/workflow-jobs/workflow-jobs.component.ts b/src/app/system/manage-jobs/workflow-jobs/workflow-jobs.component.ts index 2c3e40b9d..d1ab77b87 100644 --- a/src/app/system/manage-jobs/workflow-jobs/workflow-jobs.component.ts +++ b/src/app/system/manage-jobs/workflow-jobs/workflow-jobs.component.ts @@ -33,6 +33,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatTooltip } from '@angular/material/tooltip'; import { WorkflowDiagramComponent } from './workflow-diagram/workflow-diagram.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; export interface JobStep { stepName: string; @@ -68,6 +69,7 @@ export class WorkflowJobsComponent implements OnInit { private systemService = inject(SystemService); dialog = inject(MatDialog); private translateService = inject(TranslateService); + private alertService = inject(AlertService); stepOrderHasChanged = false; @@ -209,8 +211,14 @@ export class WorkflowJobsComponent implements OnInit { this.systemService .putWorkflowJobSteps(this.jobStepName, payload) .toPromise() - .then((data) => { + .then((data: any) => { this.stepOrderHasChanged = false; + if (data?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Workflow step order update has been submitted for approval.' + }); + } }); } } diff --git a/src/app/system/manage-reports/create-report/create-report.component.ts b/src/app/system/manage-reports/create-report/create-report.component.ts index 468674f68..438de20ec 100644 --- a/src/app/system/manage-reports/create-report/create-report.component.ts +++ b/src/app/system/manage-reports/create-report/create-report.component.ts @@ -39,6 +39,7 @@ import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { MatButton, MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Create Report Component. @@ -75,6 +76,7 @@ export class CreateReportComponent implements OnInit { private router = inject(Router); private dialog = inject(MatDialog); private translateServices = inject(TranslateService); + private alertService = inject(AlertService); /** Report Form. */ reportForm: UntypedFormGroup; @@ -267,7 +269,14 @@ export class CreateReportComponent implements OnInit { return reportParameter; }); this.systemService.createReport(this.reportForm.value).subscribe((response: any) => { - // TODO: Implement Maker Checker Component. + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Report creation has been submitted for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + return; + } this.router.navigate( [ '../', diff --git a/src/app/system/manage-reports/edit-report/edit-report.component.ts b/src/app/system/manage-reports/edit-report/edit-report.component.ts index 0e1e1383e..7dcd0f7ea 100644 --- a/src/app/system/manage-reports/edit-report/edit-report.component.ts +++ b/src/app/system/manage-reports/edit-report/edit-report.component.ts @@ -38,6 +38,7 @@ import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { MatButton, MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Edit Report Component. @@ -73,6 +74,7 @@ export class EditReportComponent implements OnInit { private router = inject(Router); private systemService = inject(SystemService); private dialog = inject(MatDialog); + private alertService = inject(AlertService); /** Report Data. */ reportData: any; @@ -292,7 +294,13 @@ export class EditReportComponent implements OnInit { return reportParameter; }); } - this.systemService.updateReport(this.reportData.id, this.reportForm.value).subscribe(() => { + this.systemService.updateReport(this.reportData.id, this.reportForm.value).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Report update has been submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/system/manage-reports/view-report/view-report.component.ts b/src/app/system/manage-reports/view-report/view-report.component.ts index c28c8c6cd..038ccaae6 100644 --- a/src/app/system/manage-reports/view-report/view-report.component.ts +++ b/src/app/system/manage-reports/view-report/view-report.component.ts @@ -20,6 +20,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatCard, MatCardTitle, MatCardContent } from '@angular/material/card'; import { YesnoPipe } from '../../../pipes/yesno.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * View Report Component. @@ -40,6 +41,7 @@ export class ViewReportComponent { private systemService = inject(SystemService); private dialog = inject(MatDialog); private router = inject(Router); + private alertService = inject(AlertService); /** Report Data. */ reportData: any; @@ -66,7 +68,13 @@ export class ViewReportComponent { }); deleteReportDialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.systemService.deleteReport(this.reportData.id).subscribe(() => { + this.systemService.deleteReport(this.reportData.id).subscribe((deleteResponse: any) => { + if (deleteResponse?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Report deletion has been submitted for approval.' + }); + } this.router.navigate(['/system/reports']); }); } diff --git a/src/app/system/manage-surveys/create-survey/create-survey.component.ts b/src/app/system/manage-surveys/create-survey/create-survey.component.ts index 5316e1698..4cf60217e 100644 --- a/src/app/system/manage-surveys/create-survey/create-survey.component.ts +++ b/src/app/system/manage-surveys/create-survey/create-survey.component.ts @@ -30,6 +30,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatDivider } from '@angular/material/divider'; import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Create survey component. @@ -55,6 +56,7 @@ export class CreateSurveyComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); dialog = inject(MatDialog); + private alertService = inject(AlertService); /** Survey form. */ surveyForm: UntypedFormGroup; @@ -244,6 +246,12 @@ export class CreateSurveyComponent implements OnInit { countryCode: this.surveyForm.value.countryCode.toUpperCase() }); this.systemService.createSurvey(this.surveyForm.value).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Survey creation has been submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/system/manage-surveys/edit-survey/edit-survey.component.ts b/src/app/system/manage-surveys/edit-survey/edit-survey.component.ts index 89ebc7658..ac615eff9 100644 --- a/src/app/system/manage-surveys/edit-survey/edit-survey.component.ts +++ b/src/app/system/manage-surveys/edit-survey/edit-survey.component.ts @@ -34,6 +34,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatDivider } from '@angular/material/divider'; import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Edit survey component. @@ -59,6 +60,7 @@ export class EditSurveyComponent { private route = inject(ActivatedRoute); private router = inject(Router); dialog = inject(MatDialog); + private alertService = inject(AlertService); /** Survey form. */ surveyForm: UntypedFormGroup; @@ -299,6 +301,12 @@ export class EditSurveyComponent { this.systemService .editSurvey(this.route.snapshot.paramMap.get('id'), this.surveyForm.value) .subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Survey update has been submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/system/manage-surveys/manage-surveys.component.ts b/src/app/system/manage-surveys/manage-surveys.component.ts index eee9d8ed9..7afb60976 100644 --- a/src/app/system/manage-surveys/manage-surveys.component.ts +++ b/src/app/system/manage-surveys/manage-surveys.component.ts @@ -30,6 +30,7 @@ import { SystemService } from '../system.service'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; /** * Manage Surveys component. @@ -60,6 +61,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class ManageSurveysComponent implements OnInit { private route = inject(ActivatedRoute); private systemService = inject(SystemService); + private alertService = inject(AlertService); /* Surveys data */ surveysData: any; @@ -139,7 +141,14 @@ export class ManageSurveysComponent implements OnInit { * @param {any} survey Survey to activate. */ activate(survey: any) { - this.systemService.activateSurvey(survey.id).subscribe(() => { + this.systemService.activateSurvey(survey.id).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Survey activation has been submitted for approval.' + }); + return; + } const today = new Date().toISOString().split('T')[0]; // This mimics the server-side logic survey.validFrom = today; @@ -152,7 +161,14 @@ export class ManageSurveysComponent implements OnInit { * @param {any} survey Survey to deactivate. */ deactivate(survey: any) { - this.systemService.deactivateSurvey(survey.id).subscribe(() => { + this.systemService.deactivateSurvey(survey.id).subscribe((response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Survey deactivation has been submitted for approval.' + }); + return; + } const date = new Date(); date.setDate(date.getDate() - 1); // Set to yesterday const yesterday = date.toISOString().split('T')[0]; diff --git a/src/app/system/manage-user-agreement/manage-user-agreement.component.ts b/src/app/system/manage-user-agreement/manage-user-agreement.component.ts index d658defde..56ea3465e 100644 --- a/src/app/system/manage-user-agreement/manage-user-agreement.component.ts +++ b/src/app/system/manage-user-agreement/manage-user-agreement.component.ts @@ -15,6 +15,7 @@ import { forkJoin } from 'rxjs'; import { UserAgreementService, UserAgreement } from './user-agreement.service'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive'; +import { AlertService } from 'app/core/alert/alert.service'; @Component({ selector: 'mifosx-manage-user-agreement', @@ -34,6 +35,7 @@ export class ManageUserAgreementComponent implements OnInit { private service = inject(UserAgreementService); private dialog = inject(MatDialog); private snackBar = inject(MatSnackBar); + private alertService = inject(AlertService); agreements: UserAgreement[] = []; activeId: number | null = null; @@ -118,10 +120,18 @@ export class ManageUserAgreementComponent implements OnInit { body: this.model.body }) .subscribe({ - next: () => { - this.snackBar.open('Agreement published', 'Close', { duration: 3000 }); + next: (response: any) => { this.saving = false; this.drawerOpen = false; + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Agreement publication has been submitted for approval.' + }); + this.load(); + return; + } + this.snackBar.open('Agreement published', 'Close', { duration: 3000 }); this.load(); }, error: (err: any) => { diff --git a/src/app/system/manage-user-agreement/user-agreement.service.ts b/src/app/system/manage-user-agreement/user-agreement.service.ts index 05643d0e3..d92c852a7 100644 --- a/src/app/system/manage-user-agreement/user-agreement.service.ts +++ b/src/app/system/manage-user-agreement/user-agreement.service.ts @@ -35,7 +35,7 @@ export class UserAgreementService { } /** Publish a new version. The backend deactivates the previous active row automatically. */ - create(data: { title: string; body: string }): Observable<{ resourceId: number }> { - return this.http.post<{ resourceId: number }>('/useragreements', data); + create(data: { title: string; body: string }): Observable<{ resourceId: number; rollbackTransaction?: boolean }> { + return this.http.post<{ resourceId: number; rollbackTransaction?: boolean }>('/useragreements', data); } } From 59e1eeacc387b8137b70df1ef1133cf126cced8a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 28 Sep 2026 13:02:38 +0530 Subject: [PATCH 16/32] fix(accounting): show latest journal entries first by default The table fell back to the backend's own default order (entry_date, id ascending) until a maker clicked a column header, so the oldest entries sat at the top on every fresh load. Defaults the table's sort to id descending instead. Co-Authored-By: Claude Sonnet 5 --- .../search-journal-entry/search-journal-entry.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.html b/src/app/accounting/search-journal-entry/search-journal-entry.component.html index 6938ce4bd..f04105114 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.html +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.html @@ -539,7 +539,7 @@
- + # From 8d0d8822f0fc26c893644113a067488d8e1a2456 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 28 Sep 2026 13:03:01 +0530 Subject: [PATCH 17/32] feat(fd): convert Premature Close to a dialog, with its full calculation shown Was a full-page form at a dedicated route, reached by navigating away from the account -- now a popup dialog in the same visual language as the Payment/Renew dialog, just in this Danger Zone action's own red accent. Also fixes a real gap in the form it replaces: the premature amount/action options never loaded until a maker happened to touch the date picker, since the calculation was only ever triggered on value change, never on open. Added a breakdown panel showing exactly how the shown Maturity Amount was reached -- Deposit Amount, Contracted Interest Rate, Pre-Closure Penalty (when applicable), and the resulting Interest Earned -- using data the account-view page already had, since the calculation endpoint itself only ever returns the final number. Removed the now-dead full-page component/route target it replaces. Co-Authored-By: Claude Sonnet 5 --- .../premature-close-dialog.component.html | 194 +++++++++ .../premature-close-dialog.component.scss | 388 ++++++++++++++++++ .../premature-close-dialog.component.ts | 218 ++++++++++ .../fixed-deposit-account-view.component.ts | 39 +- ...ed-deposits-account-actions.component.html | 3 - ...ixed-deposits-account-actions.component.ts | 4 - ...lose-fixed-deposits-account.component.html | 105 ----- ...lose-fixed-deposits-account.component.scss | 11 - ...-close-fixed-deposits-account.component.ts | 179 -------- .../fixed-deposits/fixed-deposits.module.ts | 2 - 10 files changed, 836 insertions(+), 307 deletions(-) create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts delete mode 100644 src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.html delete mode 100644 src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.scss delete mode 100644 src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.ts diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html new file mode 100644 index 000000000..018b95331 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html @@ -0,0 +1,194 @@ + + +
+
+
+ +
+

{{ 'labels.heading.Premature Close' | translate }}

+

+ Close {{ data.accountNo ?? 'this account' }} before maturity -- interest is recalculated as of the date below, + usually at a lower, penalty rate. +

+
+
+ +
+ +
+ @if (loadFailed) { + + } @else { + + + {{ 'labels.inputs.Premature Close Date' | translate }} + + + + @if (prematureCloseForm.controls.closedOnDate.hasError('required')) { + + {{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + @if (loadingAmount) { +
+ + Calculating premature-closure amount... +
+ } @else { + @if (maturityAmount !== null && maturityAmount !== undefined) { +
+ @if (data.depositAmount) { +
+ Deposit Amount + {{ data.depositAmount | formatNumber }} +
+ } + @if (data.nominalAnnualInterestRate) { +
+ Contracted Interest Rate + {{ data.nominalAnnualInterestRate }}% +
+ } + @if (data.preClosurePenalApplicable) { +
+ Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }}) + − {{ data.preClosurePenalInterest }}% +
+ } + @if (interestEarned !== null) { +
+ Interest Earned (to {{ 'labels.inputs.Premature Close Date' | translate }}) + + {{ interestEarned | formatNumber }} +
+ } +
+ {{ 'labels.inputs.Maturity Amount' | translate }} + {{ maturityAmount | formatNumber }} +
+
+ } + + +
+ @for (closureOption of onAccountClosureOptions; track closureOption) { + + } +
+ @if ( + prematureCloseForm.controls.onAccountClosureId?.hasError('required') && + prematureCloseForm.controls.onAccountClosureId?.touched + ) { +
+ {{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} +
+ } + + @if (noDestinationAccounts) { + + } + + @if (prematureCloseForm.contains('toSavingsAccountId')) { + + {{ 'labels.inputs.Transfer to Savings' | translate }} + + @for (account of savingsAccountsData; track account) { + + {{ account.accountNo }} + + } + + @if (prematureCloseForm.controls.toSavingsAccountId?.hasError('required')) { + + {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + } + + @if (prematureCloseForm.contains('transferDescription')) { + + {{ 'labels.inputs.Transfer Description' | translate }} + + + } + + @if (prematureCloseForm.contains('note')) { + + {{ 'labels.inputs.Note' | translate }} + + + } + } + + @if (submitError) { + + } + + } +
+ +
+ + +
+
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss new file mode 100644 index 000000000..289c1380e --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss @@ -0,0 +1,388 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Premature Close Dialog Styles + * CBSNexora Core Banking System + * + * Same shape/spacing/typography as the Payment dialog (.pmt__*), in this Danger Zone action's own red + * accent on the header and Confirm button, rather than Payment's blue -- an irreversible, usually + * penalty-bearing action reads differently from a routine one, everywhere else stays identical so the + * two dialogs feel like the same product. + */ + +.pcd { + display: flex; + flex-direction: column; + height: 100%; + max-height: 92vh; + box-sizing: border-box; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: #0f172a; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%); +} + +.pcd__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 26px 30px; + background: linear-gradient(135deg, #7f1d1d 0%, #b91c1c 55%, #dc2626 100%); + color: #fff; + flex-shrink: 0; + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%); + pointer-events: none; + } + + &-left { + display: flex; + align-items: center; + gap: 16px; + position: relative; + z-index: 1; + } + + &-ico { + width: 48px; + height: 48px; + border-radius: 14px; + background: rgb(255 255 255 / 16%); + border: 1px solid rgb(255 255 255 / 25%); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: #fff; + flex-shrink: 0; + } + + &-text { + display: flex; + flex-direction: column; + } + + &-close { + position: relative; + z-index: 1; + background: rgb(255 255 255 / 14%); + border: none; + color: #fff; + width: 34px; + height: 34px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + + &:hover:not(:disabled) { + background: rgb(255 255 255 / 28%); + transform: rotate(90deg); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +.pcd__title { + margin: 0; + font-size: 20px; + font-weight: 800; + color: #fff; + line-height: 1.25; + letter-spacing: -0.01em; +} + +.pcd__subtitle { + margin: 5px 0 0; + font-size: 13px; + color: rgb(255 255 255 / 88%); + line-height: 1.4; +} + +.pcd__content { + padding: 26px 30px !important; + overflow-y: auto; + flex: 1 1 auto; + background: #fbfcfe; + + mat-form-field { + width: 100%; + } +} + +.pcd__loading { + display: flex; + align-items: center; + gap: 12px; + color: #64748b; + font-size: 13px; + padding: 14px 0 18px; +} + +.pcd__preview { + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 14px; + padding: 4px 18px; + margin: 0 0 18px; + + &-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 0; + border-bottom: 1px solid #f1f5f9; + + &:last-child { + border-bottom: none; + } + + &--highlight { + .pcd__preview-value { + color: #b91c1c; + font-weight: 800; + } + } + + &--total { + margin: 0 -18px; + padding: 14px 18px; + background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); + border-bottom: none; + border-radius: 0 0 12px 12px; + + .pcd__preview-label { + color: #b91c1c; + font-weight: 700; + } + + .pcd__preview-value { + color: #0f172a; + font-size: 18px; + font-weight: 800; + } + } + } + + &-label { + font-size: 12.5px; + color: #64748b; + font-weight: 600; + } + + &-value { + font-family: 'JetBrains Mono', 'DM Sans', monospace; + font-size: 14px; + font-weight: 700; + color: #0f172a; + } +} + +.pcd__section-label { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #475569; + margin: 10px 0 12px; +} + +/* Action Options Grid - 2 Equal Columns */ +.pcd__options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; + margin-bottom: 18px; + + @media (width <= 560px) { + grid-template-columns: 1fr; + } +} + +.pcd__option { + display: flex; + align-items: flex-start; + gap: 14px; + text-align: left; + background: #fff; + border: 1.5px solid #e2e8f0; + border-radius: 14px; + padding: 16px; + cursor: pointer; + font: inherit; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + + &:hover { + border-color: #fca5a5; + background: #f8fafc; + box-shadow: 0 6px 16px rgb(15 23 42 / 6%); + transform: translateY(-1px); + } + + &--active { + border-color: #dc2626; + background: #fef2f2; + box-shadow: 0 0 0 1.5px #dc2626; + + .pcd__option-ico { + background: #dc2626; + color: #fff; + border-color: #dc2626; + } + } + + &-ico { + width: 38px; + height: 38px; + min-width: 38px; + border-radius: 10px; + background: #f1f5f9; + border: 1px solid #e2e8f0; + color: #dc2626; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + flex-shrink: 0; + transition: all 0.2s ease; + } + + &-text { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + } + + &-title { + font-size: 14px; + font-weight: 700; + color: #0f172a; + line-height: 1.3; + } + + &-desc { + font-size: 12px; + color: #64748b; + line-height: 1.4; + } + + &-check { + color: #dc2626; + font-size: 17px; + position: absolute; + top: 14px; + right: 14px; + } +} + +.pcd__option-error { + color: #b91c1c; + font-size: 12px; + font-weight: 600; + margin: -10px 0 14px; +} + +.pcd__hint { + display: flex; + align-items: flex-start; + gap: 10px; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-left: 4px solid #0284c7; + border-radius: 10px; + color: #0369a1; + font-size: 13px; + line-height: 1.5; + padding: 12px 16px; + margin: 0 0 18px; + + &--warn { + background: #fffbeb; + border-color: #fde68a; + border-left-color: #d97706; + color: #b45309; + } +} + +.pcd__error { + display: flex; + align-items: flex-start; + gap: 10px; + background: #fef2f2; + border: 1px solid #fecaca; + border-left: 4px solid #ef4444; + border-radius: 10px; + color: #b91c1c; + font-size: 13px; + padding: 12px 16px; + margin: 10px 0; +} + +.pcd__actions { + padding: 18px 30px !important; + border-top: 1px solid #e2e8f0; + background: #fff; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + flex-shrink: 0; + + .pcd__btn-cancel { + border-radius: 10px; + font-weight: 600; + color: #475569; + } + + .pcd__btn-submit { + border-radius: 10px; + font-weight: 700; + padding: 0 26px; + height: 42px; + background: linear-gradient(135deg, #b91c1c 0%, #dc2626 100%); + color: #fff; + box-shadow: 0 8px 20px rgb(220 38 38 / 30%); + transition: all 0.2s ease; + + &:hover:not(:disabled) { + box-shadow: 0 10px 24px rgb(220 38 38 / 40%); + transform: translateY(-1px); + } + + &:disabled { + background: #cbd5e1; + box-shadow: none; + color: #64748b; + } + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts new file mode 100644 index 000000000..2122653e5 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts @@ -0,0 +1,218 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { CdkTextareaAutosize } from '@angular/cdk/text-field'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, Validators } from '@angular/forms'; + +/** Custom Services */ +import { FixedDepositsService } from '../../../fixed-deposits.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * "Premature Close" dialog -- Active-only, no reinvest option (the deposit hasn't matured, so there's + * nothing to renew): pay out in cash, or transfer the pre-maturity balance to a savings account. Same + * `.pcd__*` visual language as the Payment dialog ({@link PaymentDialogComponent}), in this Danger Zone + * action's own red accent rather than Payment's blue, since it's an irreversible, usually + * penalty-bearing action rather than a routine one. + *

+ * The premature amount is date-dependent (unlike Payment's one-shot template load): every change to + * Closed On Date re-calls `calculatePrematureAmount`, same as the full-page form this replaces did. + */ +@Component({ + selector: 'mifosx-premature-close-dialog', + templateUrl: './premature-close-dialog.component.html', + styleUrls: ['./premature-close-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatDialogClose, + CdkTextareaAutosize, + FaIconComponent, + FormatNumberPipe + ] +}) +export class PrematureCloseDialogComponent implements OnInit { + /** The `onAccountClosureId` codes Premature Close can offer -- same codes Payment/Close use. */ + private static readonly WITHDRAW_DEPOSIT_ID = 100; + private static readonly TRANSFER_TO_SAVINGS_ID = 200; + + private static readonly ACTION_ICONS: Record = { + [PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave', + [PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet' + }; + + private static readonly ACTION_DESCRIPTIONS: Record = { + [PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash', + [PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account' + }; + + dialogRef = inject>(MatDialogRef); + data: { + accountId: number | string; + accountNo?: string; + depositAmount?: number; + nominalAnnualInterestRate?: number; + preClosurePenalApplicable?: boolean; + preClosurePenalInterest?: number; + preClosurePenalInterestOnType?: { value: string }; + } = inject(MAT_DIALOG_DATA); + private formBuilder = inject(UntypedFormBuilder); + private fixedDepositsService = inject(FixedDepositsService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + prematureCloseForm: UntypedFormGroup; + onAccountClosureOptions: any[] = []; + savingsAccountsData: any[] = []; + maturityAmount: number = null; + loadingAmount = true; + loadFailed = false; + submitting = false; + submitError: string = null; + minDate = new Date(2000, 0, 1); + maxDate: Date; + + ngOnInit(): void { + this.maxDate = this.settingsService.businessDate; + this.prematureCloseForm = this.formBuilder.group({ + closedOnDate: [ + this.maxDate, + Validators.required + ] + }); + + this.prematureCloseForm + .get('closedOnDate') + .valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date)); + // Same date the form already starts with -- without this the amount/options never load unless the + // maker happens to touch the date picker, which the full-page form this replaces required too. + this.calculatePrematureAmount(this.maxDate); + } + + private calculatePrematureAmount(date: Date): void { + this.loadingAmount = true; + this.loadFailed = false; + const locale = this.settingsService.language.code; + const dateFormat = this.settingsService.dateFormat; + const data = { + closedOnDate: this.dateUtils.formatDate(date, dateFormat), + dateFormat, + locale + }; + this.fixedDepositsService + .executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data) + .subscribe({ + next: (response: any) => { + this.savingsAccountsData = response.savingsAccounts ?? []; + this.onAccountClosureOptions = response.onAccountClosureOptions ?? []; + this.maturityAmount = response.maturityAmount; + this.loadingAmount = false; + if (!this.prematureCloseForm.contains('onAccountClosureId')) { + this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required)); + this.prematureCloseForm.addControl('note', new UntypedFormControl('')); + this.prematureCloseForm + .get('onAccountClosureId') + .valueChanges.subscribe((id: number) => this.onClosureTypeChange(id)); + } + }, + // Without this the spinner would run forever on a failed load, leaving Cancel as the only way out. + error: () => { + this.loadingAmount = false; + this.loadFailed = true; + } + }); + } + + /** Adds/removes the destination-account control to match what the chosen type needs. */ + private onClosureTypeChange(id: number): void { + if (id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID) { + if (!this.prematureCloseForm.contains('toSavingsAccountId')) { + this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required)); + this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl('')); + } + } else { + this.prematureCloseForm.removeControl('toSavingsAccountId'); + this.prematureCloseForm.removeControl('transferDescription'); + } + } + + /** The gap between the deposit and the computed maturity amount -- the actual interest this premature + * closure earns, at whatever rate (contracted, or the lower pre-closure-penalty one below) applied. */ + get interestEarned(): number | null { + if (this.maturityAmount === null || this.maturityAmount === undefined || !this.data.depositAmount) { + return null; + } + return Number(this.maturityAmount) - Number(this.data.depositAmount); + } + + /** True when the chosen action needs a destination account but the customer has none to pick. */ + get noDestinationAccounts(): boolean { + return ( + !this.loadingAmount && + this.prematureCloseForm?.contains('toSavingsAccountId') && + this.savingsAccountsData.length === 0 + ); + } + + iconFor(id: number): string { + return PrematureCloseDialogComponent.ACTION_ICONS[id] ?? 'circle'; + } + + descriptionFor(id: number): string { + return PrematureCloseDialogComponent.ACTION_DESCRIPTIONS[id] ?? ''; + } + + selectClosureType(id: number): void { + this.prematureCloseForm.get('onAccountClosureId').setValue(id); + this.prematureCloseForm.get('onAccountClosureId').markAsTouched(); + } + + submit(): void { + const formValue = { ...this.prematureCloseForm.value }; + const locale = this.settingsService.language.code; + const dateFormat = this.settingsService.dateFormat; + if (formValue.closedOnDate instanceof Date) { + formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat); + } + const data = { ...formValue, dateFormat, locale }; + this.submitting = true; + this.submitError = null; + this.fixedDepositsService + .executeFixedDepositsAccountCommand(this.data.accountId as string, 'prematureClose', data) + .subscribe({ + next: () => { + this.dialogRef.close({ confirm: true }); + }, + // Keep the dialog open with the entered values and say what went wrong, rather than leaving a + // permanently disabled Confirm behind whatever modal the global interceptor threw up. + error: (error: any) => { + this.submitting = false; + this.submitError = + error?.error?.defaultUserMessage ?? + error?.error?.errors?.[0]?.defaultUserMessage ?? + 'This account could not be prematurely closed. Please review the details and try again.'; + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts index 42f39f6c5..a07dde825 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts @@ -17,6 +17,7 @@ import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-int import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component'; import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component'; import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component'; +import { PrematureCloseDialogComponent } from './custom-dialogs/premature-close-dialog/premature-close-dialog.component'; import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component'; /** Custom Button Config. */ @@ -238,7 +239,6 @@ export class FixedDepositAccountViewComponent implements OnInit { case 'Undo Activation': case 'Add Charge': case 'Withdrawn by Client': - case 'Premature Close': case 'Deposit': case 'Withdrawal': this.router.navigate([`actions/${name}`], { relativeTo: this.route }); @@ -267,12 +267,17 @@ export class FixedDepositAccountViewComponent implements OnInit { case 'Payment': this.openPaymentDialog(); break; + case 'Premature Close': + this.openPrematureCloseDialog(); + break; } } /** - * Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or - * Matured, and is the only place a Partial Reinvest can be done. + * Opens the Payment action -- only offered once the FD has actually Matured (see + * FixedDepositsButtonsConfiguration), and the only place a Partial Reinvest can be done. Also the + * exact same dialog the FD Dashboard's Renew action opens (see fd-dashboard.component.ts's + * openRenewDialog), just without dashboardRenewal data, so its date picker stays visible. */ private openPaymentDialog(): void { const paymentDialogRef = this.dialog.open(PaymentDialogComponent, { @@ -293,6 +298,34 @@ export class FixedDepositAccountViewComponent implements OnInit { }); } + /** + * Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't + * matured yet. + */ + private openPrematureCloseDialog(): void { + const prematureCloseDialogRef = this.dialog.open(PrematureCloseDialogComponent, { + width: '720px', + maxWidth: '95vw', + data: { + accountId: this.fixedDepositsAccountData.id, + accountNo: this.fixedDepositsAccountData.fdAccountNumber || this.fixedDepositsAccountData.accountNo, + // calculatePrematureAmount only ever returns the final maturityAmount, not how it was reached -- + // these come from the account's own data (already loaded for this page) so the dialog can show + // the breakdown (deposit, contracted rate, penalty, interest earned) behind that one number. + depositAmount: this.fixedDepositsAccountData.depositAmount, + nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate, + preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable, + preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest, + preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType + } + }); + prematureCloseDialogRef.afterClosed().subscribe((response: any) => { + if (response?.confirm) { + this.reload(); + } + }); + } + /** * Deletes Fixed Deposits Account. */ diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.html b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.html index f20e46af8..92b1d954b 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.html @@ -24,9 +24,6 @@ @if (actions['Add Charge']) { } -@if (actions['Premature Close']) { - -} @if (actions['Close']) { } diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.ts index e0b47a2f7..b587839e8 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-account-actions.component.ts @@ -15,7 +15,6 @@ import { ActivateFixedDepositsAccountComponent } from './activate-fixed-deposits import { UndoApprovalFixedDepositsAccountComponent } from './undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component'; import { WithdrawByClientFixedDepositsAccountComponent } from './withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component'; import { AddChargeFixedDepositsAccountComponent } from './add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component'; -import { PrematureCloseFixedDepositsAccountComponent } from './premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component'; import { CloseFixedDepositsAccountComponent } from './close-fixed-deposits-account/close-fixed-deposits-account.component'; import { FixedDepositsCashTransactionComponent } from './fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -35,7 +34,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; UndoApprovalFixedDepositsAccountComponent, WithdrawByClientFixedDepositsAccountComponent, AddChargeFixedDepositsAccountComponent, - PrematureCloseFixedDepositsAccountComponent, CloseFixedDepositsAccountComponent, FixedDepositsCashTransactionComponent ] @@ -52,7 +50,6 @@ export class FixedDepositsAccountActionsComponent { 'Undo Approval': boolean; 'Undo Activation': boolean; 'Add Charge': boolean; - 'Premature Close': boolean; 'Withdrawn by Client': boolean; Withdrawal: boolean; Deposit: boolean; @@ -64,7 +61,6 @@ export class FixedDepositsAccountActionsComponent { 'Undo Approval': false, 'Undo Activation': false, 'Add Charge': false, - 'Premature Close': false, 'Withdrawn by Client': false, Withdrawal: false, Deposit: false diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.html b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.html deleted file mode 100644 index 0d204538d..000000000 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.html +++ /dev/null @@ -1,105 +0,0 @@ - - -

- -
- -
- - {{ 'labels.inputs.Premature Close Date' | translate }} - - - - @if (prematureCloseAccountForm.controls.closedOnDate.hasError('required')) { - - {{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - @if (prematureCloseAccountForm.controls.maturityAmount) { - - {{ 'labels.inputs.Maturity Amount' | translate }} - - - } - - @if (prematureCloseAccountForm.contains('onAccountClosureId')) { - - {{ 'labels.inputs.Action' | translate }} - - @for (closureOption of onAccountClosureOptions; track closureOption) { - - {{ closureOption.value }} - - } - - @if (prematureCloseAccountForm.controls.onAccountClosureId.hasError('required')) { - - {{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - } - - @if (prematureCloseAccountForm.contains('toSavingsAccountId')) { - - {{ 'labels.inputs.Transfer to Savings' | translate }} - - @for (account of savingsAccountsData; track account) { - - {{ account.accountNo }} - - } - - @if (prematureCloseAccountForm.controls.toSavingsAccountId.hasError('required')) { - - {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - } - - @if (prematureCloseAccountForm.contains('transferDescription')) { - - {{ 'labels.inputs.Transfer Description' | translate }} - - - } - - @if (prematureCloseAccountForm.contains('note')) { - - {{ 'labels.inputs.Note' | translate }} - - - } -
-
- - - - - -
-
-
diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.scss b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.scss deleted file mode 100644 index 7c3f1555b..000000000 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.scss +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -.container { - max-width: 37rem; -} diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.ts deleted file mode 100644 index 5e4d5dc51..000000000 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -/** Angular Imports */ -import { Component, OnInit, inject } from '@angular/core'; -import { - UntypedFormGroup, - UntypedFormBuilder, - Validators, - UntypedFormControl, - ReactiveFormsModule -} from '@angular/forms'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; - -/** Custom Services */ -import { FixedDepositsService } from '../../fixed-deposits.service'; -import { SettingsService } from 'app/settings/settings.service'; -import { Dates } from 'app/core/utils/dates'; -import { CdkTextareaAutosize } from '@angular/cdk/text-field'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -/** - * Premature Close Fixed Deposits Account Component - */ -@Component({ - selector: 'mifosx-premature-close-fixed-deposits-account', - templateUrl: './premature-close-fixed-deposits-account.component.html', - styleUrls: ['./premature-close-fixed-deposits-account.component.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - CdkTextareaAutosize - ] -}) -export class PrematureCloseFixedDepositsAccountComponent implements OnInit { - private formBuilder = inject(UntypedFormBuilder); - private fixedDepositsService = inject(FixedDepositsService); - private dateUtils = inject(Dates); - private route = inject(ActivatedRoute); - private router = inject(Router); - private settingsService = inject(SettingsService); - - /** Minimum date allowed. */ - minDate = new Date(2000, 0, 1); - /** Maximum date allowed. */ - maxDate = new Date(); - /** Premature Close FD Account form. */ - prematureCloseAccountForm: UntypedFormGroup; - /** Savings Account Data */ - savingsAccountsData: any; - /** On account Closure Options */ - onAccountClosureOptions: any; - /** Fixed Deposits Account Id */ - accountId: any; - /** Form submission event */ - isSubmitted = false; - - /** - * @param {FormBuilder} formBuilder Form Builder - * @param {FixedDepositsService} fixedDepositsService Fixed Deposits Service - * @param {Dates} dateUtils Date Utils - * @param {ActivatedRoute} route Activated Route - * @param {Router} router Router - * @param {SettingsService} settingsService Settings Service - */ - constructor() { - this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId']; - } - - /** - * Creates the premature close fd account form. - */ - ngOnInit() { - this.maxDate = this.settingsService.businessDate; - this.createPrematureCloseAccountForm(); - this.buildDependencies(); - } - - /** - * Creates the premature close fd account form. - */ - createPrematureCloseAccountForm() { - this.prematureCloseAccountForm = this.formBuilder.group({ - closedOnDate: [ - '', - Validators.required - ] - }); - } - - /** - * Subscribes to value changes of parent control. - */ - buildDependencies() { - this.prematureCloseAccountForm.get('closedOnDate').valueChanges.subscribe((value: Date) => { - if (!this.isSubmitted) { - this.calculatePrematureAmount(value); - } - }); - } - - /** - * Calculates prematue amount based on closed on date. - * @param {Date} date Premature Close Date - */ - calculatePrematureAmount(date: Date) { - const locale = this.settingsService.language.code; - const dateFormat = this.settingsService.dateFormat; - const data = { - closedOnDate: this.dateUtils.formatDate(date, dateFormat), - dateFormat, - locale - }; - this.fixedDepositsService - .executeFixedDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data) - .subscribe((response: any) => { - this.savingsAccountsData = response.savingsAccounts; - this.onAccountClosureOptions = response.onAccountClosureOptions; - this.prematureCloseAccountForm.addControl( - 'maturityAmount', - new UntypedFormControl({ value: '', disabled: true }) - ); - this.prematureCloseAccountForm.addControl( - 'onAccountClosureId', - new UntypedFormControl('', Validators.required) - ); - this.prematureCloseAccountForm.addControl('note', new UntypedFormControl('')); - this.prematureCloseAccountForm.get('maturityAmount').patchValue(response.maturityAmount); - this.addTransferDetails(); - }); - } - - /** - * Subscribes to value changes of `onAccountClosureId` adds and removes transfer details accordingly. - */ - addTransferDetails() { - this.prematureCloseAccountForm.get('onAccountClosureId').valueChanges.subscribe((id: any) => { - if (id === 200) { - this.prematureCloseAccountForm.addControl( - 'toSavingsAccountId', - new UntypedFormControl('', Validators.required) - ); - this.prematureCloseAccountForm.addControl('transferDescription', new UntypedFormControl('')); - } else { - this.prematureCloseAccountForm.removeControl('toSavingsAccountId'); - this.prematureCloseAccountForm.removeControl('transferDescription'); - } - }); - } - - /** - * Submits the form and premature closes the fd account, - * if successful redirects to the fd account. - */ - submit() { - const prematureCloseAccountFormData = this.prematureCloseAccountForm.value; - this.isSubmitted = true; - const locale = this.settingsService.language.code; - const dateFormat = this.settingsService.dateFormat; - const prevClosedDate: Date = this.prematureCloseAccountForm.value.closedOnDate; - if (prematureCloseAccountFormData.closedOnDate instanceof Date) { - prematureCloseAccountFormData.closedOnDate = this.dateUtils.formatDate(prevClosedDate, dateFormat); - } - const data = { - ...prematureCloseAccountFormData, - dateFormat, - locale - }; - this.fixedDepositsService - .executeFixedDepositsAccountCommand(this.accountId, 'prematureClose', data) - .subscribe(() => { - this.router.navigate(['../../'], { relativeTo: this.route }); - }); - } -} diff --git a/src/app/deposits/fixed-deposits/fixed-deposits.module.ts b/src/app/deposits/fixed-deposits/fixed-deposits.module.ts index 6529b1b04..9aa9db4ba 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits.module.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits.module.ts @@ -35,7 +35,6 @@ import { ViewTransactionComponent } from './fixed-deposit-account-view/view-tran import { WaiveChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/waive-charge-dialog/waive-charge-dialog.component'; import { InactivateChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/inactivate-charge-dialog/inactivate-charge-dialog.component'; import { AddChargeFixedDepositsAccountComponent } from './fixed-deposits-account-actions/add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component'; -import { PrematureCloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component'; import { CloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component'; import { CreateFixedDepositAccountComponent } from './create-fixed-deposit-account/create-fixed-deposit-account.component'; import { FixedDepositAccountDetailsStepComponent } from './fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component'; @@ -78,7 +77,6 @@ import { FixedDepositsCashTransactionComponent } from './fixed-deposits-account- WaiveChargeDialogComponent, InactivateChargeDialogComponent, AddChargeFixedDepositsAccountComponent, - PrematureCloseFixedDepositsAccountComponent, CloseFixedDepositsAccountComponent, CreateFixedDepositAccountComponent, FixedDepositAccountDetailsStepComponent, From 3f6485f6529455163c2626b096594e6054e86aba Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 28 Sep 2026 13:03:47 +0530 Subject: [PATCH 18/32] feat(fd): restrict Payment to Matured accounts, unify its behavior with Dashboard Renew Payment showed for both Active and Matured FDs, even though an Active FD already has Premature Close for closing early. Now Matured-only. That also meant a genuinely matured, past-grace FD closed via the account's own Payment button (rather than the FD Dashboard's Renew action) silently skipped crediting its gap-period interest -- the date-locking and gap-interest preview/crediting were gated behind dashboard-only data, not the account's actual matured state. Both now key off the same renewal-preview response regardless of entry point, so Payment and Renew are functionally identical once an FD has matured, as they should be since they're already the same dialog component. Co-Authored-By: Claude Sonnet 5 --- .../payment-dialog.component.html | 12 ++-- .../payment-dialog.component.ts | 67 +++++++++++-------- .../fixed-deposits-buttons.config.ts | 11 +++ 3 files changed, 55 insertions(+), 35 deletions(-) diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html index 646c9f4f9..94cd1ea54 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html @@ -18,7 +18,7 @@ {{ isDashboardRenewalMode ? 'Renew ' + (dashboardRenewalRow?.accountNo ?? 'this matured FD') + '.' - : "Process this Fixed Deposit now -- works whether it's Active or Matured." + : 'Process this matured Fixed Deposit now.' }}

@@ -40,10 +40,10 @@
} @else {
- @if (isDashboardRenewalMode) { + @if (isMaturedRenewal) {

- {{ dashboardRenewalTimingText }} + {{ maturedRenewalTimingText }}

@if (renewalPreviewLoading) { @@ -97,7 +97,7 @@
{{ - isDashboardRenewalMode || !remainderIsApproximate + isMaturedRenewal || !remainderIsApproximate ? ('labels.inputs.Maturity Amount' | translate) : 'Amount available on the selected date' }} @@ -106,7 +106,7 @@
} - @if (!isDashboardRenewalMode) { + @if (!isMaturedRenewal) { {{ 'labels.inputs.Closed On Date' | translate }} } - @if (!isDashboardRenewalMode) { + @if (!isMaturedRenewal) { {{ 'labels.inputs.Note' | translate }} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts index 789d1e902..d481a87bf 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts @@ -133,29 +133,32 @@ export class PaymentDialogComponent implements OnInit { note: [''] }); - const dashboardRow = this.dashboardRenewalRow; - if (dashboardRow) { - // Computed from the grace window, never picked -- the date field stays in the form (the generic - // submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its picker is - // hidden in this mode (see the template), so it can't be moved off this value. - this.paymentForm - .get('closedOnDate') - .setValue(dashboardRow.withinGraceWindow ? new Date(dashboardRow.maturityDate) : this.maxDate); - this.gapInterestPending = !dashboardRow.withinGraceWindow; - - this.renewalPreviewLoading = true; - this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({ - next: (preview: any) => { - this.renewalPreview = preview; - this.renewalPreviewLoading = false; - }, - // Non-fatal -- the plain timing text above still explains what will happen even if the - // numeric breakdown couldn't be fetched; only the numbers are missing, not the action itself. - error: () => { - this.renewalPreviewLoading = false; + // Fetched every time this dialog opens, not just from the FD Dashboard -- Payment is now only ever + // offered once an FD has Matured (see FixedDepositsButtonsConfiguration), so a plain Payment on it + // is exactly the same "late renewal" scenario the Dashboard's Renew action handles, and needs the + // exact same date-locking/gap-interest treatment, or an FD closed this way instead of from the + // Dashboard would silently skip crediting interest it genuinely earned. + this.renewalPreviewLoading = true; + this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({ + next: (preview: any) => { + this.renewalPreview = preview; + this.renewalPreviewLoading = false; + if (preview?.isMatured) { + // Computed from the grace window, never picked -- the date field stays in the form (the + // generic submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its + // picker is hidden once matured (see the template), so it can't be moved off this value. + this.paymentForm + .get('closedOnDate') + .setValue(preview.withinGrace ? new Date(preview.maturityDate) : this.maxDate); + this.gapInterestPending = !preview.withinGrace; } - }); - } + }, + // Non-fatal -- Confirm still works from here (backend re-derives the same numbers on submit), + // only the numeric preview and its date-locking are unavailable, not the action itself. + error: () => { + this.renewalPreviewLoading = false; + } + }); this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id)); // The cap depends on whether the chosen date is on or after maturity, so a date change can make an @@ -195,13 +198,19 @@ export class PaymentDialogComponent implements OnInit { return this.data.dashboardRenewal?.row ?? null; } - /** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date in this - * mode, since the date itself is no longer visible to explain it. */ - get dashboardRenewalTimingText(): string { - const row = this.dashboardRenewalRow; - if (!row) return ''; - return row.withinGraceWindow - ? `Backdated to its original maturity date (${row.maturityDate}), at the currently active interest rate.` + /** True once the loaded preview confirms this FD has actually Matured -- which, since Payment is + * only ever offered on a Matured account, is every real open of this dialog now, from either entry + * point. Drives the date-locking and gap-interest preview/crediting identically either way. */ + get isMaturedRenewal(): boolean { + return !!this.renewalPreview?.isMatured; + } + + /** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date once + * matured, since the date itself is no longer visible to explain it. */ + get maturedRenewalTimingText(): string { + if (!this.renewalPreview) return ''; + return this.renewalPreview.withinGrace + ? `Backdated to its original maturity date (${this.renewalPreview.maturityDate}), at the currently active interest rate.` : `This FD's grace period has passed -- gap-period interest (for the days since maturity) will be ` + `credited at the linked account's rate first, then this renews from today.`; } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts index 76abd062d..9ce40ede6 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts @@ -110,7 +110,18 @@ export class FixedDepositsButtonsConfiguration { setOptions(status: string) { switch (status) { case 'Active': + this.optionArray = [ + { + name: 'Post Interest' + }, + { + name: 'Add Charge' + } + ]; + break; case 'Matured': + // Payment only makes sense once the deposit has actually matured -- an Active FD that needs + // closing early already has Premature Close for that. this.optionArray = [ { name: 'Post Interest' From eb278978bd4b6af8cda5b6a24363f491d4d04bd1 Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 28 Sep 2026 13:36:29 +0530 Subject: [PATCH 19/32] feat: update RBI reserve return form 1 & 9 UI and FD accounting mapping --- ...ount-interest-rate-chart-step.component.ts | 22 +- .../rbi-reserve-config.component.html | 196 ++--- .../rbi-reserve-config.component.scss | 20 +- .../rbi-reserve-config.component.ts | 34 +- ...posit-product-accounting-step.component.ts | 25 +- ...eposit-product-preview-step.component.html | 1 + .../fixed-deposit-general-tab.component.html | 1 + .../rbi-circulars/rbi-circulars.service.ts | 41 ++ .../reserve-return/form1-crr.component.ts | 2 + .../reserve-return/form9-slr.component.ts | 2 + .../reserve-return/reserve-return.base.ts | 240 +++++- .../reserve-return.component.html | 682 ++++++++++++------ .../reserve-return.component.scss | 198 ++++- ...-savings-accounting-details.component.html | 8 +- ...ew-savings-accounting-details.component.ts | 2 + src/assets/translations/en-US.json | 4 + src/environments/.env.ts | 4 +- 17 files changed, 1116 insertions(+), 366 deletions(-) diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts index a8ebfa8de..666f126d5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts @@ -334,17 +334,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit); if (this.principalAmount && this.principalAmount > 0) { - // Convert tenure to months for backend API. A Days tenure under 30 days rounds down to 0 - // months, which the backend rejects -- send it as tenureInDays instead in that case. + // Years and Months convert exactly; a Days tenure is sent as days and never converted. let tenureInMonths: number | undefined; let tenureInDays: number | undefined; if (this.tenurePeriodUnit === 'Years') { tenureInMonths = this.tenureValue * 12; } else if (this.tenurePeriodUnit === 'Months') { tenureInMonths = this.tenureValue; - } else if (enteredDays >= 30) { - tenureInMonths = Math.floor(enteredDays / 30); } else { + // Sent as days, whatever the count. Converting to whole months silently dropped the + // remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the + // screen still said "200 Days" above the figure. The backend takes days directly and + // works the calendar out itself. tenureInDays = enteredDays; } @@ -423,10 +424,17 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan } else { const n = 12 / compMonths; const effectiveMonths = tenureInMonths ?? enteredDays / 30; - const totalCompoundings = Math.floor(effectiveMonths / compMonths); const rPerCompounding = rate / (n * 100); - result.maturityAmount = - Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100; + // Whole compounding periods, then simple interest on the days left over. Flooring the + // periods and stopping there dropped the remainder entirely: a 200-day deposit + // compounding monthly quoted only six months of interest and lost the other twenty + // days, which is the depositor's money. + const wholePeriods = Math.floor(effectiveMonths / compMonths); + const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods); + const leftoverDays = enteredDays - wholePeriods * compMonths * 30; + const leftoverInterest = + leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0; + result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100; } result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100; this.matchResult = { ...result }; diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html index 0e5f05cb9..9c8f007c2 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html @@ -27,101 +27,104 @@ } @else { @if (showRates) { -
-

Reserve rates

-

- Rates are effective-dated rather than overwritten: a return filed for an earlier period keeps reporting the rate - that was actually in force then. Saving against a date that already has a rate edits it; a new date adds one. -

+
+

Reserve rates

+

+ Rates are effective-dated rather than overwritten: a return filed for an earlier period keeps reporting the + rate that was actually in force then. Saving against a date that already has a rate edits it; a new date adds + one. +

-
- @for (type of ['CRR', 'SLR', 'SAVINGS_DEMAND']; track type) { -
- - {{ type === 'SAVINGS_DEMAND' ? 'Savings demand share' : type }} in force today - - @if (currentRate(type); as rate) { - {{ rate.percentage }}% - @if (rate.seedDefault) { - - Placeholder — confirm the rate actually in force - - } @else { - since {{ rate.effectiveFrom }} - } - } @else { - Not set - {{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute - } -
- } -
- -
- - Rate - - CRR — Form 1 - SLR — Form 9 - - Savings — demand share - - - - - Percentage (%) - - - - - Effective from - - - - -
- - - - - - - - - - - - - @for (rate of rates; track rate.id) { - - - - - - - + } @else { + Not set + {{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute + } + } - -
RatePercentageEffective fromStatus
{{ rate.rateType }}{{ rate.percentage }}%{{ rate.effectiveFrom }} - @if (isFuture(rate)) { - Future-dated - } @else if (currentRate(rate.rateType)?.id === rate.id) { - In force - } @else { - Superseded - } +
+ @for (type of ['CRR', 'SLR', 'SAVINGS_DEMAND']; track type) { +
+ + {{ type === 'SAVINGS_DEMAND' ? 'Savings demand share' : type }} in force today + + @if (currentRate(type); as rate) { + {{ rate.percentage }}% @if (rate.seedDefault) { - Placeholder + + Placeholder — confirm the rate actually in force + + } @else { + since {{ rate.effectiveFrom }} } -
- -
-
+
+ +
+ + Rate + + CRR — Form 1 + SLR — Form 9 + + Savings — demand share + + + + + Percentage (%) + + + + + Effective from + + + + +
+ + + + + + + + + + + + + @for (rate of rates; track rate.id) { + + + + + + + + } + +
RatePercentageEffective fromStatus
+ {{ rate.rateType }} + {{ rate.percentage }}%{{ rate.effectiveFrom }} + @if (isFuture(rate)) { + Future-dated + } @else if (currentRate(rate.rateType)?.id === rate.id) { + In force + } @else { + Superseded + } + @if (rate.seedDefault) { + Placeholder + } + + +
+
} @@ -324,6 +327,17 @@ + +

+ Naming a head Memorandum - …, Annexure A - … or + Annexure B - … prints it under that section of the return instead of in the main + statement. +

+ diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss index 4b597578c..2660489ab 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss @@ -297,7 +297,7 @@ $red: #c5221f; &--active { border-color: $primary; - background: rgba(19, 83, 160, 0.06); + background: rgb(19 83 160 / 6%); color: $primary; } @@ -306,13 +306,13 @@ $red: #c5221f; font-weight: 500; padding: 1px 7px; border-radius: 10px; - background: rgba(176, 96, 0, 0.12); + background: rgb(176 96 0 / 12%); color: $amber; // A fully mapped return is worth stating as plainly as an incomplete one, so the tab row can // be read at a glance instead of by elimination. &--done { - background: rgba(19, 115, 51, 0.12); + background: rgb(19 115 51 / 12%); color: $green; } } @@ -379,6 +379,12 @@ $red: #c5221f; } } +// Spans the add-head form's columns rather than sitting in one of them. +.rrc-add-role__note { + flex-basis: 100%; + margin: 0 !important; +} + .rrc-remove { min-width: 0 !important; padding: 0 8px !important; @@ -397,7 +403,7 @@ $red: #c5221f; padding: 3px 10px; } -@media (max-width: 720px) { +@media (width <= 720px) { .rrc-rate-form__type, .rrc-rate-form__pct, .rrc-rate-form__date, @@ -419,7 +425,7 @@ $red: #c5221f; font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; - color: rgba(0, 0, 0, 0.55); + color: rgb(0 0 0 / 55%); font-weight: 600; padding: 0 0.5rem 0.25rem 0; } @@ -447,7 +453,7 @@ $red: #c5221f; /* An incomplete row is marked where it is, rather than only by the disabled Save button, so it is clear which row is holding the save back. */ &__row--invalid td { - background: rgba(214, 106, 0, 0.06); + background: rgb(214 106 0 / 6%); } } @@ -460,7 +466,7 @@ $red: #c5221f; .rrc-add { margin-top: 1.25rem; padding-top: 1rem; - border-top: 1px dashed rgba(0, 0, 0, 0.12); + border-top: 1px dashed rgb(0 0 0 / 12%); &__form { display: flex; diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts index 3499691c4..73a9c7de2 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts @@ -91,12 +91,19 @@ export class RbiReserveConfigComponent implements OnInit { { value: 'TIME', label: 'Time' } ]; - /** How a head an administrator adds is consumed by the return. */ + /** + * How a head an administrator adds is consumed by the return. + * + * MAINTAINED and SLR_ASSET are separate because the two returns admit different assets: CRR is + * met in cash and balances with banks, SLR in cash, gold and approved securities. One option for + * both would let a head meant for Form 1 quietly satisfy Form 9. + */ readonly roleUsages = [ - { value: 'LIABILITY', label: 'Liability — adds to the DTL base' }, - { value: 'ASSET_NET', label: 'Asset — netted off the base (NDTL)' }, - { value: 'MAINTAINED', label: 'Counts toward what is maintained' }, - { value: 'MEMO', label: 'Memo — shown but totals nothing' } + { value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1 & 9)' }, + { value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1 & 9)' }, + { value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1)' }, + { value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 9)' }, + { value: 'MEMO', label: 'Memo — shown on the return but totals nothing' } ]; /** New-head form. */ @@ -168,10 +175,12 @@ export class RbiReserveConfigComponent implements OnInit { } byGroup.get(group)!.roles.push(role); } - this.roleGroups = [...byGroup.entries()].map(([ - name, - entry - ]) => ({ name, label: entry.label, roles: entry.roles })); + this.roleGroups = [...byGroup.entries()].map( + ([ + name, + entry + ]) => ({ name, label: entry.label, roles: entry.roles }) + ); // Keep the open tab if it still exists, so saving a role does not throw the user back to the // first tab with their place lost. @@ -237,8 +246,7 @@ export class RbiReserveConfigComponent implements OnInit { // a bank look compliant when it is not. this.selection[role.configKey] = [ ...(this.selection[role.configKey] || []), - { glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null } - ]; + { glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null }]; } removeMapping(role: any, index: number): void { @@ -252,9 +260,7 @@ export class RbiReserveConfigComponent implements OnInit { */ availableAccounts(role: any, index: number): any[] { const rows = this.selection[role.configKey] || []; - const taken = new Set( - rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`) - ); + const taken = new Set(rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`)); const current = rows[index]; return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`)); } diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts index 921f9773e..0f6bb338f 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts @@ -308,6 +308,11 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha setRequired('incomeFromPenaltyAccountId', cashOrAccrual); setRequired('feesReceivableAccountId', accrualOnly); setRequired('penaltiesReceivableAccountId', accrualOnly); + // Required only on accrual, where there is no posting without it. On cash it stays optional but + // is offered, because interest posting routes through it when the product names one (see + // AccountingProcessorHelper.createInterestPostingJournalEntriesForSavings): the payable carries + // interest declared but not yet credited, and a product that names none collapses straight to + // Savings Control instead. setRequired('interestPayableAccountId', accrualOnly); setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled()); @@ -326,8 +331,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha rows: [ { key: 'savingsReferenceAccountId', - label: 'Savings Reference Account', - inputLabel: 'Savings reference account', + label: 'Fixed Deposit Reference Account', + inputLabel: 'Fixed deposit reference account', controlName: 'savingsReferenceAccountId', options: this.assetAccountOptions, required: true @@ -360,8 +365,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha rows: [ { key: 'savingsControlAccountId', - label: 'Savings Control Account', - inputLabel: 'Savings control account', + label: 'Fixed Deposit Control Account', + inputLabel: 'Fixed deposit control account', controlName: 'savingsControlAccountId', options: this.liabilityAccountOptions, required: true @@ -375,12 +380,16 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha required: true }, { + // Offered on cash accounting too, not only accrual: interest posting routes through + // this account whichever basis the product uses, so that interest declared but not yet + // credited to the member reads as the bank's own liability rather than appearing only + // when it is paid. Optional on cash - a product naming none posts straight to Savings + // Control as before. key: 'interestPayableAccountId', label: 'Interest Payable Account', inputLabel: 'Interest payable account', controlName: 'interestPayableAccountId', - options: this.liabilityAccountOptions, - accrualOnly: true + options: this.liabilityAccountOptions }, { key: 'escheatLiabilityId', @@ -402,8 +411,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha rows: [ { key: 'interestOnSavingsAccountId', - label: 'Interest On Savings Account', - inputLabel: 'Interest on savings account', + label: 'Interest On Fixed Deposits', + inputLabel: 'Interest on fixed deposits', controlName: 'interestOnSavingsAccountId', options: this.expenseAccountOptions, required: true diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html index e92b052fd..8a6431b90 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html @@ -481,6 +481,7 @@ } diff --git a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html index cb3324ea9..458eece5b 100644 --- a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html +++ b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html @@ -607,6 +607,7 @@ } diff --git a/src/app/rbi-circulars/rbi-circulars.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts index 6558ba987..38fd0c260 100644 --- a/src/app/rbi-circulars/rbi-circulars.service.ts +++ b/src/app/rbi-circulars/rbi-circulars.service.ts @@ -121,6 +121,47 @@ export class RbiCircularsService { return this.http.get('/ossreturn/oss2', { params: this.ossParams(quarterEndDate, officeId) }); } + // ── Saved returns ── + + /** + * Returns saved earlier, newest first. A saved return is served from the snapshot taken when it + * was saved, never recomputed — a backdated entry or a changed mapping would otherwise produce a + * different statement from the one that was reviewed. + */ + getSavedReturns(formCode?: string): Observable { + let params = new HttpParams(); + if (formCode) { + params = params.set('formCode', formCode); + } + return this.http.get('/rbiformafiling', { params }); + } + + /** One saved return, with the stored snapshot, so the screen renders what was saved. */ + getSavedReturn(filingId: number): Observable { + return this.http.get(`/rbiformafiling/${filingId}`); + } + + /** Keeps the return currently on screen. Re-saving the same form and date replaces it. */ + saveReturn(formCode: string, reportingDate: string, report: any, remarks?: string): Observable { + return this.http.post('/rbiformafiling', { + formCode, + reportingDate, + report, + remarks, + locale: 'en', + dateFormat: 'yyyy-MM-dd' + }); + } + + /** Moves a saved return along its filing lifecycle; the snapshot itself is untouched. */ + updateSavedReturnStatus(filingId: number, status: string, remarks?: string): Observable { + return this.http.post(`/rbiformafiling/${filingId}/status`, { status, remarks }); + } + + deleteSavedReturn(filingId: number): Observable { + return this.http.delete(`/rbiformafiling/${filingId}`); + } + private ossParams(quarterEndDate?: string, officeId?: number): HttpParams { let params = new HttpParams(); if (quarterEndDate) { diff --git a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts index 2db6c5c69..c5935ff16 100644 --- a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts +++ b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts @@ -30,6 +30,7 @@ import { ReserveReturnBase } from './reserve-return.base'; }) export class Form1CrrComponent extends ReserveReturnBase implements OnInit { protected readonly exportStem = 'RBI_Form1_CRR'; + protected readonly formCode = 'FORM_1'; protected fetch(reportingDate: string): Observable { return this.rbiService.getForm1(reportingDate); @@ -37,5 +38,6 @@ export class Form1CrrComponent extends ReserveReturnBase implements OnInit { ngOnInit(): void { this.load(); + this.loadSaved(); } } diff --git a/src/app/rbi-circulars/reserve-return/form9-slr.component.ts b/src/app/rbi-circulars/reserve-return/form9-slr.component.ts index 4dee07ffe..656b1fdc3 100644 --- a/src/app/rbi-circulars/reserve-return/form9-slr.component.ts +++ b/src/app/rbi-circulars/reserve-return/form9-slr.component.ts @@ -30,6 +30,7 @@ import { ReserveReturnBase } from './reserve-return.base'; }) export class Form9SlrComponent extends ReserveReturnBase implements OnInit { protected readonly exportStem = 'RBI_Form9_SLR'; + protected readonly formCode = 'FORM_9'; protected fetch(reportingDate: string): Observable { return this.rbiService.getForm9(reportingDate); @@ -37,5 +38,6 @@ export class Form9SlrComponent extends ReserveReturnBase implements OnInit { ngOnInit(): void { this.load(); + this.loadSaved(); } } diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts index 65642760c..efa7f5962 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts +++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts @@ -64,7 +64,12 @@ export interface SectionRow { * and topped up on the last one fails on the daily test while passing on the closing position. */ export interface FortnightMaintenance { - days: { date: string; amount: number }[]; + /** Each day of the month, with the requirement it was measured against and the gap. */ + days: { date: string; required: number; amount: number; surplus: number }[]; + /** The month the table covers. */ + periodStart?: string; + periodEnd?: string; + periodLabel?: string; dayCount: number; average: number; lowest: number; @@ -146,6 +151,9 @@ export abstract class ReserveReturnBase { /** Filename stem for the Excel export. */ protected abstract readonly exportStem: string; + /** Which return this screen is, so saved returns list only this one's. */ + protected abstract readonly formCode: string; + data: ReserveReturn | null = null; loading = true; errorMsg = ''; @@ -158,6 +166,33 @@ export abstract class ReserveReturnBase { toastVisible = false; private toastTimer: any; + /** Returns saved earlier, listed under the live one. */ + savedReturns: any[] = []; + savedLoading = false; + saving = false; + savedRemarks = ''; + + /** + * Which half of the screen is showing: the return itself, or the ones kept on record. + * + * Two tabs rather than one long page: the live return and the saved list are read at different + * times and for different reasons, and stacking them meant scrolling past the whole statement - + * including a month of daily rows - to reach the list at all. + */ + activeTab: 'report' | 'saved' = 'report'; + + showTab(tab: 'report' | 'saved'): void { + this.activeTab = tab; + } + + /** The saved return currently open below the list, or null when none is. */ + viewingSaved: any = null; + /** + * The opened snapshot's own statement, rendered in its own section. Held separately from `data` so + * the live return stays on screen alongside it and the two can be compared directly. + */ + savedData: ReserveReturn | null = null; + load(): void { this.loading = true; this.errorMsg = ''; @@ -224,6 +259,167 @@ export abstract class ReserveReturnBase { return Math.abs(this.data?.surplusOrShortfall ?? 0); } + // ── Saved returns ── + + loadSaved(): void { + this.savedLoading = true; + this.rbiService + .getSavedReturns(this.formCode) + .pipe(catchError(() => of([]))) + .subscribe((rows: any[]) => { + this.savedLoading = false; + this.savedReturns = rows || []; + }); + } + + /** Keeps the return on screen, so it can be read again later exactly as it stands now. */ + saveCurrent(): void { + if (!this.data) { + return; + } + this.saving = true; + this.rbiService + .saveReturn(this.data.formCode, this.data.reportingDate, this.data, this.savedRemarks || undefined) + .pipe( + catchError((err: any) => { + this.saving = false; + this.toast(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this return.'); + return of(null); + }) + ) + .subscribe((rows: any[] | null) => { + this.saving = false; + if (!rows) { + return; + } + this.savedReturns = rows; + this.savedRemarks = ''; + this.toast('Return saved.'); + }); + } + + /** + * Opens a saved return in its own section below the list, leaving the live return on screen. + * + * Deliberately not in place of the live one: the reason to open a saved return is almost always to + * compare it against where the bank stands now, and swapping the live figures out makes that + * comparison impossible without clicking back and forth from memory. + */ + viewSaved(row: any): void { + // Clicking the open one closes it, so the button toggles rather than re-fetching. + if (this.viewingSaved?.id === row.id) { + this.closeSaved(); + return; + } + this.rbiService + .getSavedReturn(row.id) + .pipe( + catchError(() => { + this.toast('Could not open this saved return.'); + return of(null); + }) + ) + .subscribe((saved: any) => { + if (!saved?.report) { + this.toast('This saved return has no stored statement.'); + return; + } + this.viewingSaved = saved; + this.savedData = saved.report as ReserveReturn; + // Opens on the Saved tab, where the list it was chosen from is - never jumping the user to + // the live return, which is a different statement. + this.activeTab = 'saved'; + setTimeout(() => document.getElementById('rr-saved-view')?.scrollIntoView({ behavior: 'smooth' }), 0); + }); + } + + /** Closes the opened snapshot. The live return was never hidden, so nothing is restored. */ + closeSaved(): void { + this.viewingSaved = null; + this.savedData = null; + } + + /** Kept for callers that still name the old action. */ + backToLive(): void { + this.closeSaved(); + } + + /** Magnitude of the opened snapshot's gap, so the sign lives in its label rather than the number. */ + get savedSurplus(): number { + return Math.abs(this.savedData?.surplusOrShortfall ?? 0); + } + + updateSavedStatus(row: any, status: string): void { + this.rbiService + .updateSavedReturnStatus(row.id, status) + .pipe( + catchError((err: any) => { + this.toast(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not update the status.'); + return of(null); + }) + ) + .subscribe((rows: any[] | null) => { + if (!rows) { + return; + } + this.savedReturns = rows; + if (this.viewingSaved?.id === row.id) { + this.viewingSaved = { ...this.viewingSaved, status }; + } + this.toast(`Marked ${this.statusLabel(status)}.`); + }); + } + + deleteSaved(row: any): void { + this.rbiService + .deleteSavedReturn(row.id) + .pipe( + catchError(() => { + this.toast('Could not remove this saved return.'); + return of(null); + }) + ) + .subscribe((rows: any[] | null) => { + if (!rows) { + return; + } + this.savedReturns = rows; + if (this.viewingSaved?.id === row.id) { + this.backToLive(); + } + this.toast('Saved return removed.'); + }); + } + + /** The stages a return moves through, in order, for the status menu. */ + readonly filingStatuses = [ + 'DRAFT', + 'GENERATED', + 'CHECKED', + 'APPROVED', + 'PROVISIONAL_SUBMITTED', + 'FINAL_SUBMITTED', + 'REVISED' + ]; + + statusLabel(status: string): string { + const labels: Record = { + DRAFT: 'Draft', + GENERATED: 'Generated', + CHECKED: 'Checked', + APPROVED: 'Approved', + PROVISIONAL_SUBMITTED: 'Provisional submitted', + FINAL_SUBMITTED: 'Final submitted', + REVISED: 'Revised' + }; + return labels[status] ?? status; + } + + /** Hands the rendered return to the browser's print dialog, which also prints to PDF. */ + printReturn(): void { + window.print(); + } + /** * True when a head takes only part of an account, so the printed figure is not that account's * balance. Anything at or above 100, or unset, is the whole balance and needs no explanation. @@ -268,17 +464,25 @@ export abstract class ReserveReturnBase { try { const wb = newWorkbook(); const sheet = wb.addWorksheet(this.data.formCode.replace('_', ' ')); + // Five columns because the daily table is the widest block on the sheet; the statement above + // it uses the first three and leaves the rest empty. sheet.columns = [ { width: 52 }, { width: 22 }, - { width: 20 }]; + { width: 20 }, + { width: 22 }, + { width: 16 }]; addBanner(sheet, `${this.data.formName} — ${this.data.frequency}`, 'C'); let start = sheet.rowCount + 1; this.addKeyValue(sheet, 'Reporting date', this.data.reportingDate); if (this.data.fortnightLabel) { - this.addKeyValue(sheet, 'Fortnight', `${this.data.fortnightLabel} (${this.data.fortnightStart} to ${this.data.fortnightEnd})`); + this.addKeyValue( + sheet, + 'Fortnight', + `${this.data.fortnightLabel} (${this.data.fortnightStart} to ${this.data.fortnightEnd})` + ); } this.addKeyValue(sheet, 'DTL base as at', this.data.basis.dtlAsOnDate); this.addKeyValue(sheet, 'Balances maintained as at', this.data.basis.holdingsAsOnDate); @@ -388,16 +592,26 @@ export abstract class ReserveReturnBase { this.addKeyValue(sheet, 'Average maintained', this.amount(m.average)); this.addKeyValue(sheet, 'Required average', this.amount(m.requiredAverage)); this.addKeyValue(sheet, 'Average test', m.averageMet ? 'Met' : 'NOT MET'); - this.addKeyValue(sheet, `Daily floor (${m.dailyMinimumPercent}% of requirement)`, this.amount(m.dailyMinimumAmount)); + this.addKeyValue( + sheet, + `Daily floor (${m.dailyMinimumPercent}% of requirement)`, + this.amount(m.dailyMinimumAmount) + ); this.addKeyValue(sheet, 'Lowest day', `${this.amount(m.lowest)} on ${m.lowestOn}`); this.addKeyValue(sheet, 'Daily minimum test', m.dailyMinimumMet ? 'Met' : 'NOT MET'); - this.addKeyValue(sheet, 'Days counted', `${m.dayCount}${m.fortnightComplete ? ' (full fortnight)' : ' (to reporting date)'}`); + this.addKeyValue( + sheet, + 'Days counted', + `${m.dayCount}${m.fortnightComplete ? ' (full fortnight)' : ' (to reporting date)'}` + ); boxAllCells(sheet, start, sheet.rowCount, 2); sheet.addRow([]); const dayHead = sheet.addRow([ 'Date', - 'Maintained (INR)', + 'Required (INR)', + 'Actual (INR)', + 'Surplus / (Shortfall)', 'Against floor' ]); dayHead.font = { bold: true }; @@ -406,13 +620,21 @@ export abstract class ReserveReturnBase { for (const day of m.days) { const row = sheet.addRow([ day.date, + day.required, day.amount, + day.surplus, day.amount < m.dailyMinimumAmount ? 'Below floor' : 'OK' ]); - row.getCell(2).numFmt = '#,##0.00'; - row.getCell(2).alignment = { horizontal: 'right' }; + for (const col of [ + 2, + 3, + 4 + ]) { + row.getCell(col).numFmt = '#,##0.00'; + row.getCell(col).alignment = { horizontal: 'right' }; + } } - boxAllCells(sheet, dayStart, sheet.rowCount, 3); + boxAllCells(sheet, dayStart, sheet.rowCount, 5); } // The Memorandum and Annexures, each under its own heading. diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html index 5dcc3af53..2f8dbc1a9 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.component.html +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html @@ -32,6 +32,14 @@ + +
+
+ + +
+ + @if (activeTab === 'report') { + - @if (data.unmetRequirements.length) { -
- - {{ data.computable ? 'Check this before filing.' : 'This return cannot be computed yet.' }} - -
    - @for (reason of data.unmetRequirements; track reason) { -
  • {{ reason }}
  • - } -
- - Open RBI Reserve Configuration → - -
- } + @if (data.unmetRequirements.length) { +
+ + {{ data.computable ? 'Check this before filing.' : 'This return cannot be computed yet.' }} + +
    + @for (reason of data.unmetRequirements; track reason) { +
  • {{ reason }}
  • + } +
+ + Open RBI Reserve Configuration → + +
+ } - -
-
-
- DTL base as at - {{ data.basis.dtlAsOnDate }} -
-
-
- Balances maintained as at - {{ data.basis.holdingsAsOnDate }} -
- @if (data.basis.rawLagDate) { -
- {{ data.basis.lagDays }}-day lag falls on - {{ data.basis.rawLagDate }} + +
+
+
+ DTL base as at + {{ data.basis.dtlAsOnDate }}
- } +
+
+ Balances maintained as at + {{ data.basis.holdingsAsOnDate }} +
+ @if (data.basis.rawLagDate) { +
+ {{ data.basis.lagDays }}-day lag falls on + {{ data.basis.rawLagDate }} +
+ } +
+

{{ data.basis.note }}

-

{{ data.basis.note }}

-
- - @if (data.computable) { -
- + @if (data.computable) { +
+ -
- NDTL base - {{ amount(data.ndtlAmount) }} - - DTL {{ amount(data.dtlAmount) }} less {{ amount(data.interBankAssets) }} · as at - {{ data.basis.dtlAsOnDate }} - -
-
- {{ data.rateType }} rate - {{ data.ratePercentage }}% - @if (data.rateIsSeedDefault) { - Placeholder — not confirmed - } @else { - in force on {{ data.reportingDate }} - } -
-
- Required - {{ amount(data.requiredAmount) }} - {{ data.ratePercentage }}% of NDTL -
-
- Maintained - {{ amount(data.maintainedAmount) }} - {{ percent(data.maintainedPercentage) }} of NDTL -
-
- {{ isShortfall ? 'Shortfall' : 'Surplus' }} - {{ amount(shortfallAmount) }} - {{ isShortfall ? 'Below requirement' : 'Above requirement' }} -
-
- } - - -
- - - - - - - - - - - @for (line of data.lines; track $index) { - - - - - - - @for (account of line.accounts; track account.glAccountId) { - - - - - - } - } - - @if (data.computable) { - - - - - - - - - - - - - - - - } - -
ParticularsAs atAmount (INR)
- {{ line.label }} - @if (!line.configured) { - Not configured - } - {{ line.asOnDate }} - @if (line.configured) { - {{ amount(line.amount) }} - } @else { - — - } -
- {{ account.glCode }} - {{ account.name }} - - @if (account.excluded) { - excluded — {{ account.excluded }} - } - - @if (isPartial(account)) { - - {{ account.weightPercentage }}% of {{ amount(account.accountBalance) }} - - } - - @if (account.liabilityClass) { - {{ account.liabilityClass | titlecase }} - } - {{ amount(account.balance) }}
{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL){{ amount(data.requiredAmount) }}
{{ data.rateType }} maintained{{ amount(data.maintainedAmount) }}
{{ isShortfall ? 'Shortfall' : 'Surplus' }}{{ amount(shortfallAmount) }}
-
- - - @if (data.maintenance; as m) { -
-

- Maintenance over the fortnight - @if (!m.fortnightComplete) { - In progress — {{ m.dayCount }} days so far - } -

-

- CRR is reckoned over the whole fortnight, not on one day: the full requirement must be held - on average, and at least {{ m.dailyMinimumPercent }}% of it on every single day. -

- -
-
- Average maintained - {{ amount(m.average) }} +
+ NDTL base + {{ amount(data.ndtlAmount) }} - {{ m.averageMet ? 'Meets' : 'Below' }} required {{ amount(m.requiredAverage) }} + DTL {{ amount(data.dtlAmount) }} less {{ amount(data.interBankAssets) }} · as at + {{ data.basis.dtlAsOnDate }}
-
- Lowest day - {{ amount(m.lowest) }} - - on {{ m.lowestOn }} · floor {{ amount(m.dailyMinimumAmount) }} - +
+ {{ data.rateType }} rate + {{ data.ratePercentage }}% + @if (data.rateIsSeedDefault) { + Placeholder — not confirmed + } @else { + in force on {{ data.reportingDate }} + }
- Days counted - {{ m.dayCount }} - {{ m.fortnightComplete ? 'Full fortnight' : 'To reporting date' }} + Required + {{ amount(data.requiredAmount) }} + {{ data.ratePercentage }}% of NDTL +
+
+ Maintained + {{ amount(data.maintainedAmount) }} + {{ percent(data.maintainedPercentage) }} of NDTL +
+
+ {{ isShortfall ? 'Shortfall' : 'Surplus' }} + {{ amount(shortfallAmount) }} + {{ isShortfall ? 'Below requirement' : 'Above requirement' }}
+ } + +
- - - + + + - @for (day of m.days; track day.date) { - - - - + + + + + + @for (account of line.accounts; track account.glAccountId) { + + + + + + } + } + + @if (data.computable) { + + + + + + + + + + + + + + }
DateMaintained (INR)Against floorParticularsAs atAmount (INR)
{{ day.date }}{{ amount(day.amount) }} - @if (day.amount < m.dailyMinimumAmount) { - Below floor - } @else { - OK + + @for (line of data.lines; track $index) { +
+ {{ line.label }} + @if (!line.configured) { + Not configured } {{ line.asOnDate }} + @if (line.configured) { + {{ amount(line.amount) }} + } @else { + — + } +
+ {{ account.glCode }} + {{ account.name }} + + @if (account.excluded) { + excluded — {{ account.excluded }} + } + + @if (isPartial(account)) { + + {{ account.weightPercentage }}% of {{ amount(account.accountBalance) }} + + } + + @if (account.liabilityClass) { + {{ account.liabilityClass | titlecase }} + } + {{ amount(account.balance) }}
{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL){{ amount(data.requiredAmount) }}
{{ data.rateType }} maintained{{ amount(data.maintainedAmount) }}
{{ isShortfall ? 'Shortfall' : 'Surplus' }}{{ amount(shortfallAmount) }}
- } - - @for (section of namedSections; track section.title) { - @if (section.rows.length) { + + @if (data.maintenance; as m) {
-

{{ section.title }}

-

{{ section.hint }}

+

+ Daily maintenance — {{ m.periodLabel || 'this month' }} + @if (!m.fortnightComplete) { + In progress — {{ m.dayCount }} days so far + } +

+

+ Every day of the month, against the requirement in force. CRR is reckoned over the period rather than on one + day: the full requirement must be held on average, and at least + {{ m.dailyMinimumPercent }}% of it on every single day. +

+ +
+
+ Average maintained + {{ amount(m.average) }} + + {{ m.averageMet ? 'Meets' : 'Below' }} required {{ amount(m.requiredAverage) }} + +
+
+ Lowest day + {{ amount(m.lowest) }} + on {{ m.lowestOn }} · floor {{ amount(m.dailyMinimumAmount) }} +
+
+ Days counted + {{ m.dayCount }} + {{ m.fortnightComplete ? 'Full fortnight' : 'To reporting date' }} +
+
+ - - + + + + + - @for (row of section.rows; track row.configKey) { - - + @for (day of m.days; track day.date) { + + + + + @@ -311,6 +305,250 @@
ParticularsAmount (INR)DateRequired (INR)Actual (INR)Surplus / (Shortfall)Against floor
- {{ row.label }} - @if (!row.configured) { - Not configured - } -
{{ day.date }}{{ amount(day.required) }}{{ amount(day.amount) }}{{ amount(day.surplus) }} - @if (row.configured) { - {{ amount(row.amount) }} + @if (day.amount < m.dailyMinimumAmount) { + Below floor } @else { - — + OK }
} + + + @for (section of namedSections; track section.title) { + @if (section.rows.length) { +
+

{{ section.title }}

+

{{ section.hint }}

+ + + + + + + + + @for (row of section.rows; track row.configKey) { + + + + + } + +
ParticularsAmount (INR)
+ {{ row.label }} + @if (!row.configured) { + Not configured + } + + @if (row.configured) { + {{ amount(row.amount) }} + } @else { + — + } +
+
+ } + } + } + } + + + @if (activeTab === 'saved') { +
+

+ Saved returns + @if (savedReturns.length) { + {{ savedReturns.length }} + } +

+

+ Returns kept for the record. Each opens exactly as it was saved — the stored statement, not a re-run of today's + ledger, so a figure already reported cannot change underneath it. +

+ + @if (data?.computable) { +
+ + Note (optional) + + + +
+ } + + @if (savedLoading) { +
Loading saved returns…
+ } @else if (!savedReturns.length) { +
Nothing saved yet. Generate a return and press Save to keep it.
+ } @else { + + + + + + + + + + + + + + + @for (row of savedReturns; track row.id) { + + + + + + + + + + + } + +
Reporting datePeriodNDTLRequiredMaintainedStatusSaved
{{ row.reportingDate }}{{ row.periodStart }} → {{ row.periodEnd }}{{ amount(row.ndtlAmount) }}{{ amount(row.requiredAmount) }}{{ amount(row.maintainedAmount) }} + + + @for (s of filingStatuses; track s) { + {{ statusLabel(s) }} + } + + + {{ row.createdOn }}
{{ row.createdBy }}
+ + +
+ } +
+ + + @if (viewingSaved && savedData; as _) { +
+
+
+

+ Saved return — {{ savedData.reportingDate }} + {{ statusLabel(viewingSaved.status) }} +

+

+ Saved {{ viewingSaved.createdOn }} by {{ viewingSaved.createdBy || 'unknown' }}. These are the figures as + they stood when it was saved — not today's ledger, which may have moved since. + @if (viewingSaved.remarks) { + “{{ viewingSaved.remarks }}” + } +

+
+ +
+ +
+
+ NDTL base + {{ amount(savedData.ndtlAmount ?? savedData.dtlAmount) }} + as at {{ savedData.basis.dtlAsOnDate }} +
+
+ {{ savedData.rateType }} rate + {{ savedData.ratePercentage }}% + as filed +
+
+ Required + {{ amount(savedData.requiredAmount) }} + {{ savedData.ratePercentage }}% of NDTL +
+
+ Maintained + {{ amount(savedData.maintainedAmount) }} + on {{ savedData.basis.holdingsAsOnDate }} +
+
+ {{ savedData.compliant ? 'Surplus' : 'Shortfall' }} + {{ amount(savedSurplus) }} + {{ savedData.compliant ? 'Above requirement' : 'Below requirement' }} +
+
+ + + + + + + + + + + @for (line of savedData.lines; track line.configKey) { + + + + + + @for (account of line.accounts; track account.glAccountId) { + + + + + + } + } + +
ParticularsAs atAmount (INR)
+ {{ line.label }} + {{ line.asOnDate }} + @if (line.configured) { + {{ amount(line.amount) }} + } @else { + — + } +
+ {{ account.glCode }} + {{ account.name }} + @if (isPartial(account)) { + + {{ account.weightPercentage }}% of {{ amount(account.accountBalance) }} + + } + @if (account.liabilityClass) { + {{ account.liabilityClass | titlecase }} + } + {{ amount(account.balance) }}
+ + @if (savedData.maintenance; as sm) { +

Daily maintenance as filed — {{ sm.periodLabel || '' }}

+ + + + + + + + + + + @for (day of sm.days; track day.date) { + + + + + + + } + +
DateRequiredActualSurplus / (Shortfall)
{{ day.date }}{{ amount(day.required) }}{{ amount(day.amount) }}{{ amount(day.surplus) }}
+ } +
} } diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.scss b/src/app/rbi-circulars/reserve-return/reserve-return.component.scss index 6577a52db..cdc132990 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.component.scss +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.scss @@ -129,6 +129,52 @@ $red: #c5221f; width: 180px; } +// ── Tabs ── + +.rr-tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid $border; + margin-bottom: 16px; +} + +.rr-tab { + display: inline-flex; + align-items: center; + gap: 8px; + background: none; + border: none; + border-bottom: 2px solid transparent; + padding: 10px 16px; + font-size: 13px; + font-weight: 600; + color: $gray-500; + cursor: pointer; + font-family: inherit; + + i { + font-size: 12px; + } + + &:hover { + color: $gray-900; + } + + &--on { + color: $primary; + border-bottom-color: $primary; + } + + &__count { + font-size: 10.5px; + font-weight: 700; + background: #e8f0fe; + color: $primary; + border-radius: 20px; + padding: 1px 8px; + } +} + // ── Basis ── .rr-basis { @@ -297,7 +343,7 @@ $red: #c5221f; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; - padding: 10px 10px; + padding: 10px; border-bottom: 1px solid $border; white-space: nowrap; } @@ -446,6 +492,154 @@ $red: #c5221f; font-weight: 600; } +// A shortfall on a day reads as one, rather than as a number with a minus sign in front. +.rr-neg { + color: $red; + font-weight: 600; +} + +// ── Saved returns ── + +.rr-empty { + padding: 22px; + text-align: center; + color: $gray-500; + font-size: 12.5px; +} + +.rr-save-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; + + &__note { + flex: 1 1 340px; + } +} + +.rr-saved { + &__period, + &__meta { + font-size: 11px; + color: $gray-500; + white-space: nowrap; + } + + &__status { + width: 170px; + } + + &__actions { + white-space: nowrap; + } + + &__view { + font-size: 12px !important; + min-width: 0 !important; + color: $primary !important; + } + + &__del { + min-width: 0 !important; + padding: 0 8px !important; + color: $gray-500 !important; + + &:hover { + color: $red !important; + } + } + + &__remarks { + display: block; + margin-top: 4px; + color: $gray-500; + } + + &--open td { + background: #e8f0fe; + } +} + +// The opened snapshot, in its own section. Tinted and edged so it reads as a different document +// from the live return above it rather than as more of the same page. +.rr-card--saved { + border-color: #c6dafc; + background: #fbfdff; + border-left: 4px solid $primary; +} + +.rr-saved-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.rr-saved__subhead { + margin: 20px 0 8px; + font-size: 13px; + font-weight: 600; + color: $gray-900; +} + +// ── Print ── +// The browser's print dialog is also its save-as-PDF, so one stylesheet serves both. Controls, +// navigation and the saved-returns list are dropped: a printed return is a statement, and a page +// carrying dead buttons reads as a screenshot of software rather than as a filing. +@media print { + .rr-noprint, + .rr-header__actions, + .rr-unmet__link { + display: none !important; + } + + .rr-container { + max-width: none; + padding: 0; + } + + .rr-card, + .rr-basis, + .rr-stat { + border-color: #999 !important; + box-shadow: none !important; + break-inside: avoid; + } + + .rr-card { + overflow: visible !important; + } + + // Header rows repeat on every printed page, so a table spilling over a page break stays readable. + .rr-table thead { + display: table-header-group; + } + + .rr-table tr { + break-inside: avoid; + } + + .rr-toast { + display: none !important; + } + + // Backgrounds carry meaning here — a day below the floor is shaded, not merely labelled — so they + // must survive the print engine's default of dropping them to save ink. + .rr-container, + .rr-card, + .rr-basis, + .rr-stat, + .rr-table, + .rr-table th, + .rr-table td { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } +} + .rr-badge { display: inline-block; margin-left: 8px; @@ -479,7 +673,7 @@ $red: #c5221f; z-index: 1000; } -@media (max-width: 720px) { +@media (width <= 720px) { .rr-header__actions { width: 100%; } diff --git a/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.html b/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.html index e121e3be3..7084e2a16 100644 --- a/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.html +++ b/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.html @@ -21,7 +21,7 @@
@@ -72,7 +72,7 @@
@@ -81,7 +81,7 @@
@@ -143,7 +143,7 @@
diff --git a/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.ts b/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.ts index 769771faf..0b3948c00 100644 --- a/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.ts +++ b/src/app/shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component.ts @@ -26,6 +26,8 @@ export class ViewSavingsAccountingDetailsComponent { @Input() accountingRule: OptionData; @Input() accountingMappings: any[] = []; + /** Product name for GL row titles (e.g. 'Fixed deposit'); omitted = savings wording. */ + @Input() productLabel?: string; isCashOrAccrualAccounting(): boolean { if (this.accountingRule) { diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index 9160611c2..2ce8822a5 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -2236,6 +2236,9 @@ "Interest free period": "Interest free period", "Interest method": "Interest method", "Interest on savings": "Interest on savings", + "Fixed deposit control": "Fixed deposit control", + "Fixed deposit reference": "Fixed deposit reference", + "Interest on fixed deposits": "Interest on fixed deposits", "Interest rate": "Interest Rate", "Interest recalculation compounding on": "Interest recalculation compounding on", "Interest waived on": "Interest waived on", @@ -2793,6 +2796,7 @@ "Savings Product": "Savings Product", "Savings SubType": "Savings SubType", "Savings transfers in suspense": "Savings transfers in suspense", + "Transfers in suspense": "Transfers in suspense", "Schedule Date": "Schedule Date", "Share Details": "Share Details", "Scheduler Jobs": "Scheduler Jobs", diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 44f95bdbb..6a375a6e4 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260924', - 'hash': 'a6e62201' + 'version': '260928', + 'hash': '6cbb31b5' }, 'allow_switching_backend_instance': true }; From 6afa79bd375e8502882299c1d0f3e2268ab2fe30 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 28 Sep 2026 17:53:50 +0530 Subject: [PATCH 20/32] fix: correct withdrawal-review account lookup, switch tenant to utkarshbank - Manager withdrawal-review screen always searched the Savings account type regardless of the request's actual account_type, so reviewing a Current-account withdrawal failed with "Account not found". Reuse the existing SEARCH_ACCOUNT_TYPE_TO_AC_TYP mapping (already used by the cashier lookup path) instead of a hardcoded 'SD'. - Point the UI tenant id at utkarshbank (was lincpay) to match the new backend tenant. Co-Authored-By: Claude Sonnet 5 --- .../transaction-entry/transaction-entry.component.ts | 7 ++++++- src/assets/env.js | 4 ++-- src/environments/environment.prod.ts | 4 ++-- src/environments/environment.ts | 4 ++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index aa4c8ca6f..940287102 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -6041,7 +6041,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.editRequest = req; } - const acTyp = 'SD'; + // Same bug, same fix as the token-driven withdrawal-slip lookup above (see its comment): the + // request carries its own real account_type (SAVINGS, CURRENT, FIXED_DEPOSIT or + // RECURRING_DEPOSIT), and hardcoding 'SD' here searched every non-savings account on the + // wrong endpoint, so reviewing a Current-account withdrawal always failed with "Account not + // found". + const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD'; const row = this.rows[0]; row.acNo = req.account_no ?? ''; row.acTyp = acTyp; diff --git a/src/assets/env.js b/src/assets/env.js index e7ec0e8c1..410cacdc0 100644 --- a/src/assets/env.js +++ b/src/assets/env.js @@ -20,8 +20,8 @@ // Tenants the login screen offers, and which one it starts on. Every identifier here has to exist // in the tenants table of the tenant-store database, or the platform rejects the request as an // unknown tenant. - window["env"]["fineractPlatformTenantId"] = 'lincpay'; - window["env"]["fineractPlatformTenantIds"] = 'lincpay'; + window["env"]["fineractPlatformTenantId"] = 'utkarshbank'; + window["env"]["fineractPlatformTenantIds"] = 'utkarshbank'; window['env']['tenantLogoUrl'] = ''; window['env']['tenantLogoUrlDark'] = ''; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index d7885aac8..3b02a7373 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -19,8 +19,8 @@ export const environment = { version: env.mifos_x.version, hash: env.mifos_x.hash, // For connecting to server running elsewhere update the tenant identifier - fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'lincpay', - fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'lincpay', + fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'utkarshbank', + fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'utkarshbank', // For connecting to others servers running elsewhere update the base API URL baseApiUrls: loadedEnv['fineractApiUrls'] || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin, diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 700ff10f9..2679b3c20 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -22,8 +22,8 @@ export const environment = { version: env.mifos_x.version, hash: env.mifos_x.hash, // For connecting to server running elsewhere update the tenant identifier - fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'lincpay', - fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'lincpay', + fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'utkarshbank', + fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'utkarshbank', // For connecting to others servers running elsewhere update the base API URL baseApiUrls: loadedEnv.fineractApiUrls || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin, From 18c3bb6356b7f21eb5560f6f86fd46213ffbbb5e Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 28 Sep 2026 18:18:35 +0530 Subject: [PATCH 21/32] fix(current-accounts): show bank/branch letterhead on passbook, matching savings Current account passbook hardcoded "LincPay Co-operative Bank Ltd." and had no branch identification line at all, unlike the savings passbook which pulls the bank name and branch/IFSC/MICR details from the Institution and Branch Profile. Wire up the same OrganizationService lookup so the current account statement shows real bank/branch details too. Co-Authored-By: Claude Sonnet 5 --- .../passbook-tab/passbook-tab.component.html | 23 +++++++- .../passbook-tab/passbook-tab.component.scss | 12 +++++ .../passbook-tab/passbook-tab.component.ts | 54 ++++++++++++++++++- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.html b/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.html index d9c5887a3..97a08deac 100644 --- a/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.html +++ b/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.html @@ -54,8 +54,8 @@ account_balance
-
LincPay Co-operative Bank Ltd.
-
Current Account Statement
+
{{ bankName }}
+
{{ bankSubtitle }}
@@ -64,6 +64,25 @@
+ + @if (branchName || ifscCode || micrCode) { +
+ @if (branchName) { + {{ branchName }} Branch + } + @if (branchAddress) { + {{ branchAddress }} + } + @if (ifscCode) { + IFSC: {{ ifscCode }} + } + @if (micrCode) { + MICR: {{ micrCode }} + } +
+ } +
+ + + @if (statutory.daily?.days?.length) { +
+

+ Day by day — {{ statutory.daily!.from }} to {{ statutory.daily!.to }} + @if (statutory.daily!.crrShortDays || statutory.daily!.slrShortDays) { + + @if (statutory.daily!.crrShortDays) { + CRR short on {{ statutory.daily!.crrShortDays }} of {{ statutory.daily!.dayCount }} days. + } + @if (statutory.daily!.slrShortDays) { + SLR short on {{ statutory.daily!.slrShortDays }} of {{ statutory.daily!.dayCount }} days. + } + + } +

+

+ Each day is charged on its own base, which moves at the fortnight boundary. A shortfall shows as a negative. +

+ +
+
+ CRR average held + {{ amount(statutory.daily!.crrAverage) }} + + lowest {{ amount(statutory.daily!.crrLowest) }} on {{ statutory.daily!.crrLowestOn }} + +
+
+ SLR average held + {{ amount(statutory.daily!.slrAverage) }} + + lowest {{ amount(statutory.daily!.slrLowest) }} on {{ statutory.daily!.slrLowestOn }} + +
+
+ Days counted + {{ statutory.daily!.dayCount }} + to {{ statutory.daily!.to }} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + @for (day of statutory.daily!.days; track day.date) { + + + + + + + + + + + + + + } + +
DateNet demand & time liabilitiesSection 18 — CRRSection 24 — SLR
On the dayCharged onBase amountRequiredMaintainedSurplus / (short)RequiredMaintainedSurplus / (short)
{{ day.date }} + {{ day.ownLiabilities === undefined ? '—' : amount(day.ownLiabilities) }} + {{ day.baseDate }} + {{ day.baseLiabilities === undefined ? '—' : amount(day.baseLiabilities) }} + {{ amount(day.crrRequired) }}{{ amount(day.crrMaintained) }}{{ amount(day.crrSurplus) }}{{ amount(day.slrRequired) }}{{ amount(day.slrMaintained) }}{{ amount(day.slrSurplus) }}
+
+ } } } @@ -831,10 +969,18 @@ Particulars + @for (col of saved.columns; track col; let i = $index) { {{ col }} - {{ i === 2 ? 'base for ratios' : 'fortnight close' }} + + {{ saved.columns.length > 2 && i === 2 ? 'base for ratios' : 'fortnight close' }} + + @if (saved.baseColumns?.[i]) { + charged on {{ saved.baseColumns[i] }} + } } diff --git a/src/app/system/system.service.ts b/src/app/system/system.service.ts index b5e69a37d..c93cf2564 100644 --- a/src/app/system/system.service.ts +++ b/src/app/system/system.service.ts @@ -421,6 +421,14 @@ export class SystemService { return this.http.get(`/businessdate/${dateType}`); } + /** + * @returns {Observable<{ today: string }>} The bank's "today" (yyyy-MM-dd): the business date while + * enable-business-date is on, the server's date while it is off. Readable by any signed-in user. + */ + getBusinessToday(): Observable<{ today: string }> { + return this.http.get<{ today: string }>('/branchday/today'); + } + /** * @param {any} dateData Business Date data to be updated. * @returns {Observable} diff --git a/src/app/tds-reports/tds-interest-export.spec.ts b/src/app/tds-reports/tds-interest-export.spec.ts new file mode 100644 index 000000000..0b4335614 --- /dev/null +++ b/src/app/tds-reports/tds-interest-export.spec.ts @@ -0,0 +1,138 @@ +/** + * @jest-environment-options {"customExportConditions": ["node", "node-addons"]} + */ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture'; +import { buildTdsInterestWorkbook } from './tds-interest-export'; +import { TdsDeductee, setTdsDeductees } from './tds-reports-data'; +import { tdsInterestRows, tdsInterestSummary } from './tds-reports.compute'; + +installTdsConfigFixture(); + +const FY = '2025-26'; +const Q = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-31']; + +const BOOK: TdsDeductee[] = [ + { + clientId: 1, + customerId: 'C1', + name: 'Ramesh', + pan: 'ABCPR1234K', + dob: '1980-05-01', + branchCode: 'Head Office', + declarations: {}, + accounts: [ + { + id: 11, + accountNo: 'FD11', + depositTypeCode: 'depositAccountType.fixedDeposit', + clientId: 1, + credits: Q.map((date) => ({ date, amount: 15_000 })), + deductions: [{ date: Q[3], amount: 6_000 }] + }, + { + id: 12, + accountNo: 'SB12', + depositTypeCode: 'depositAccountType.savingsDeposit', + clientId: 1, + credits: [{ date: Q[1], amount: 1_200 }], + deductions: [] + } + ] + }, + { + clientId: 4, + customerId: 'C4', + name: 'Kamala', + pan: 'ABCPK9012M', + dob: '1979-07-07', + branchCode: 'bhopal', + declarations: { [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } }, + accounts: [ + { + id: 41, + accountNo: 'FD41', + depositTypeCode: 'depositAccountType.fixedDeposit', + clientId: 4, + credits: Q.map((date) => ({ date, amount: 20_000 })), + deductions: [] + } + ] + } +]; + +function build() { + setTdsDeductees(BOOK); + const filter = { + fy: FY, + branchCode: '', + quarter: 0 as const, + band: '' as const, + search: '', + crossedOnly: false, + nearThresholdOnly: false + }; + const rows = tdsInterestRows(filter); + return buildTdsInterestWorkbook({ + fy: FY, + scopeLabel: 'All branches', + rules: { general: 50_000, senior: 100_000, rate: 10, rateNoPan: 20 }, + statement: 'Form 26Q', + rows, + declaredRows: rows.filter((r) => r.hasDeclaration), + summary: tdsInterestSummary(filter), + statusOf: () => ({ label: 'status' }) + }); +} + +describe('TDS register workbook', () => { + it('has the four sheets a reader expects, in order', () => { + expect(build().worksheets.map((w) => w.name)).toEqual(['Summary', 'Customers', 'Accounts', 'Form 121 given']); + }); + + it('lists one row per customer, with the figures as numbers and a live total', () => { + const ws = build().getWorksheet('Customers')!; + expect(ws.getRow(3).getCell(2).value).toBe('Customer name'); + const at = [4, 5].find((n) => ws.getRow(n).getCell(2).value === 'Ramesh')!; + const ramesh = ws.getRow(at); + expect(ramesh.getCell(2).value).toBe('Ramesh'); + expect(ramesh.getCell(7).value).toBe(60_000); + expect(ramesh.getCell(7).numFmt).toContain('#,##0.00'); + expect(ramesh.getCell(11).value).toBe(6_000); + expect(ramesh.getCell(12).value).toBe(6_000); + expect(ramesh.getCell(13).value).toEqual({ formula: `K${at}-L${at}` }); + const total = ws.getRow(6); + expect(total.getCell(2).value).toBe('Total'); + expect(total.getCell(7).value).toEqual({ formula: 'SUM(G4:G5)' }); + }); + + it('lists every account behind the figures, saying which ones count', () => { + const ws = build().getWorksheet('Accounts')!; + const byAccount = new Map([4, 5, 6].map((n) => [ws.getRow(n).getCell(4).value, ws.getRow(n)])); + expect([...byAccount.keys()].sort()).toEqual(['FD11', 'FD41', 'SB12']); + expect(byAccount.get('SB12')!.getCell(6).value).toBe('No'); + expect(byAccount.get('FD11')!.getCell(6).value).toBe('Yes'); + expect(byAccount.get('FD11')!.getCell(10).value).toBeInstanceOf(Date); + }); + + it('puts the declarants on their own sheet with the TDS the form saved', () => { + const ws = build().getWorksheet('Form 121 given')!; + const kamala = ws.getRow(4); + expect(kamala.getCell(2).value).toBe('Kamala'); + expect(kamala.getCell(7).value).toBe('UIN0001'); + expect(kamala.getCell(10).value).toBe(40_000); + expect(kamala.getCell(11).value).toBe(4_000); + }); + + it('writes a file Excel can open', async () => { + const buffer = await build().xlsx.writeBuffer(); + expect(buffer.byteLength).toBeGreaterThan(5_000); + }); +}); diff --git a/src/app/tds-reports/tds-interest-export.ts b/src/app/tds-reports/tds-interest-export.ts new file mode 100644 index 000000000..2a55151fe --- /dev/null +++ b/src/app/tds-reports/tds-interest-export.ts @@ -0,0 +1,379 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import * as ExcelJS from 'exceljs'; + +import { TdsAccountLine, TdsInterestRow, TdsInterestSummary } from './tds-reports.compute'; + +/** What the workbook needs from the screen, so it prints exactly what the user is looking at. */ +export interface TdsExportInput { + fy: string; + scopeLabel: string; + /** The financial-year rules, from Organization › TDS Configuration. */ + rules: { general: number; senior: number; rate: number; rateNoPan: number } | null; + /** The quarterly return the declarants' gross-paid interest is reported in. */ + statement: string; + rows: TdsInterestRow[]; + declaredRows: TdsInterestRow[]; + summary: TdsInterestSummary; + statusOf: (row: TdsInterestRow) => { label: string }; +} + +const MONEY = '"₹"#,##0.00'; +const DATE = 'dd-mmm-yyyy'; +const NAVY = 'FF1F4E79'; +const STRIPE = 'FFF3F6FA'; +const LINE = 'FFD0D7E2'; +const GREEN = 'FF15803D'; +const AMBER = 'FFB45309'; + +const ACCOUNT_TYPE: Record = { + FD: 'Fixed deposit', + RD: 'Recurring deposit', + SB: 'Savings', + OTHER: 'Other' +}; + +/** A column: its heading, width and, for figures, the cell format. */ +interface Col { + header: string; + width: number; + fmt?: string; + /** Totalled in the closing row. */ + sum?: boolean; +} + +/** + * The Deposit Interest & TDS register as a workbook a branch can read without the screen beside it: + * a one-page summary, one row per customer, one row per account, and the declarants on their own. + */ +export function buildTdsInterestWorkbook(input: TdsExportInput): ExcelJS.Workbook { + const wb = new ExcelJS.Workbook(); + wb.creator = 'CBS Nexora'; + wb.created = new Date(); + + summarySheet(wb, input); + customersSheet(wb, input); + accountsSheet(wb, input); + declarationsSheet(wb, input); + return wb; +} + +// ─────────────────────────────── Sheets ─────────────────────────────── + +function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { + const ws = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] }); + ws.columns = [ + { width: 46 }, + { width: 22 } + ]; + title(ws, `Deposit Interest & TDS — FY ${input.fy}`, 2); + ws.addRow([`Branch: ${input.scopeLabel}`]); + ws.addRow([`Prepared on: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}`]); + ws.getRow(2).font = { color: { argb: 'FF555555' } }; + ws.getRow(3).font = { color: { argb: 'FF555555' } }; + ws.addRow([]); + + const s = input.summary; + section(ws, 'Key figures'); + const figures: [string, number, string?][] = [ + ['Customers in this report', s.deductees], + [' of whom have an FD / RD being tracked', s.trackedCustomers], + ['FD / RD interest received this year', s.applicableInterest, MONEY], + ['Savings interest (shown only — no TDS)', s.exemptInterest, MONEY], + ['Customers over the TDS limit', s.crossed], + ['TDS due by the rules', s.totalTds, MONEY], + ['TDS actually deducted from accounts', s.actualTds, MONEY], + ['Difference (due − deducted) — should be 0', s.totalTds - s.actualTds, MONEY], + ['Customers with Form 121 / 15G / 15H', s.declarations], + ['TDS not deducted because of those forms', s.tdsSaved, MONEY], + ['Interest paid without TDS (report in the return)', s.paidWithoutDeduction, MONEY] + ]; + for (const [label, value, fmt] of figures) { + const r = ws.addRow([label, value]); + r.getCell(2).numFmt = fmt ?? '#,##0'; + r.getCell(2).alignment = { horizontal: 'right' }; + boxRow(r, 2); + } + // The control figure stands out when it is not zero. + const diffRow = ws.getRow(ws.rowCount - 3); + if (Math.round(s.totalTds - s.actualTds) !== 0) { + diffRow.font = { bold: true, color: { argb: AMBER } }; + } + ws.addRow([]); + + if (input.rules) { + section(ws, 'Rules for this year'); + const rules: [string, string][] = [ + ['TDS limit — below 60', rupees(input.rules.general)], + ['TDS limit — senior citizen (60+)', rupees(input.rules.senior)], + ['Rate with PAN', `${input.rules.rate}%`], + ['Rate without PAN', `${input.rules.rateNoPan}%`] + ]; + for (const [label, value] of rules) { + const r = ws.addRow([label, value]); + r.getCell(2).alignment = { horizontal: 'right' }; + boxRow(r, 2); + } + ws.addRow([]); + } + + section(ws, 'How to read this workbook'); + const notes = [ + 'Only interest from fixed and recurring deposits counts toward the limit — on the day it reaches savings, or when paid out at closure.', + "A savings account's own interest is shown for information and never attracts TDS.", + "Once a customer's interest for the year crosses the limit, TDS is due on the whole year's interest, not only the excess.", + 'A Form 121 / 15G / 15H stops TDS on interest received after the form was accepted.', + 'Sheet "Customers": one row per customer. Sheet "Accounts": every account behind those figures.', + 'Sheet "Form 121 given": customers with a declaration, and the TDS the form saved them.' + ]; + for (const n of notes) { + const r = ws.addRow([`• ${n}`]); + ws.mergeCells(r.number, 1, r.number, 2); + r.alignment = { wrapText: true, vertical: 'top' }; + r.height = 30; + } +} + +function customersSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { + const cols: Col[] = [ + { header: 'S.No', width: 6 }, + { header: 'Customer name', width: 26 }, + { header: 'Customer ID', width: 13 }, + { header: 'PAN', width: 13 }, + { header: 'Branch', width: 14 }, + { header: 'Age group', width: 12 }, + { header: 'FD/RD interest received (₹)', width: 16, fmt: MONEY, sum: true }, + { header: 'TDS limit (₹)', width: 13, fmt: MONEY }, + { header: 'Over the limit?', width: 10 }, + { header: 'Rate', width: 7 }, + { header: 'TDS due (₹)', width: 13, fmt: MONEY, sum: true }, + { header: 'TDS deducted (₹)', width: 13, fmt: MONEY, sum: true }, + { header: 'Difference (₹)', width: 13, fmt: MONEY, sum: true }, + { header: 'Form 121 given?', width: 10 }, + { header: 'Status', width: 42 } + ]; + const ws = table(wb, 'Customers', `Customers — FY ${input.fy} · ${input.scopeLabel}`, cols); + + input.rows.forEach((r, i) => { + const line = ws.addRow([ + i + 1, + r.deductee.name, + r.deductee.customerId, + r.deductee.pan || 'Not on record', + r.branchCode, + r.band === 'SENIOR' ? 'Senior (60+)' : 'Below 60', + r.tdsApplicableInterest, + r.threshold, + r.crossed ? 'Yes' : 'No', + `${Math.round(r.rateApplied * 100)}%`, + r.schedule.totalTds, + r.actualTds, + null, + r.hasDeclaration ? 'Yes' : 'No', + input.statusOf(r).label + ]); + // Due − deducted as a formula, so an edited figure keeps the check honest. + line.getCell(13).value = { formula: `K${line.number}-L${line.number}` }; + styleDataRow(line, cols, i); + if (!r.deductee.pan) { + line.getCell(4).font = { color: { argb: AMBER } }; + } + if (r.crossed) { + line.getCell(9).font = { bold: true, color: { argb: AMBER } }; + } + if (Math.round(r.schedule.totalTds - r.actualTds) !== 0) { + line.getCell(13).font = { bold: true, color: { argb: AMBER } }; + } + }); + totals(ws, cols, input.rows.length); +} + +function accountsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { + const cols: Col[] = [ + { header: 'S.No', width: 6 }, + { header: 'Customer name', width: 26 }, + { header: 'Customer ID', width: 13 }, + { header: 'Account no.', width: 19 }, + { header: 'Account type', width: 17 }, + { header: 'Counted for TDS?', width: 10 }, + { header: 'Why', width: 46 }, + { header: 'Interest this year (₹)', width: 15, fmt: MONEY, sum: true }, + { header: 'Credits', width: 8 }, + { header: 'Last credited', width: 13, fmt: DATE }, + { header: 'TDS deducted (₹)', width: 13, fmt: MONEY, sum: true }, + { header: 'Account held by', width: 22 } + ]; + const ws = table(wb, 'Accounts', `Accounts — FY ${input.fy} · ${input.scopeLabel}`, cols); + + let n = 0; + for (const r of input.rows) { + for (const a of r.accounts) { + const line = ws.addRow([ + n + 1, + r.deductee.name, + r.deductee.customerId, + a.accountNo, + ACCOUNT_TYPE[a.type], + a.tracked ? 'Yes' : 'No', + a.reason, + a.interest, + a.postings, + a.lastPostedOn ? isoToDate(a.lastPostedOn) : '—', + a.tdsDeducted, + a.holderName ?? 'Same customer' + ]); + styleDataRow(line, cols, n); + if (a.tracked) { + line.getCell(6).font = { bold: true, color: { argb: GREEN } }; + } + n++; + } + } + totals(ws, cols, n); +} + +function declarationsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { + const cols: Col[] = [ + { header: 'S.No', width: 6 }, + { header: 'Customer name', width: 26 }, + { header: 'Customer ID', width: 13 }, + { header: 'PAN', width: 13 }, + { header: 'Branch', width: 14 }, + { header: 'Form', width: 10 }, + { header: 'UIN', width: 18 }, + { header: 'Accepted on', width: 13, fmt: DATE }, + { header: 'FD/RD interest received (₹)', width: 16, fmt: MONEY, sum: true }, + { header: `Paid without TDS — report in ${input.statement} (₹)`, width: 20, fmt: MONEY, sum: true }, + { header: 'TDS not deducted because of the form (₹)', width: 18, fmt: MONEY, sum: true }, + { header: 'TDS deducted before the form (₹)', width: 16, fmt: MONEY, sum: true } + ]; + const ws = table(wb, 'Form 121 given', `Customers with Form 121 / 15G / 15H — FY ${input.fy} · ${input.scopeLabel}`, cols); + + input.declaredRows.forEach((r, i) => { + const line = ws.addRow([ + i + 1, + r.deductee.name, + r.deductee.customerId, + r.deductee.pan || 'Not on record', + r.branchCode, + `Form ${r.form}`, + r.declarationUin ?? 'Not allotted', + r.declarationAcceptedOn ? isoToDate(r.declarationAcceptedOn) : '—', + r.tdsApplicableInterest, + r.paidWithoutDeduction, + r.tdsSaved, + r.actualTds + ]); + styleDataRow(line, cols, i); + if (!r.declarationUin) { + line.getCell(7).font = { color: { argb: AMBER } }; + } + }); + if (input.declaredRows.length === 0) { + const r = ws.addRow(['', 'No customer has a declaration on record for this year.']); + r.font = { italic: true, color: { argb: 'FF777777' } }; + } else { + totals(ws, cols, input.declaredRows.length); + } +} + +// ─────────────────────────────── Styling ─────────────────────────────── + +/** A sheet with a title, a styled header row, frozen headings and a filter. */ +function table(wb: ExcelJS.Workbook, name: string, heading: string, cols: Col[]): ExcelJS.Worksheet { + const ws = wb.addWorksheet(name, { views: [{ state: 'frozen', ySplit: 3, showGridLines: false }] }); + ws.columns = cols.map((c) => ({ width: c.width })); + title(ws, heading, cols.length); + ws.addRow([]); + const head = ws.addRow(cols.map((c) => c.header)); + head.height = 34; + head.eachCell((cell) => { + cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } }; + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + cell.border = border(); + }); + ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: cols.length } }; + return ws; +} + +function title(ws: ExcelJS.Worksheet, text: string, span: number): void { + const r = ws.addRow([text]); + ws.mergeCells(r.number, 1, r.number, span); + r.font = { bold: true, size: 14, color: { argb: NAVY } }; + r.height = 24; +} + +function section(ws: ExcelJS.Worksheet, text: string): void { + const r = ws.addRow([text]); + r.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + r.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } }; + r.getCell(2).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } }; +} + +function styleDataRow(row: ExcelJS.Row, cols: Col[], index: number): void { + cols.forEach((c, i) => { + const cell = row.getCell(i + 1); + cell.border = border(); + cell.alignment = { vertical: 'middle', wrapText: i === cols.length - 1 || c.width > 30 }; + if (c.fmt) { + cell.numFmt = c.fmt; + cell.alignment = { ...cell.alignment, horizontal: 'right' }; + } + if (index % 2 === 1) { + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: STRIPE } }; + } + }); +} + +/** A closing row that totals the money columns with live SUM formulas. */ +function totals(ws: ExcelJS.Worksheet, cols: Col[], count: number): void { + if (count === 0) { + return; + } + const first = 4; + const last = first + count - 1; + const r = ws.addRow(cols.map((c, i) => (i === 1 ? 'Total' : null))); + cols.forEach((c, i) => { + const cell = r.getCell(i + 1); + if (c.sum) { + const letter = ws.getColumn(i + 1).letter; + cell.value = { formula: `SUM(${letter}${first}:${letter}${last})` }; + cell.numFmt = c.fmt ?? '#,##0'; + cell.alignment = { horizontal: 'right' }; + } + cell.font = { bold: true }; + cell.border = { top: { style: 'medium', color: { argb: NAVY } }, bottom: { style: 'thin', color: { argb: LINE } } }; + }); +} + +function boxRow(row: ExcelJS.Row, span: number): void { + for (let i = 1; i <= span; i++) { + row.getCell(i).border = border(); + } +} + +function border(): Partial { + const side = { style: 'thin' as const, color: { argb: LINE } }; + return { top: side, left: side, bottom: side, right: side }; +} + +function rupees(n: number): string { + return `₹${Math.round(n).toLocaleString('en-IN')}`; +} + +/** '2026-10-05' as a real date, so Excel sorts and filters it as one. */ +function isoToDate(iso: string): Date { + const [ + y, + m, + d + ] = iso.slice(0, 10).split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d)); +} diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html index f2e4c032e..d553115f6 100644 --- a/src/app/tds-reports/tds-interest-report.component.html +++ b/src/app/tds-reports/tds-interest-report.component.html @@ -49,8 +49,11 @@ account is which, and why.
  • - Only fixed and recurring deposits are tracked. Savings interest is shown for information but - never attracts TDS. + Only the interest from fixed and recurring deposits that reaches the customer's savings account + is tracked — counted on the day it arrives. The savings account's own interest is shown for information but + never attracts TDS. Interest still inside an FD/RD counts when it is paid out at maturity or closure — to + savings, in cash or to a GL (then the TDS comes out of the payout). A full reinvestment into a new FD/RD is not + taxed.
  • @if (rules) {
  • @@ -64,10 +67,14 @@ record.
  • } -
  • A customer who has submitted Form 15G/15H is not deducted, even over the limit.
  • - The TDS figures here are worked out by this report from the interest actually posted. They - are not debited from the customer's account automatically. + A customer who has submitted Form 15G/15H is not deducted on interest received after the form was accepted. +
  • +
  • + TDS is debited from the savings account at the moment the FD/RD interest that takes the + customer over the limit arrives, and credited to the TDS payable account. The FD/RD itself is never debited. + The TDS column shows what is due by these rules and what was actually deducted — the two + should match.
  • @@ -81,7 +88,7 @@
    ₹{{ summary.applicableInterest | number: '1.0-0' }}
    -
    FD / RD interest — counts toward the limit
    +
    FD / RD interest received in savings — counts toward the limit
    ₹{{ summary.exemptInterest | number: '1.0-0' }}
    @@ -93,7 +100,17 @@
    ₹{{ summary.totalTds | number: '1.0-0' }}
    -
    TDS to be deducted
    +
    TDS due by the rules
    +
    +
    +
    ₹{{ summary.actualTds | number: '1.0-0' }}
    +
    + Actually deducted from accounts + @if (summary.actualTds !== summary.totalTds) { + — ₹{{ summary.totalTds - summary.actualTds | number: '1.0-0' }} apart + } +
    @@ -194,7 +211,15 @@ [class.tdsr__tab--active]="selectedTab === 'paying'" (click)="selectTab('paying')" > - TDS to deduct ({{ payingRows.length }}) + TDS deducted / due ({{ payingRows.length }}) + +
    + @if (selectedTab === 'declared') { +
    + +
    + Customers who gave a declaration for FY {{ filter.fy }}. TDS not deducted is the tax that would + have been taken without the form; Paid without TDS is the interest received after the form was + accepted — it must still be reported in the {{ statute.statement }} return. Interest received before the form + stays taxable, and any TDS taken on it is shown as deducted before the form. +
    +
    + } +
    @@ -214,11 +251,19 @@ Customer Branch - Accounts - FD / RD interest this year - Limit - Status - TDS + @if (selectedTab === 'declared') { + Form · UIN · accepted on + FD / RD interest received · paid without TDS + Limit + Status + TDS not deducted · deducted before the form + } @else { + Accounts + FD / RD interest received this year + Limit + Status + TDS + } @@ -240,20 +285,32 @@
    {{ row.branchCode || '—' }} - - @if (row.trackedAccounts) { - {{ row.trackedAccounts }} tracked - } - @if (untrackedOf(row).length) { - {{ untrackedOf(row).length }} not tracked - } - - - ₹{{ row.tdsApplicableInterest | number: '1.0-0' }} - @if (row.exemptInterest) { -
    + ₹{{ row.exemptInterest | number: '1.0-0' }} savings (no TDS)
    - } - + @if (selectedTab === 'declared') { + + Form {{ row.form }} +
    {{ row.declarationUin || 'no UIN yet' }}
    +
    accepted {{ row.declarationAcceptedOn | date: 'd MMM y' }}
    + + + ₹{{ row.tdsApplicableInterest | number: '1.0-0' }} +
    ₹{{ row.paidWithoutDeduction | number: '1.0-0' }} paid without TDS
    + + } @else { + + @if (row.trackedAccounts) { + {{ row.trackedAccounts }} tracked + } + @if (untrackedOf(row).length) { + {{ untrackedOf(row).length }} not tracked + } + + + ₹{{ row.tdsApplicableInterest | number: '1.0-0' }} + @if (row.exemptInterest) { +
    + ₹{{ row.exemptInterest | number: '1.0-0' }} savings (no TDS)
    + } + + } ₹{{ row.threshold | number: '1.0-0' }} - @if (row.schedule.totalTds) { + @if (selectedTab === 'declared') { + ₹{{ row.tdsSaved | number: '1.0-0' }} +
    not deducted
    + @if (row.actualTds || row.schedule.totalTds) { +
    ₹{{ row.actualTds | number: '1.0-0' }} deducted before the form
    + } + } @else if (row.schedule.totalTds || row.actualTds) { ₹{{ row.schedule.totalTds | number: '1.0-0' }} -
    @ {{ row.rateApplied * 100 | number: '1.0-0' }}%
    +
    due @ {{ row.rateApplied * 100 | number: '1.0-0' }}%
    +
    + ₹{{ row.actualTds | number: '1.0-0' }} deducted +
    } @else if (row.tdsSaved) { ₹{{ row.tdsSaved | number: '1.0-0' }} saved } @else { @@ -298,15 +365,21 @@ Account no. Type Tracked for TDS? - Interest this year - Postings - Last posted + Interest this year (FD/RD: received in savings) + Credits + Last credited + TDS deducted @for (a of row.accounts; track a.accountNo) { - {{ a.accountNo }} + + {{ a.accountNo }} + @if (a.holderName) { +
    held by {{ a.holderName }}
    + } + {{ accountTypeLabel[a.type] }} @if (a.tracked) { @@ -319,7 +392,14 @@ ₹{{ a.interest | number: '1.2-2' }} {{ a.postings }} - {{ a.lastPostedOn ? (a.lastPostedOn | date: 'd MMM y') : 'No interest posted yet' }} + {{ a.lastPostedOn ? (a.lastPostedOn | date: 'd MMM y') : 'Nothing received yet' }} + + + @if (a.tdsDeducted) { + ₹{{ a.tdsDeducted | number: '1.2-2' }} + } @else { + — + } } @@ -418,6 +498,9 @@ Loading… } @else if (selectedTab === 'paying') { No customer needs TDS deducted for these filters. Check the "No TDS" tab. + } @else if (selectedTab === 'declared') { + No customer has a Form 121 / 15G / 15H on record for this year. Record one from the customer's + Form 121 tab. } @else { No customers match these filters. } diff --git a/src/app/tds-reports/tds-interest-report.component.ts b/src/app/tds-reports/tds-interest-report.component.ts index bf9ab9914..618066c16 100644 --- a/src/app/tds-reports/tds-interest-report.component.ts +++ b/src/app/tds-reports/tds-interest-report.component.ts @@ -10,11 +10,11 @@ import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; -import * as ExcelJS from 'exceljs'; import { BranchScopeService } from '../core/branch-scope.service'; import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; import { TdsDeductee, setTdsDeductees, tdsFys } from './tds-reports-data'; +import { buildTdsInterestWorkbook } from './tds-interest-export'; import { TdsReportsService } from './tds-reports.service'; import { NEAR_THRESHOLD_BAND, @@ -26,6 +26,9 @@ import { tdsInterestSummary } from './tds-reports.compute'; +/** The three answers the register gives: TDS taken, not taken because of a declaration, none due. */ +type TdsTab = 'paying' | 'declared' | 'not-paying'; + @Component({ selector: 'mifosx-tds-interest-report', templateUrl: './tds-interest-report.component.html', @@ -70,7 +73,7 @@ export class TdsInterestReportComponent implements OnInit { /** Which customer's quarter-wise detail is open. */ expandedId: string | null = null; exporting = false; - selectedTab: 'paying' | 'not-paying' = 'paying'; + selectedTab: TdsTab = 'paying'; /** Rows per page; 0 shows the whole list. */ readonly pageSizeOptions = [ @@ -92,6 +95,7 @@ export class TdsInterestReportComponent implements OnInit { private allRows: TdsInterestRow[] = []; private paying: TdsInterestRow[] = []; private notPaying: TdsInterestRow[] = []; + private declared: TdsInterestRow[] = []; private summaryCacheKey = ''; private cachedSummary: TdsInterestSummary | null = null; @@ -219,8 +223,28 @@ export class TdsInterestReportComponent implements OnInit { if (!row.trackedAccounts) { return { label: 'Not tracked — no FD/RD', tone: 'muted' }; } + if (row.hasDeclaration) { + const beforeForm = Math.round(row.schedule.totalTds - row.actualTds); + if (beforeForm > 0) { + return { label: `Form ${row.form} — ₹${beforeForm.toLocaleString('en-IN')} due from before the form, not yet deducted`, tone: 'warn' }; + } + if (row.tdsSaved > 0) { + return { label: `Form ${row.form} — TDS not deducted`, tone: 'ok' }; + } + return { label: `Form ${row.form} on record — below the limit so far`, tone: 'ok' }; + } if (row.schedule.totalTds > 0) { - return { label: 'Over the limit — TDS due', tone: 'warn' }; + const gap = Math.round(row.schedule.totalTds - row.actualTds); + if (gap > 0) { + return { label: `Over the limit — ₹${gap.toLocaleString('en-IN')} not yet deducted`, tone: 'warn' }; + } + if (gap < 0) { + return { label: `Deducted ₹${(-gap).toLocaleString('en-IN')} more than due — check`, tone: 'warn' }; + } + return { label: 'Over the limit — TDS deducted', tone: 'ok' }; + } + if (row.actualTds > 0) { + return { label: `₹${Math.round(row.actualTds).toLocaleString('en-IN')} deducted but none due — check`, tone: 'warn' }; } if (row.hasDeclaration && row.crossed) { return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' }; @@ -247,8 +271,12 @@ export class TdsInterestReportComponent implements OnInit { } this.rowsCacheKey = key; this.allRows = tdsInterestRows(this.filter); - this.paying = this.allRows.filter((r) => r.schedule.totalTds > 0); - this.notPaying = this.allRows.filter((r) => r.schedule.totalTds === 0); + // Three plain answers: whose TDS was taken, whose was not because they gave a declaration, and who + // owed none. A declarant whose tax was partly taken before the form is shown with the declarants, + // where both figures sit side by side. + this.declared = this.allRows.filter((r) => r.hasDeclaration); + this.paying = this.allRows.filter((r) => !r.hasDeclaration && (r.schedule.totalTds > 0 || r.actualTds > 0)); + this.notPaying = this.allRows.filter((r) => !r.hasDeclaration && r.schedule.totalTds === 0 && r.actualTds === 0); } get rows(): TdsInterestRow[] { @@ -266,6 +294,11 @@ export class TdsInterestReportComponent implements OnInit { return this.notPaying; } + get declaredRows(): TdsInterestRow[] { + this.ensureRows(); + return this.declared; + } + get summary(): TdsInterestSummary { const key = JSON.stringify(this.filter); if (key !== this.summaryCacheKey || !this.cachedSummary) { @@ -277,6 +310,9 @@ export class TdsInterestReportComponent implements OnInit { /** Every row of the tab on screen, before paging. */ get visibleRows(): TdsInterestRow[] { + if (this.selectedTab === 'declared') { + return this.declaredRows; + } return this.selectedTab === 'paying' ? this.payingRows : this.notPayingRows; } @@ -337,7 +373,7 @@ export class TdsInterestReportComponent implements OnInit { this.expandedId = null; } - selectTab(tab: 'paying' | 'not-paying'): void { + selectTab(tab: TdsTab): void { this.selectedTab = tab; this.onFilterChanged(); } @@ -385,105 +421,17 @@ export class TdsInterestReportComponent implements OnInit { async exportWorkbook(): Promise { this.exporting = true; try { - const wb = new ExcelJS.Workbook(); - const ws = wb.addWorksheet(`TDS Interest FY ${this.filter.fy}`); - - ws.addRow([`Deposit Interest & TDS — FY ${this.filter.fy}`]); - ws.addRow([`Scope: ${this.scopeLabel}`]); - ws.addRow([]); - ws.addRow([ - 'Customer', - 'CIF', - 'PAN', - 'Branch', - 'Accounts tracked for TDS', - 'Accounts not tracked', - 'Status', - 'Form', - 'Band', - 'Age', - 'Q1', - 'Q2', - 'Q3', - 'Q4', - 'Interest (FD/RD)', - 'Savings interest (exempt)', - 'Threshold', - 'Crossed in', - 'Rate %', - 'TDS payable', - 'Declaration', - 'UIN', - 'TDS saved', - 'Paid without deduction' - ]); - - for (const r of this.rows) { - ws.addRow([ - r.deductee.name, - r.deductee.customerId, - r.deductee.pan || 'NOT ON RECORD', - r.branchCode, - this.trackedOf(r) - .map((a) => `${a.accountNo} (${a.type})`) - .join(', '), - this.untrackedOf(r) - .map((a) => `${a.accountNo} (${a.type})`) - .join(', '), - this.statusOf(r).label, - r.form === '121' ? 'Form 121' : `Form ${r.form}`, - r.band === 'SENIOR' ? 'Senior citizen' : 'General', - r.age, - r.quarters[1], - r.quarters[2], - r.quarters[3], - r.quarters[4], - r.tdsApplicableInterest, - r.exemptInterest, - r.threshold, - r.crossedInQuarter ? `Q${r.crossedInQuarter}` : '—', - r.rateApplied * 100, - r.schedule.totalTds, - r.hasDeclaration ? 'Yes' : 'No', - r.declarationUin ?? '', - r.tdsSaved, - r.paidWithoutDeduction - ]); - } - - const s = this.summary; - ws.addRow([]); - ws.addRow([ - 'TOTAL', - `${s.deductees} deductees`, - '', - '', - `${s.trackedCustomers} tracked`, - '', - '', - '', - '', - '', - s.byQuarter[0].interest, - s.byQuarter[1].interest, - s.byQuarter[2].interest, - s.byQuarter[3].interest, - s.applicableInterest, - s.exemptInterest, - '', - '', - '', - s.totalTds, - `${s.declarations} filed`, - '', - s.tdsSaved, - s.paidWithoutDeduction - ]); - - ws.getRow(1).font = { bold: true, size: 13 }; - ws.getRow(4).font = { bold: true }; - ws.columns.forEach((c) => (c.width = 18)); - + const wb = buildTdsInterestWorkbook({ + fy: this.filter.fy, + scopeLabel: this.scopeLabel, + rules: this.rules, + statement: this.statute.statement, + // What the screen shows under the current filters, every tab included. + rows: this.rows, + declaredRows: this.declaredRows, + summary: this.summary, + statusOf: (row) => this.statusOf(row) + }); const buffer = await wb.xlsx.writeBuffer(); const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' @@ -491,7 +439,7 @@ export class TdsInterestReportComponent implements OnInit { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `TDS-Interest-FY${this.filter.fy}.xlsx`; + a.download = `TDS-Register-FY${this.filter.fy}.xlsx`; a.click(); URL.revokeObjectURL(url); } finally { diff --git a/src/app/tds-reports/tds-remittance-report.component.html b/src/app/tds-reports/tds-remittance-report.component.html index 4364cd3f9..48dd34e94 100644 --- a/src/app/tds-reports/tds-remittance-report.component.html +++ b/src/app/tds-reports/tds-remittance-report.component.html @@ -13,225 +13,329 @@

    TDS Remitted to Government

    - Challan-wise deposits and the {{ statute.statement }} year-end position · {{ scopeLabel }} · as at - {{ asAt | date: 'd MMM y' }} + TDS deducted from customers' deposits each month, and the challans Head Office paid it with · Bank as a whole + @if (asAt) { + · as at {{ asAt | date: 'd MMM y' }} + }
    -
    - - -
    + @if (isHeadOffice) { +
    + + + +
    + }
    - - @if (reconciled) { -
    - + @if (!isHeadOffice) { +
    +
    - Reconciled. - ₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ - summary.totalDeposited | number: '1.0-0' - }} - deposited, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date. + This report is for Head Office. TDS is paid to the Government by Head Office for the whole bank, + under one TAN. Your branch's customers and the TDS deducted from them are on + Reports › Deposit Interest & TDS.
    } @else { -
    - -
    - Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted. - Tax deducted must equal tax deposited plus tax still within its due date. Find the difference before filing the - return. + @if (loadError) { +
    + +
    {{ loadError }}
    -
    - } + } - @if (summary.overdueChallans > 0) { -
    - -
    - {{ summary.overdueChallans }} challan{{ summary.overdueChallans === 1 ? '' : 's' }} overdue. - Interest runs at 1.5% for every month or part of a month until the tax reaches the Government. + + @if (showForm) { +
    +
    Record a challan — FY {{ fy }}
    +
    + + + + + + + +
    +
    + Saving posts Dr TDS payable / Cr the account paid from at Head Office, dated the day it was + paid. +
    + @if (formError) { +
    + +
    {{ formError }}
    +
    + } +
    + + +
    -
    - } + } - -
    -
    -
    ₹{{ summary.totalDeducted | number: '1.0-0' }}
    -
    Total TDS deducted
    -
    -
    -
    ₹{{ summary.totalDeposited | number: '1.0-0' }}
    -
    Deposited with Government
    -
    -
    -
    ₹{{ summary.pendingDeposit | number: '1.0-0' }}
    -
    Within due date
    -
    -
    -
    ₹{{ totalDefaultInterest | number: '1.0-0' }}
    -
    Interest on late deposit
    -
    -
    -
    {{ summary.deductees | number }}
    -
    Deductees in the return
    -
    -
    - - -
    -
    Part A — challan-wise remittance
    -
    - - - - - - - - - - - - - - - - @for (row of rows; track trackRow($index, row)) { - - - - - - - - - - - - } - @if (rows.length === 0) { - - - - } - -
    QuarterDeduction monthAmountDue dateDeposited onChallan · BSRStatusLate-deposit interest{{ statute.statement }} due
    - {{ quarterLabel[row.quarter] }} - {{ row.month }} - ₹{{ row.amount | number: '1.0-0' }} - {{ row.dueDate | date: 'd MMM y' }} - @if (row.depositedOn) { - {{ row.depositedOn | date: 'd MMM y' }} - } @else { - — - } - - @if (row.challanNo) { - {{ row.challanNo }} -
    BSR {{ row.bsrCode }}
    - } @else { - — - } -
    - @if (row.overdue) { - Overdue - } @else if (row.late) { - Late by {{ row.delayMonths }}m - } @else if (row.depositedOn) { - On time - } @else { - Within due date - } - - @if (row.defaultInterest) { - ₹{{ row.defaultInterest | number: '1.0-0' }} -
    1.5% × {{ row.delayMonths }}
    - } @else { - — - } -
    {{ row.returnDueDate }}
    - No tax was deducted in this financial year. -
    -
    -
    - Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April. -
    -
    - - -
    -
    - Part B — {{ statute.statement }} year-end position -
    -
    -
    - Total interest paid on time deposits - ₹{{ summary.totalInterestPaid | number: '1.0-0' }} -
    -
    - with tax deducted - ₹{{ summary.interestWithDeduction | number: '1.0-0' }} -
    -
    - - without deduction — declaration on record - mandatory disclosure in {{ statute.statement }} - - ₹{{ summary.interestWithoutDeduction | number: '1.0-0' }} -
    - -
    - TDS at standard rate — PAN on record - ₹{{ summary.tdsAtStandardRate | number: '1.0-0' }} -
    -
    - TDS at higher rate — no PAN on record - ₹{{ summary.tdsAtHigherRate | number: '1.0-0' }} -
    - -
    - Total TDS deducted - ₹{{ summary.totalDeducted | number: '1.0-0' }} -
    -
    - Deposited with Government - ₹{{ summary.totalDeposited | number: '1.0-0' }} -
    -
    - Still within due date - ₹{{ summary.pendingDeposit | number: '1.0-0' }} -
    -
    - Difference — must be zero - ₹{{ summary.difference | number: '1.0-0' }} -
    - - @if (summary.defaultInterest > 0) { -
    - Interest payable by the bank for late deposit - ₹{{ summary.defaultInterest | number: '1.0-0' }} + @if (summary) { + + @if (reconciled) { +
    + +
    + Reconciled. + ₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ summary.totalDeposited | number: '1.0-0' }} + paid to the Government, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date. +
    +
    + } @else { +
    + +
    + Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted. + Tax deducted must equal tax paid plus tax still within its due date. + @if (summary.overdueAmount > 0) { + ₹{{ summary.overdueAmount | number: '1.0-0' }} is past its due date and not yet paid. + } +
    } + + @if (summary.calculatedTds !== summary.totalDeducted) { +
    + +
    + By the TDS rules ₹{{ summary.calculatedTds | number: '1.0-0' }} was due this year, but + ₹{{ summary.totalDeducted | number: '1.0-0' }} was actually deducted from accounts. Open + Deposit Interest & TDS to see which customers differ. +
    +
    + } + + +
    +
    +
    ₹{{ summary.totalDeducted | number: '1.0-0' }}
    +
    TDS deducted from accounts
    +
    +
    +
    ₹{{ summary.totalDeposited | number: '1.0-0' }}
    +
    Paid to Government
    +
    +
    +
    ₹{{ summary.pendingDeposit | number: '1.0-0' }}
    +
    To pay — still within due date
    +
    +
    +
    ₹{{ summary.overdueAmount | number: '1.0-0' }}
    +
    Overdue — pay now
    +
    +
    +
    ₹{{ summary.defaultInterest | number: '1.0-0' }}
    +
    Interest for paying late
    +
    +
    + } + + +
    +
    Part A — month by month
    +
    + + + + + + + + + + + + + + + + + @for (row of rows; track trackRow($index, row)) { + + + + + + + + + + + + + } + @if (rows.length === 0) { + + + + } + +
    QuarterDeduction monthDeductedPay byChallansPaidStill to payStatusLate interest
    {{ quarterLabel[row.quarter] }} + {{ row.month + '-01' | date: 'MMM y' }} +
    {{ row.postings }} deduction{{ row.postings === 1 ? '' : 's' }}
    +
    ₹{{ row.deducted | number: '1.0-2' }}{{ row.dueDate | date: 'd MMM y' }} + @for (c of row.challans; track c.id) { +
    + {{ c.challanNo }} · BSR {{ c.bsrCode }} · {{ c.depositedOn | date: 'd MMM y' }} · ₹{{ + c.amount | number: '1.0-2' + }} + +
    + } @empty { + — + } +
    ₹{{ row.deposited | number: '1.0-2' }} + @if (row.outstanding) { + ₹{{ row.outstanding | number: '1.0-2' }} + } @else if (row.excess) { + ₹{{ row.excess | number: '1.0-2' }} paid extra + } @else { + — + } + + @if (row.overdue) { + Overdue + } @else if (row.late) { + Paid late by {{ row.delayMonths }}m + } @else if (row.outstanding) { + Within due date + } @else { + Paid on time + } + + @if (row.defaultInterest) { + ₹{{ row.defaultInterest | number: '1.0-0' }} + } @else { + — + } + + @if (row.outstanding) { + + } +
    + + {{ loading ? 'Loading…' : 'No TDS was deducted from any account in this financial year.' }} +
    +
    +
    + Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April. + Interest runs at 1.5% for every month or part of a month it is late. +
    -
    - The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the - quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of - declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current - specification before generating the file. -
    -
    + + + @if (summary) { +
    +
    + Part B — {{ statute.statement }} year-end position +
    +
    +
    + Deductees (customers with an FD/RD) + {{ summary.deductees | number }} +
    +
    + Total interest paid on time deposits + ₹{{ summary.totalInterestPaid | number: '1.0-0' }} +
    +
    + with tax deducted + ₹{{ summary.interestWithDeduction | number: '1.0-0' }} +
    +
    + + without deduction — declaration on record + mandatory disclosure in {{ statute.statement }} + + ₹{{ summary.interestWithoutDeduction | number: '1.0-0' }} +
    + +
    + TDS at standard rate — PAN on record + ₹{{ summary.tdsAtStandardRate | number: '1.0-0' }} +
    +
    + TDS at higher rate — no PAN on record + ₹{{ summary.tdsAtHigherRate | number: '1.0-0' }} +
    + +
    + Total TDS deducted + ₹{{ summary.totalDeducted | number: '1.0-0' }} +
    +
    + Paid to Government + ₹{{ summary.totalDeposited | number: '1.0-0' }} +
    +
    + Still within due date + ₹{{ summary.pendingDeposit | number: '1.0-0' }} +
    +
    + Difference — must be zero + ₹{{ summary.difference | number: '1.0-0' }} +
    + + @if (summary.defaultInterest > 0) { +
    + Interest payable by the bank for late deposit + ₹{{ summary.defaultInterest | number: '1.0-0' }} +
    + } +
    +
    + The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the + quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of + declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current + specification before generating the file. +
    +
    + } + }
    diff --git a/src/app/tds-reports/tds-remittance-report.component.ts b/src/app/tds-reports/tds-remittance-report.component.ts index 9a1892cf0..d24ffc6be 100644 --- a/src/app/tds-reports/tds-remittance-report.component.ts +++ b/src/app/tds-reports/tds-remittance-report.component.ts @@ -6,17 +6,25 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import * as ExcelJS from 'exceljs'; +import { forkJoin } from 'rxjs'; import { BranchScopeService } from '../core/branch-scope.service'; import { FY_TAX_CONFIG, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TDS_AS_AT, tdsFys } from './tds-reports-data'; +import { TdsChallan, TdsDeductedMonth, setTdsDeductees, tdsFys } from './tds-reports-data'; +import { NewTdsChallan, TdsReportsService } from './tds-reports.service'; import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } from './tds-reports.compute'; +/** + * Tax deducted against tax remitted, month by month, for Head Office. + * + * The deducted side is the withhold-tax postings on deposits; the remitted side is the challans + * entered here. Both are records — nothing on this page is worked out from a rate. + */ @Component({ selector: 'mifosx-tds-remittance-report', templateUrl: './tds-remittance-report.component.html', @@ -27,22 +35,83 @@ import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } fro FormsModule ] }) -export class TdsRemittanceReportComponent { +export class TdsRemittanceReportComponent implements OnInit { private scope = inject(BranchScopeService); + private tdsService = inject(TdsReportsService); /** Filled by the route resolver from Organization › TDS Configuration. */ readonly financialYears = tdsFys(); readonly quarterLabel = QUARTER_LABEL; - readonly asAt = TDS_AS_AT; fy = tdsFys()[0] ?? ''; exporting = false; + loading = false; + loadError = ''; + + /** The business date the platform is on — what "overdue" is judged against. */ + asAt = ''; + private deducted: TdsDeductedMonth[] = []; + challans: TdsChallan[] = []; + rows: TdsRemittanceRow[] = []; + summary: Tds26qSummary | null = null; + + paidFromOptions: { id: number; glCode: string; name: string }[] = []; + showForm = false; + saving = false; + formError = ''; + form: NewTdsChallan = this.blankForm(); + + /** Challans are remitted by Head Office under the bank's one TAN; a branch has nothing to enter here. */ + get isHeadOffice(): boolean { + return this.scope.isHeadOffice; + } + + ngOnInit(): void { + if (!this.isHeadOffice || !this.fy) { + return; + } + this.load(); + this.tdsService.getChallanTemplate().subscribe({ + next: (t) => (this.paidFromOptions = t?.paidFromOptions || []), + error: () => (this.paidFromOptions = []) + }); + } + + load(): void { + this.loading = true; + this.loadError = ''; + const openingYear = Number(this.fy.slice(0, 4)); + forkJoin({ + deducted: this.tdsService.getDeducted(this.fy), + challans: this.tdsService.getChallans(this.fy), + // Part B summarises the year per customer, so the interest register is loaded too. + deductees: this.tdsService.getDepositInterest(openingYear) + }).subscribe({ + next: ({ deducted, challans, deductees }) => { + this.loading = false; + this.asAt = deducted.asAt; + this.deducted = deducted.months; + this.challans = challans; + setTdsDeductees(deductees); + this.recompute(); + }, + error: (err: any) => { + this.loading = false; + this.loadError = err?.error?.defaultUserMessage || 'Could not load the remittance position.'; + } + }); + } + + private recompute(): void { + this.rows = tdsRemittanceRows(this.fy, this.deducted, this.challans, this.asAt); + this.summary = tds26qSummary(this.fy, this.rows, this.asAt); + } + + onYearChanged(): void { + this.showForm = false; + this.load(); + } - /** - * Provision and return references for the selected year. The quarterly return - * became Form 140 under the 2025 Act, so the labels are read from the config - * rather than written into the template. - */ get statute(): { section: string; statement: string; frequency: string } { const cfg = FY_TAX_CONFIG[this.fy]; return { @@ -52,53 +121,85 @@ export class TdsRemittanceReportComponent { }; } - get scopeLabel(): string { - return this.scope.isHeadOffice ? 'Bank as a whole' : this.scope.officeName; - } - - get rows(): TdsRemittanceRow[] { - return tdsRemittanceRows(this.fy, this.asAt); - } - - get summary(): Tds26qSummary { - return tds26qSummary(this.fy, this.asAt); - } - - /** - * The control that matters. Tax deducted must equal tax deposited plus tax - * still within its due date. Anything else means something has gone missing - * and must be found before the return is filed. - */ get reconciled(): boolean { - return this.summary.difference === 0; + return !!this.summary && Math.abs(this.summary.difference) < 1; } - get totalDefaultInterest(): number { - return this.summary.defaultInterest; + /** Opens the challan form, pre-filled for a month that still has tax outstanding. */ + openForm(row?: TdsRemittanceRow): void { + this.form = this.blankForm(); + if (row) { + this.form.month = row.month; + this.form.amount = row.outstanding; + } + this.formError = ''; + this.showForm = true; + } + + saveChallan(): void { + this.formError = ''; + const f = this.form; + if (!f.month || !f.amount || !f.challanNo || !f.bsrCode || !f.depositedOn || !f.paidFromGlAccountId) { + this.formError = 'Fill in every field marked *.'; + return; + } + this.saving = true; + this.tdsService.createChallan({ ...f, fy: this.fy, amount: Number(f.amount) }).subscribe({ + next: (challans) => { + this.saving = false; + this.showForm = false; + this.challans = challans; + this.recompute(); + }, + error: (err: any) => { + this.saving = false; + this.formError = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.'; + } + }); + } + + reverseChallan(c: TdsChallan): void { + if (!confirm(`Reverse challan ${c.challanNo} for ₹${c.amount}? Its accounting entry will be undone.`)) { + return; + } + this.tdsService.reverseChallan(c.id).subscribe({ + next: (challans) => { + this.challans = challans; + this.recompute(); + }, + error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not reverse the challan.') + }); + } + + private blankForm(): NewTdsChallan { + return { fy: this.fy, month: '', amount: 0, challanNo: '', bsrCode: '', depositedOn: '', paidFromGlAccountId: 0, remarks: '' }; } trackRow(_: number, row: TdsRemittanceRow): string { - return `${row.fy}-${row.quarter}`; + return row.month; } async exportWorkbook(): Promise { + const s = this.summary; + if (!s) { + return; + } this.exporting = true; try { - const s = this.summary; const wb = new ExcelJS.Workbook(); const challan = wb.addWorksheet('Challans'); challan.addRow([`TDS remitted to Government — FY ${this.fy}`]); - challan.addRow([`Scope: ${this.scopeLabel} · as at ${this.asAt}`]); + challan.addRow([`Bank as a whole · as at ${this.asAt}`]); challan.addRow([]); challan.addRow([ 'Quarter', 'Deduction month', - 'Amount deducted', + 'Deducted', 'Due date', - 'Deposited on', - 'Challan no.', - 'BSR code', + 'Deposited', + 'Challans', + 'Outstanding', 'Status', 'Delay (months)', 'Late-deposit interest', @@ -108,12 +209,12 @@ export class TdsRemittanceReportComponent { challan.addRow([ `Q${r.quarter}`, r.month, - r.amount, + r.deducted, r.dueDate, - r.depositedOn ?? 'PENDING', - r.challanNo ?? '', - r.bsrCode ?? '', - r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.depositedOn ? 'On time' : 'Within due date', + r.deposited, + r.challans.map((c) => `${c.challanNo} / BSR ${c.bsrCode} / ${c.depositedOn}`).join('; '), + r.outstanding, + r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.outstanding ? 'Within due date' : 'Deposited', r.delayMonths || '', r.defaultInterest || '', r.returnDueDate @@ -127,58 +228,19 @@ export class TdsRemittanceReportComponent { recon.addRow([`${this.statute.statement} year-end position — FY ${this.fy}`]); recon.addRow([]); const lines: [string, number | string][] = [ - [ - 'Deductees', - s.deductees - ], - [ - 'Total interest paid on time deposits', - s.totalInterestPaid - ], - [ - ' — with tax deducted', - s.interestWithDeduction - ], - [ - ' — without deduction (declaration on record)', - s.interestWithoutDeduction - ], - [ - 'TDS at standard rate (PAN on record)', - s.tdsAtStandardRate - ], - [ - 'TDS at higher rate (no PAN)', - s.tdsAtHigherRate - ], - [ - 'Total TDS deducted', - s.totalDeducted - ], - [ - 'Total deposited with Government', - s.totalDeposited - ], - [ - 'Still within due date', - s.pendingDeposit - ], - [ - 'DIFFERENCE (must be zero)', - s.difference - ], - [ - 'Interest payable for late deposit', - s.defaultInterest - ], - [ - 'Challans deposited late', - s.lateChallans - ], - [ - 'Challans overdue', - s.overdueChallans - ] + ['Deductees', s.deductees], + ['Total interest paid on time deposits', s.totalInterestPaid], + [' — with tax deducted', s.interestWithDeduction], + [' — without deduction (declaration on record)', s.interestWithoutDeduction], + ['TDS at standard rate (PAN on record)', s.tdsAtStandardRate], + ['TDS at higher rate (no PAN)', s.tdsAtHigherRate], + ['TDS due by the rules', s.calculatedTds], + ['Total TDS actually deducted', s.totalDeducted], + ['Total deposited with Government', s.totalDeposited], + ['Still within due date', s.pendingDeposit], + ['Overdue', s.overdueAmount], + ['DIFFERENCE (must be zero)', s.difference], + ['Interest payable for late deposit', s.defaultInterest] ]; for (const line of lines) { recon.addRow(line); diff --git a/src/app/tds-reports/tds-reports-data.ts b/src/app/tds-reports/tds-reports-data.ts index f64f23644..4f753f880 100644 --- a/src/app/tds-reports/tds-reports-data.ts +++ b/src/app/tds-reports/tds-reports-data.ts @@ -7,22 +7,16 @@ */ /** - * Deductees and their deposit interest, for the two TDS reports. + * Deductees, their deposit interest and the challans, for the two TDS reports. * - * The bank's deduction obligation runs on time deposits, so the seed - * gives each customer a mix of fixed and recurring deposits — and some savings - * accounts, which are outside the section and must stay out of the aggregate. - * - * Everything is generated from a fixed seed so the figures do not move between - * loads, and the challan schedule is deliberately imperfect: one quarter is - * deposited late so the default-interest column has something to show. + * Everything here is what the platform returned. The challans used to be generated from the + * computed deductions - challan numbers, BSR codes and deposit dates made up, one quarter + * deliberately late - so the remittance report reconciled against figures nobody had entered. */ -import { GL_BRANCHES } from '../gl-reports/gl-reports-data'; import { configuredFys, Declaration, - Form15Kind, InterestCredit, TdsAccountInput } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; @@ -32,8 +26,14 @@ export function tdsFys(): string[] { return configuredFys(); } -/** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */ -export const TDS_AS_AT = '2026-08-01'; +/** A deposit as the platform reports it: the interest credited and the TDS actually withheld. */ +export interface TdsReportAccount extends TdsAccountInput { + /** TDS the platform actually deducted from this account in the year. */ + deductions: InterestCredit[]; + /** The account's own holder, when the tax is deducted against somebody else (minor, joint). */ + holderClientId?: number; + holderName?: string; +} export interface TdsDeductee { clientId: number; @@ -44,115 +44,35 @@ export interface TdsDeductee { branchCode: string; /** Declarations on record, keyed by financial year. */ declarations: Record; - accounts: TdsAccountInput[]; + accounts: TdsReportAccount[]; } -/** One month's remittance of deducted tax to the Government. */ +/** One challan Head Office entered: tax remitted to the Government. */ export interface TdsChallan { + id: number; fy: string; - /** Quarter the deduction belongs to. */ - quarter: 1 | 2 | 3 | 4; /** Month the tax was deducted in, e.g. '2026-06'. */ month: string; amount: number; - /** Statutory date by which it had to be deposited. */ - dueDate: string; - /** When it actually reached the Government — null while still pending. */ - depositedOn: string | null; - challanNo: string | null; - bsrCode: string | null; + challanNo: string; + bsrCode: string; + depositedOn: string; + paidFrom: string; + remarks: string | null; + reversed: boolean; } -/** Deterministic linear congruential generator — same figures on every load. */ -function lcg(seed: number): () => number { - let s = seed >>> 0; - return () => { - s = (Math.imul(s, 1664525) + 1013904223) >>> 0; - return s / 4294967296; - }; -} - -function pick(rand: () => number, list: T[]): T { - return list[Math.floor(rand() * list.length)]; -} - -const FIRST_NAMES = [ - 'Ramesh', - 'Sunita', - 'Vitthal', - 'Kamala', - 'Ganpat', - 'Shobha', - 'Dnyaneshwar', - 'Mangala', - 'Bhaskar', - 'Anita', - 'Tukaram', - 'Sarika', - 'Nivrutti', - 'Rekha', - 'Popat', - 'Chhaya', - 'Ashok', - 'Vaishali', - 'Sitaram', - 'Jayshree', - 'Madhukar', - 'Pushpa', - 'Eknath', - 'Sulochana', - 'Hanumant', - 'Kalpana' -]; - -const SURNAMES = [ - 'Jadhav', - 'Pawar', - 'Shinde', - 'Gaikwad', - 'More', - 'Kadam', - 'Bhosale', - 'Patil', - 'Sawant', - 'Chavan', - 'Salunkhe', - 'Mane', - 'Deshmukh', - 'Thorat', - 'Nikam', - 'Wagh' -]; - -/** - * Quarter-end dates for a financial year. Interest is credited on these, which - * is what makes the deduction fall due. - */ -function quarterEnds(fy: string): string[] { - const y = Number(fy.slice(0, 4)); - return [ - `${y}-06-30`, - `${y}-09-30`, - `${y}-12-31`, - `${y + 1}-03-31` - ]; -} - -/** A well-formed individual PAN — fourth character 'P'. */ -function makePan(rand: () => number, surname: string): string { - const letter = () => String.fromCharCode(65 + Math.floor(rand() * 26)); - const digits = `${1000 + Math.floor(rand() * 9000)}`; - return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`; +/** Tax the platform withheld in one month. */ +export interface TdsDeductedMonth { + month: string; + amount: number; + postings: number; } /** * The deductees the reports run on, loaded from the platform. * - * This used to be sixty-four customers generated from a fixed seed — names, PANs, deposits and all. - * The computation over them is the real section 194A calculation, so the figures looked like a - * filing and were not one: not a rupee of it came from the bank's own books. - * - * It is set once by whoever loads the screen and read by the pure computation below, which is why + * It is set once by whoever loads the screen and read by the pure computation, which is why * this is a module-level holder rather than a service the compute would have to inject. */ let liveDeductees: TdsDeductee[] = []; @@ -188,63 +108,3 @@ export function depositDueDate(month: string): string { const nextM = m === 12 ? 1 : m + 1; return `${nextY}-${`${nextM}`.padStart(2, '0')}-07`; } - -let challanCache: Record | null = null; - -/** - * The remittance schedule, derived from what the engine says was deducted. - * - * Deductions land at each quarter end, so there is one challan per quarter. The - * Q2 challan of the prior year is deliberately deposited a month and a bit late - * so the late-deposit interest column is exercised, and the current year's latest - * quarter is left pending, which is the ordinary state of a live book. - */ -export function tdsChallans(deductedByQuarter: Record>): Record { - if (challanCache) { - return challanCache; - } - const out: Record = {}; - - for (const fy of Object.keys(deductedByQuarter)) { - const y = Number(fy.slice(0, 4)); - const months: Record = { - 1: `${y}-06`, - 2: `${y}-09`, - 3: `${y}-12`, - 4: `${y + 1}-03` - }; - const rows: TdsChallan[] = []; - - for (const q of [ - 1, - 2, - 3, - 4 - ] as (1 | 2 | 3 | 4)[]) { - const amount = Math.round(deductedByQuarter[fy]?.[q] ?? 0); - if (amount <= 0) { - continue; - } - const month = months[q]; - const dueDate = depositDueDate(month); - // Anything falling after the business date has not been deposited yet. - const pending = dueDate > TDS_AS_AT; - const late = fy === '2025-26' && q === 2; - - rows.push({ - fy, - quarter: q, - month, - amount, - dueDate, - depositedOn: pending ? null : late ? `${y}-11-14` : dueDate, - challanNo: pending ? null : `${y}${q}${`${amount % 10000}`.padStart(4, '0')}`, - bsrCode: pending ? null : '0510308' - }); - } - out[fy] = rows; - } - - challanCache = out; - return out; -} diff --git a/src/app/tds-reports/tds-reports.compute.spec.ts b/src/app/tds-reports/tds-reports.compute.spec.ts index f3b16c811..ee0aea6e5 100644 --- a/src/app/tds-reports/tds-reports.compute.spec.ts +++ b/src/app/tds-reports/tds-reports.compute.spec.ts @@ -6,13 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { isTdsApplicable, tdsDeducteeOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; import { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture'; -import { TDS_AS_AT, depositDueDate, tdsDeductees, tdsFys } from './tds-reports-data'; +import { TdsChallan, TdsDeductee, depositDueDate, setTdsDeductees } from './tds-reports-data'; import { - NEAR_THRESHOLD_BAND, TdsReportFilter, - deductedByQuarter, tds26qSummary, tdsInterestRows, tdsInterestRowsForFy, @@ -22,12 +19,81 @@ import { installTdsConfigFixture(); -/** The complete year in the seed — the one a return would be filed for. */ -const CLOSED_FY = '2025-26'; +/** + * A small book with every case the reports must handle once, so each figure below can be worked + * out by hand. The reports used to run on sixty-four generated customers; the tests asserted + * shapes ("some row crossed") because nobody could know the numbers. + */ +const FY = '2025-26'; +const Q = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-31']; + +const fd = (id: number, amounts: number[], extra: object = {}) => ({ + id, + accountNo: `FD${id}`, + depositTypeCode: 'depositAccountType.fixedDeposit', + credits: amounts.map((amount, i) => ({ date: Q[i], amount, accountNo: `FD${id}` })), + deductions: [] as { date: string; amount: number }[], + ...extra +}); + +const sb = (id: number, amount: number) => ({ + id, + accountNo: `SB${id}`, + depositTypeCode: 'depositAccountType.savingsDeposit', + credits: [{ date: '2025-09-30', amount, accountNo: `SB${id}` }], + deductions: [] as { date: string; amount: number }[] +}); + +function person( + clientId: number, + name: string, + dob: string, + pan: string, + branch: string, + accounts: any[], + declarations = {} +): TdsDeductee { + return { + clientId, + customerId: `C${clientId}`, + name, + pan, + dob, + branchCode: branch, + declarations, + accounts: accounts.map((a) => ({ clientId, ...a })) + }; +} + +const BOOK: TdsDeductee[] = [ + // Over the limit in Q4, plus a savings account that must stay outside the figure. + person(1, 'Ramesh', '1980-05-01', 'ABCPR1234K', 'Head Office', [ + { ...fd(11, [15_000, 15_000, 15_000, 15_000]), deductions: [{ date: Q[3], amount: 6_000 }] }, + sb(12, 1_200) + ]), + // Senior citizen, no PAN: higher threshold, higher rate - and the platform deducted too little. + person(2, 'Sunita', '1955-01-01', '', 'bhopal', [ + { ...fd(21, [30_000, 30_000, 30_000, 30_000]), deductions: [{ date: Q[3], amount: 20_000 }] } + ]), + // ₹6,000 short of the limit: the customer to ask for a declaration. + person(3, 'Vitthal', '1985-03-03', 'ABCPV5678L', 'bhopal', [fd(31, [22_000, 22_000])]), + // Declaration accepted mid-year: interest before it stays liable, interest after it is paid gross. + person(4, 'Kamala', '1979-07-07', 'ABCPK9012M', 'Head Office', [fd(41, [20_000, 20_000, 20_000, 20_000])], { + [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } + }), + // A minor's deposit, already listed under the guardian by the platform. + person(5, 'Ganpat', '1975-02-02', 'ABCPG3456N', 'Head Office', [ + { ...fd(51, [5_000]), holderClientId: 99, holderName: 'Baby Ganpat' } + ]), + // Savings only, no PAN: never deducted, so never counted as a no-PAN problem. + person(6, 'Shobha', '1990-09-09', '', 'Head Office', [sb(61, 900)]) +]; + +setTdsDeductees(BOOK); function filter(overrides: Partial = {}): TdsReportFilter { return { - fy: CLOSED_FY, + fy: FY, branchCode: '', quarter: 0, band: '', @@ -38,115 +104,94 @@ function filter(overrides: Partial = {}): TdsReportFilter { }; } -describe('the seed has every case the reports need to show', () => { - it('produces deductees on both sides of the threshold', () => { - const rows = tdsInterestRowsForFy(CLOSED_FY); - expect(rows.length).toBeGreaterThan(20); - expect(rows.some((r) => r.crossed)).toBe(true); - expect(rows.some((r) => !r.crossed)).toBe(true); - expect(rows.some((r) => r.nearThreshold)).toBe(true); - expect(rows.some((r) => r.band === 'GENERAL')).toBe(true); - expect(rows.some((r) => r.band === 'SENIOR')).toBe(true); - expect(rows.some((r) => !r.panOnRecord)).toBe(true); - expect(rows.some((r) => r.hasDeclaration)).toBe(true); +const row = (name: string) => tdsInterestRowsForFy(FY).find((r) => r.deductee.name === name)!; + +describe('which accounts are tracked', () => { + it('counts the FD and leaves the savings account out, saying why', () => { + const r = row('Ramesh'); + expect(r.tdsApplicableInterest).toBe(60_000); + expect(r.exemptInterest).toBe(1_200); + expect(r.trackedAccounts).toBe(1); + const savings = r.accounts.find((a) => a.type === 'SB')!; + expect(savings.tracked).toBe(false); + expect(savings.reason).toContain('Savings'); + expect(r.accounts[0].type).toBe('FD'); }); - it('gives senior citizens the higher threshold', () => { - const rows = tdsInterestRowsForFy(CLOSED_FY); - for (const row of rows) { - expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000); - } + it('shows whose account a deposit is when the tax falls on the guardian', () => { + const line = row('Ganpat').accounts[0]; + expect(line.tracked).toBe(true); + expect(line.holderName).toBe('Baby Ganpat'); }); - it('deducts at the higher rate wherever PAN is missing', () => { - for (const row of tdsInterestRowsForFy(CLOSED_FY)) { - expect(row.rateApplied).toBe(row.panOnRecord ? 0.1 : 0.2); - } + it('marks a savings-only customer as not tracked', () => { + expect(row('Shobha').trackedAccounts).toBe(0); }); }); -describe('the reports follow the Act that governed each year', () => { - it('shows 15G / 15H for FY 2025-26 and Form 121 for FY 2026-27', () => { - const old = tdsInterestRowsForFy('2025-26'); - expect(old.some((r) => r.form === '15G')).toBe(true); - expect(old.some((r) => r.form === '15H')).toBe(true); - expect(old.some((r) => r.form === '121')).toBe(false); - - // The two forms merged on 01 April 2026. - const current = tdsInterestRowsForFy('2026-27'); - expect(current.every((r) => r.form === '121')).toBe(true); +describe('the threshold and the rate', () => { + it('deducts on the whole year once the general limit is crossed', () => { + const r = row('Ramesh'); + expect(r.threshold).toBe(50_000); + expect(r.crossedInQuarter).toBe(4); + expect(r.schedule.totalTds).toBe(6_000); }); - it('keeps the age-based threshold band in both years', () => { - for (const fy of tdsFys()) { - const rows = tdsInterestRowsForFy(fy); - expect(rows.some((r) => r.band === 'SENIOR')).toBe(true); - expect(rows.some((r) => r.band === 'GENERAL')).toBe(true); - for (const row of rows) { - expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000); - } - } + it('gives a senior the higher limit, and a missing PAN the higher rate', () => { + const r = row('Sunita'); + expect(r.band).toBe('SENIOR'); + expect(r.threshold).toBe(100_000); + expect(r.rateApplied).toBe(0.2); + expect(r.schedule.totalTds).toBe(24_000); + }); + + it('flags a customer just under the limit', () => { + const r = row('Vitthal'); + expect(r.crossed).toBe(false); + expect(r.nearThreshold).toBe(true); + expect(r.headroom).toBe(6_000); + expect(r.schedule.totalTds).toBe(0); }); }); -describe('savings interest stays out of the 194A aggregate', () => { - it('reports it separately and never inside the applicable interest', () => { - const rows = tdsInterestRowsForFy(CLOSED_FY); - const withSavings = rows.filter((r) => r.exemptInterest > 0); - expect(withSavings.length).toBeGreaterThan(0); +describe('a mid-year declaration', () => { + it('taxes only the interest credited before it was accepted', () => { + const r = row('Kamala'); + expect(r.hasDeclaration).toBe(true); + // ₹40,000 credited before 15 Oct is liable once the year's ₹80,000 crosses the limit. + expect(r.schedule.totalTds).toBe(4_000); + expect(r.tdsSaved).toBe(4_000); + expect(r.paidWithoutDeduction).toBe(40_000); + }); +}); - for (const row of withSavings) { - // The applicable figure must equal only the time-deposit interest where - // this customer is the deductee. - const expected = row.deductee.accounts - .filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === row.deductee.clientId) - .flatMap((a) => a.credits) - .filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31') - .reduce((s, c) => s + c.amount, 0); - expect(row.tdsApplicableInterest).toBe(expected); - } +describe('due against actually deducted', () => { + it('agrees where the platform deducted the right amount', () => { + expect(row('Ramesh').actualTds).toBe(6_000); }); - it('leaves the savings totals out of the summary base', () => { + it('shows the gap where it did not', () => { + const r = row('Sunita'); + expect(r.actualTds).toBe(20_000); + expect(r.schedule.totalTds - r.actualTds).toBe(4_000); + }); + + it('totals both in the summary', () => { const s = tdsInterestSummary(filter()); - expect(s.exemptInterest).toBeGreaterThan(0); - expect(s.applicableInterest).toBe(tdsInterestRows(filter()).reduce((acc, r) => acc + r.tdsApplicableInterest, 0)); + expect(s.totalTds).toBe(6_000 + 24_000 + 4_000); + expect(s.actualTds).toBe(26_000); }); }); -describe('joint deposits are attributed to the first-named holder only', () => { - it('excludes second-holder interest from the aggregate and shows it apart', () => { - const rows = tdsInterestRowsForFy(CLOSED_FY); - const secondHolders = rows.filter((r) => r.jointHeldInterest > 0); - expect(secondHolders.length).toBeGreaterThan(0); - - for (const row of secondHolders) { - const jointAccounts = row.deductee.accounts.filter( - (a) => isTdsApplicable(a) && tdsDeducteeOf(a) !== row.deductee.clientId - ); - expect(jointAccounts.length).toBeGreaterThan(0); - // None of that interest may appear in the figure the threshold is tested - // against, or one deposit would be taxed against two people. - const jointTotal = jointAccounts - .flatMap((a) => a.credits) - .filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31') - .reduce((s, c) => s + c.amount, 0); - expect(row.jointHeldInterest).toBe(jointTotal); - expect(row.tdsApplicableInterest).toBeLessThan(row.tdsApplicableInterest + jointTotal); - } - }); -}); - -describe('quarter figures add up to the year', () => { - it('per row', () => { - for (const row of tdsInterestRowsForFy(CLOSED_FY)) { - const quarterInterest = row.quarters[1] + row.quarters[2] + row.quarters[3] + row.quarters[4]; - expect(quarterInterest).toBe(row.tdsApplicableInterest); - expect(row.schedule.lines.reduce((s, l) => s + l.tds, 0)).toBe(row.schedule.totalTds); - } +describe('the summary', () => { + it('counts a missing PAN only where a deposit is being tracked', () => { + const s = tdsInterestSummary(filter()); + expect(s.noPan).toBe(1); + expect(s.trackedCustomers).toBe(5); + expect(s.exemptInterest).toBe(2_100); }); - it('and across the summary', () => { + it('adds the quarters up to the year', () => { const s = tdsInterestSummary(filter()); expect(s.byQuarter.reduce((acc, q) => acc + q.interest, 0)).toBe(s.applicableInterest); expect(s.byQuarter.reduce((acc, q) => acc + q.tds, 0)).toBe(s.totalTds); @@ -154,151 +199,116 @@ describe('quarter figures add up to the year', () => { }); describe('filters', () => { - it("narrows to a branch without changing anyone's figures", () => { - const all = tdsInterestRows(filter()); - const branch = all[0].branchCode; - const scoped = tdsInterestRows(filter({ branchCode: branch })); - expect(scoped.length).toBeGreaterThan(0); - expect(scoped.every((r) => r.branchCode === branch)).toBe(true); - // A branch view must not re-aggregate — the threshold is computed bank-wide. - const sample = scoped[0]; - const sameRow = all.find((r) => r.deductee.customerId === sample.deductee.customerId); - expect(sample.tdsApplicableInterest).toBe(sameRow?.tdsApplicableInterest); + it('narrows to a branch by office name, whatever its case, without re-aggregating', () => { + const scoped = tdsInterestRows(filter({ branchCode: 'BHOPAL' })); + expect(scoped.map((r) => r.deductee.name).sort()).toEqual(['Sunita', 'Vitthal']); + expect(scoped.find((r) => r.deductee.name === 'Sunita')!.schedule.totalTds).toBe(24_000); }); - it('finds the customers worth chasing a declaration from', () => { - const near = tdsInterestRows(filter({ nearThresholdOnly: true })); - expect(near.length).toBeGreaterThan(0); - for (const row of near) { - expect(row.crossed).toBe(false); - expect(row.headroom).toBeGreaterThan(0); - expect(row.headroom).toBeLessThanOrEqual(NEAR_THRESHOLD_BAND); - } + it('finds the customers near the limit and those over it', () => { + expect(tdsInterestRows(filter({ nearThresholdOnly: true })).map((r) => r.deductee.name)).toEqual(['Vitthal']); + expect( + tdsInterestRows(filter({ crossedOnly: true })) + .map((r) => r.deductee.name) + .sort() + ).toEqual(['Kamala', 'Ramesh', 'Sunita']); }); - it('narrows to customers who crossed', () => { - const crossed = tdsInterestRows(filter({ crossedOnly: true })); - expect(crossed.length).toBeGreaterThan(0); - expect(crossed.every((r) => r.crossed && r.tdsApplicableInterest > r.threshold)).toBe(true); - }); - - it('searches on name, CIF and PAN', () => { - const target = tdsInterestRowsForFy(CLOSED_FY).find((r) => r.panOnRecord)!; - expect(tdsInterestRows(filter({ search: target.deductee.customerId })).length).toBe(1); - expect(tdsInterestRows(filter({ search: target.deductee.pan })).length).toBeGreaterThanOrEqual(1); + it('searches on name, customer ID and PAN', () => { + expect(tdsInterestRows(filter({ search: 'C3' })).length).toBe(1); + expect(tdsInterestRows(filter({ search: 'abcpk9012m' }))[0].deductee.name).toBe('Kamala'); }); }); -describe('declarations', () => { - it('save tax and record the interest paid gross', () => { - const declared = tdsInterestRowsForFy(CLOSED_FY).filter((r) => r.hasDeclaration); - expect(declared.length).toBeGreaterThan(0); - expect(declared.some((r) => r.tdsSaved > 0)).toBe(true); - // Interest paid without deduction is a mandatory 26Q disclosure, so it must - // be carried even though no tax came off it. - expect(declared.some((r) => r.paidWithoutDeduction > 0)).toBe(true); - for (const row of declared) { - expect(row.declarationUin).toBeTruthy(); - } +describe('remittance', () => { + const challan = (over: Partial): TdsChallan => ({ + id: 1, + fy: FY, + month: '2026-03', + amount: 0, + challanNo: '00001', + bsrCode: '0510308', + depositedOn: '2026-04-20', + paidFrom: '', + remarks: null, + reversed: false, + ...over }); - it('leaves tax deducted before a mid-year declaration in place', () => { - // Some declarations in the seed were accepted in October. Tax already taken - // in Q1/Q2 cannot be reversed, so those rows still carry a deduction. - const midYear = tdsInterestRowsForFy(CLOSED_FY).filter( - (r) => r.hasDeclaration && r.declarationAcceptedOn && r.declarationAcceptedOn.slice(5, 7) === '10' - ); - expect(midYear.length).toBeGreaterThan(0); - expect(midYear.some((r) => r.schedule.totalTds > 0)).toBe(true); - }); -}); - -describe('remittance schedule', () => { - it('deposits by the seventh of the following month, and 30 April for March', () => { + it('pays by the seventh of the following month, and 30 April for March', () => { expect(depositDueDate('2025-06')).toBe('2025-07-07'); expect(depositDueDate('2025-12')).toBe('2026-01-07'); expect(depositDueDate('2026-03')).toBe('2026-04-30'); }); - it('remits exactly what the engine says was deducted', () => { - const deducted = deductedByQuarter(CLOSED_FY); - const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT); - for (const row of rows) { - expect(row.amount).toBe(Math.round(deducted[row.quarter])); - } + it('marks a month paid in full and on time', () => { + const [r] = tdsRemittanceRows(FY, [{ month: '2026-03', amount: 26_000, postings: 2 }], [challan({ amount: 26_000 })], '2026-06-01'); + expect(r.quarter).toBe(4); + expect(r.outstanding).toBe(0); + expect(r.late).toBe(false); + expect(r.overdue).toBe(false); + expect(r.defaultInterest).toBe(0); }); - it('charges section 201(1A) interest on a late deposit and nothing on a timely one', () => { - const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT); - const late = rows.filter((r) => r.late); - expect(late.length).toBeGreaterThan(0); - for (const row of late) { - expect(row.delayMonths).toBeGreaterThan(0); - expect(row.defaultInterest).toBe(Math.round(row.amount * 0.015 * row.delayMonths)); - } - for (const row of rows.filter((r) => !r.late && !r.overdue)) { - expect(row.defaultInterest).toBe(0); - } + it('charges interest on a late challan', () => { + const [r] = tdsRemittanceRows( + FY, + [{ month: '2026-03', amount: 26_000, postings: 2 }], + [challan({ amount: 26_000, depositedOn: '2026-05-02' })], + '2026-06-01' + ); + expect(r.late).toBe(true); + expect(r.delayMonths).toBeGreaterThan(0); + expect(r.defaultInterest).toBeGreaterThan(0); }); -}); -describe('the 26Q reconciliation control', () => { - it.each(tdsFys())('balances for FY %s', (fy) => { - const s = tds26qSummary(fy, TDS_AS_AT); - // Deducted must equal deposited plus what is still within its due date. - // Anything else means tax has gone missing between the ledger and the - // Government, and the return must not be filed until it is found. + it('flags an unpaid month past its due date, and not one still within it', () => { + const rows = tdsRemittanceRows( + FY, + [ + { month: '2025-12', amount: 5_000, postings: 1 }, + { month: '2026-03', amount: 7_000, postings: 1 } + ], + [], + '2026-04-10' + ); + const dec = rows.find((r) => r.month === '2025-12')!; + const mar = rows.find((r) => r.month === '2026-03')!; + expect(dec.overdue).toBe(true); + expect(dec.defaultInterest).toBeGreaterThan(0); + expect(mar.overdue).toBe(false); + expect(mar.outstanding).toBe(7_000); + }); + + it('ignores a reversed challan', () => { + const [r] = tdsRemittanceRows( + FY, + [{ month: '2026-03', amount: 26_000, postings: 2 }], + [challan({ amount: 26_000, reversed: true })], + '2026-04-10' + ); + expect(r.deposited).toBe(0); + expect(r.outstanding).toBe(26_000); + }); + + it('reconciles deducted against paid plus still-within-due', () => { + const asAt = '2026-04-10'; + const rows = tdsRemittanceRows( + FY, + [ + { month: '2025-12', amount: 5_000, postings: 1 }, + { month: '2026-03', amount: 21_000, postings: 1 } + ], + [challan({ month: '2025-12', amount: 5_000, depositedOn: '2026-01-05' })], + asAt + ); + const s = tds26qSummary(FY, rows, asAt); + expect(s.totalDeducted).toBe(26_000); + expect(s.totalDeposited).toBe(5_000); + expect(s.pendingDeposit).toBe(21_000); expect(s.difference).toBe(0); - expect(s.totalDeducted).toBe(s.totalDeposited + s.pendingDeposit); - }); - - it('splits the tax between the standard and the no-PAN rate without loss', () => { - const s = tds26qSummary(CLOSED_FY, TDS_AS_AT); - expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(s.totalDeducted); - expect(s.tdsAtHigherRate).toBeGreaterThan(0); - }); - - it('splits interest into the deducted and the paid-gross halves', () => { - const s = tds26qSummary(CLOSED_FY, TDS_AS_AT); - expect(s.interestWithDeduction + s.interestWithoutDeduction).toBe(s.totalInterestPaid); - // The gross-paid half is the Form 121 disclosure and must not be zero here, - // or the report would never exercise that line. - expect(s.interestWithoutDeduction).toBeGreaterThan(0); - }); - - it('agrees with the interest report on the same year', () => { - const summary = tdsInterestSummary(filter()); - expect(tds26qSummary(CLOSED_FY, TDS_AS_AT).totalDeducted).toBe(summary.totalTds); - }); -}); - -describe('the part-run current year', () => { - it('has postings only up to the business date', () => { - for (const deductee of tdsDeductees()) { - for (const account of deductee.accounts) { - for (const credit of account.credits) { - expect(credit.date <= TDS_AS_AT).toBe(true); - } - } - } - }); - - it('shows the closed quarter as settled and future obligations as not yet due', () => { - const rows = tdsRemittanceRows('2026-27', TDS_AS_AT); - - // Q1 (Apr–Jun) has closed, so its tax was deducted and is due by 07 Jul. - expect(rows.some((r) => r.quarter === 1)).toBe(true); - - // Q3 cannot appear: no interest has posted there and nothing settles there. - expect(rows.some((r) => r.quarter === 3)).toBe(false); - - // Q4 *can* appear even in a part-run year — deposits on annual interest - // posting settle once, at year end — but that is a future obligation, so it - // must read as pending and must never be flagged overdue. - for (const row of rows.filter((r) => r.quarter === 4)) { - expect(row.depositedOn).toBeNull(); - expect(row.overdue).toBe(false); - expect(row.defaultInterest).toBe(0); - } + // The year's figures come from the same book as the interest report. + expect(s.calculatedTds).toBe(34_000); + expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(26_000); }); }); diff --git a/src/app/tds-reports/tds-reports.compute.ts b/src/app/tds-reports/tds-reports.compute.ts index 7d1af6c6e..de0e0e568 100644 --- a/src/app/tds-reports/tds-reports.compute.ts +++ b/src/app/tds-reports/tds-reports.compute.ts @@ -17,7 +17,6 @@ import { DeclarationForm, - FY_TAX_CONFIG, Quarter, TdsMode, TdsSchedule, @@ -36,7 +35,15 @@ import { tdsDeducteeOf, tdsModeFor } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees, tdsDeducteesVersion } from './tds-reports-data'; +import { + TdsChallan, + TdsDeductedMonth, + TdsDeductee, + TdsReportAccount, + depositDueDate, + tdsDeductees, + tdsDeducteesVersion +} from './tds-reports-data'; // ─────────────────────────────── Filtering ─────────────────────────────── @@ -73,6 +80,10 @@ export interface TdsAccountLine { /** How many interest postings made up that figure. */ postings: number; lastPostedOn: string | null; + /** TDS the platform actually withheld from this account in the year. */ + tdsDeducted: number; + /** Set when the tax is deducted against someone other than the account's own holder. */ + holderName: string | null; } /** How close to the trigger counts as "worth chasing a declaration for". */ @@ -124,6 +135,11 @@ export interface TdsInterestRow { accounts: TdsAccountLine[]; /** How many of them count toward the deduction. */ trackedAccounts: number; + /** + * TDS the platform actually withheld this year. `schedule.totalTds` is what this report works + * out is due; the two should agree, and a difference is the first thing to investigate. + */ + actualTds: number; } function emptyQuarters(): Record { @@ -192,8 +208,9 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { reason = 'Joint deposit — counted under the first-named holder'; } else { tracked = true; - reason = 'Counted toward the TDS limit'; + reason = 'Its interest received in savings counts toward the TDS limit'; } + const report = account as TdsReportAccount; accountLines.push({ accountNo: account.accountNo || '', type, @@ -201,7 +218,10 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { reason, interest: inYear, postings: yearCredits.length, - lastPostedOn: yearCredits.reduce((last, c) => (!last || c.date > last ? c.date : last), null) + lastPostedOn: yearCredits.reduce((last, c) => (!last || c.date > last ? c.date : last), null), + tdsDeducted: (report.deductions || []).filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0), + holderName: + report.holderClientId && report.holderClientId !== deductee.clientId ? (report.holderName ?? null) : null }); } // Tracked accounts first, so the deposits that drive the figure lead the list. @@ -236,7 +256,8 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { panOnRecord: isWellFormedPan(deductee.pan), rateApplied: schedule.rateApplied, accounts: accountLines, - trackedAccounts: accountLines.filter((a) => a.tracked).length + trackedAccounts: accountLines.filter((a) => a.tracked).length, + actualTds: accountLines.reduce((s, a) => s + a.tdsDeducted, 0) }); } @@ -313,6 +334,8 @@ export interface TdsInterestSummary { /** Savings interest, outside the section. */ exemptInterest: number; totalTds: number; + /** TDS the platform actually withheld — set against `totalTds`, which is what is due. */ + actualTds: number; crossed: number; nearThreshold: number; declarations: number; @@ -342,6 +365,7 @@ export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary applicableInterest: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0), exemptInterest: rows.reduce((s, r) => s + r.exemptInterest, 0), totalTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0), + actualTds: rows.reduce((s, r) => s + r.actualTds, 0), crossed: rows.filter((r) => r.crossed).length, nearThreshold: rows.filter((r) => r.nearThreshold).length, declarations: rows.filter((r) => r.hasDeclaration).length, @@ -357,14 +381,34 @@ export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary // ─────────────────────── Report 2: remittance ─────────────────────── -export interface TdsRemittanceRow extends TdsChallan { - /** Statutory due date has passed with nothing deposited. */ +/** + * One deduction month: what the platform withheld, and what Head Office remitted against it. + * + * Driven by the withhold-tax postings and the challans entered, never by this report's own + * computation — a return reconciles what was deducted, not what should have been. + */ +export interface TdsRemittanceRow { + fy: string; + quarter: Quarter; + /** Month the tax was deducted in, e.g. '2026-06'. */ + month: string; + deducted: number; + postings: number; + /** Live challans entered against this month. */ + challans: TdsChallan[]; + deposited: number; + lastDepositedOn: string | null; + dueDate: string; + /** Deducted but not yet remitted. */ + outstanding: number; + /** Remitted beyond what was deducted — a challan entered against the wrong month, usually. */ + excess: number; + /** Something is still outstanding and the due date has passed. */ overdue: boolean; - /** Deposited, but after the due date. */ + /** A challan reached the Government after the due date. */ late: boolean; - /** Months or part months of delay. */ delayMonths: number; - /** Interest charged for depositing late. */ + /** Interest the bank owes for depositing late. */ defaultInterest: number; /** Return the quarter's deduction is reported in. */ returnDueDate: string; @@ -377,41 +421,78 @@ const RETURN_DUE: Record = { 4: '31 May' }; -/** Tax deducted per quarter, from the engine, for one year. */ -export function deductedByQuarter(fy: string): Record { - const out: Record = { 1: 0, 2: 0, 3: 0, 4: 0 }; - for (const row of tdsInterestRowsForFy(fy)) { - for (const line of row.schedule.lines) { - out[line.quarter] += line.tds; - } +export function quarterOfMonth(month: string): Quarter { + const m = Number(month.slice(5, 7)); + if (m >= 4 && m <= 6) { + return 1; } - return out; + if (m >= 7 && m <= 9) { + return 2; + } + if (m >= 10) { + return 3; + } + return 4; } -export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] { - const deducted: Record> = {}; - for (const year of Object.keys(FY_TAX_CONFIG)) { - deducted[year] = deductedByQuarter(year); - } - const challans = tdsChallans(deducted)[fy] ?? []; +function round2(n: number): number { + return Math.round(n * 100) / 100; +} - return challans.map((c) => { - const overdue = !c.depositedOn && c.dueDate < asAt; - const late = !!c.depositedOn && c.depositedOn > c.dueDate; - // While a payment is overdue the clock runs to today; once paid it stops on - // the payment date. - const settledOn = c.depositedOn ?? asAt; - const delayMonths = late || overdue ? monthsOrPartBetween(c.dueDate, settledOn) : 0; - const defaultInterest = - late || overdue ? lateDeductionInterest(c.amount, c.dueDate, settledOn, 'DEPOSIT', fy).interest : 0; +export function tdsRemittanceRows( + fy: string, + deducted: TdsDeductedMonth[], + challans: TdsChallan[], + asAt: string +): TdsRemittanceRow[] { + const live = challans.filter((c) => !c.reversed && c.fy === fy); + const months = [...new Set([...deducted.map((d) => d.month), ...live.map((c) => c.month)])].sort(); + + return months.map((month) => { + const d = deducted.find((x) => x.month === month); + const amount = d?.amount ?? 0; + const forMonth = live.filter((c) => c.month === month); + const deposited = round2(forMonth.reduce((s, c) => s + c.amount, 0)); + const lastDepositedOn = forMonth.reduce( + (last, c) => (!last || c.depositedOn > last ? c.depositedOn : last), + null + ); + const dueDate = depositDueDate(month); + const outstanding = Math.max(0, round2(amount - deposited)); + const excess = Math.max(0, round2(deposited - amount)); + const overdue = outstanding > 0 && dueDate < asAt; + const lateChallans = forMonth.filter((c) => c.depositedOn > dueDate); + const late = lateChallans.length > 0; + + // Interest runs per challan from the due date to the day it was paid, and on whatever is still + // outstanding from the due date to today. + let defaultInterest = lateChallans.reduce( + (s, c) => s + lateDeductionInterest(c.amount, dueDate, c.depositedOn, 'DEPOSIT', fy).interest, + 0 + ); + if (overdue) { + defaultInterest += lateDeductionInterest(outstanding, dueDate, asAt, 'DEPOSIT', fy).interest; + } + const settledOn = overdue ? asAt : lastDepositedOn; + const delayMonths = (late || overdue) && settledOn ? monthsOrPartBetween(dueDate, settledOn) : 0; return { - ...c, + fy, + quarter: quarterOfMonth(month), + month, + deducted: amount, + postings: d?.postings ?? 0, + challans: forMonth, + deposited, + lastDepositedOn, + dueDate, + outstanding, + excess, overdue, late, delayMonths, defaultInterest, - returnDueDate: RETURN_DUE[c.quarter] + returnDueDate: RETURN_DUE[quarterOfMonth(month)] }; }); } @@ -419,9 +500,9 @@ export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] /** * The year-end position — what the quarterly TDS return has to reconcile to. * - * `difference` is the control figure: tax the engine says was deducted, less what - * actually reached the Government. Anything other than zero, once the pending - * challans are settled, means something is wrong and must be found before filing. + * `difference` is the control figure: tax actually deducted, less what reached the Government, + * less what is still within its due date. Anything other than zero means a month is overdue or + * a challan sits against the wrong month. */ export interface Tds26qSummary { fy: string; @@ -432,9 +513,13 @@ export interface Tds26qSummary { interestWithoutDeduction: number; tdsAtStandardRate: number; tdsAtHigherRate: number; + /** What this report works out was due. */ + calculatedTds: number; + /** What the platform actually withheld from accounts. */ totalDeducted: number; totalDeposited: number; pendingDeposit: number; + overdueAmount: number; /** Deducted less deposited less still-pending. Must be zero. */ difference: number; defaultInterest: number; @@ -442,29 +527,33 @@ export interface Tds26qSummary { overdueChallans: number; } -export function tds26qSummary(fy: string, asAt: string): Tds26qSummary { +export function tds26qSummary(fy: string, remittance: TdsRemittanceRow[], asAt: string): Tds26qSummary { const rows = tdsInterestRowsForFy(fy); - const challans = tdsRemittanceRows(fy, asAt); const standard = rows.filter((r) => r.panOnRecord); const higher = rows.filter((r) => !r.panOnRecord); - const totalDeducted = rows.reduce((s, r) => s + r.schedule.totalTds, 0); - const totalDeposited = challans.filter((c) => c.depositedOn).reduce((s, c) => s + c.amount, 0); - const pendingDeposit = challans.filter((c) => !c.depositedOn).reduce((s, c) => s + c.amount, 0); + const totalDeducted = round2(remittance.reduce((s, r) => s + r.deducted, 0)); + const totalDeposited = round2(remittance.reduce((s, r) => s + r.deposited, 0)); + const pendingDeposit = round2( + remittance.filter((r) => r.dueDate >= asAt).reduce((s, r) => s + r.outstanding, 0) + ); + const overdueAmount = round2(remittance.filter((r) => r.overdue).reduce((s, r) => s + r.outstanding, 0)); return { fy, - deductees: rows.length, + deductees: rows.filter((r) => r.trackedAccounts > 0).length, totalInterestPaid: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0), interestWithDeduction: rows.reduce((s, r) => s + r.schedule.liableInterest, 0), interestWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0), - tdsAtStandardRate: standard.reduce((s, r) => s + r.schedule.totalTds, 0), - tdsAtHigherRate: higher.reduce((s, r) => s + r.schedule.totalTds, 0), + tdsAtStandardRate: standard.reduce((s, r) => s + r.actualTds, 0), + tdsAtHigherRate: higher.reduce((s, r) => s + r.actualTds, 0), + calculatedTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0), totalDeducted, totalDeposited, pendingDeposit, - difference: Math.round(totalDeducted - totalDeposited - pendingDeposit), - defaultInterest: challans.reduce((s, c) => s + c.defaultInterest, 0), - lateChallans: challans.filter((c) => c.late).length, - overdueChallans: challans.filter((c) => c.overdue).length + overdueAmount, + difference: round2(totalDeducted - totalDeposited - pendingDeposit), + defaultInterest: remittance.reduce((s, r) => s + r.defaultInterest, 0), + lateChallans: remittance.filter((r) => r.late).length, + overdueChallans: remittance.filter((r) => r.overdue).length }; } diff --git a/src/app/tds-reports/tds-reports.service.ts b/src/app/tds-reports/tds-reports.service.ts index 8856cc8b7..89d50c912 100644 --- a/src/app/tds-reports/tds-reports.service.ts +++ b/src/app/tds-reports/tds-reports.service.ts @@ -13,16 +13,52 @@ import { Observable, map } from 'rxjs'; /** Custom Helpers */ import { toIsoDate } from '../core/utils/api-date'; -import { configuredFys } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TdsDeductee } from './tds-reports-data'; +import { Declaration, formKindFor } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; +import { TdsChallan, TdsDeductedMonth, TdsDeductee } from './tds-reports-data'; + +/** A declaration as recorded on the platform. */ +export interface TdsDeclarationRecord { + id: number; + clientId: number; + clientName?: string; + fy: string; + formType: '15G' | '15H' | '121'; + uin: string | null; + acceptedOn: string; + estimatedIncome: number | null; + revoked: boolean; + revokedOn: string | null; + revokeReason: string | null; +} + +export interface NewTdsDeclaration { + clientId: number; + fy: string; + formType: string; + acceptedOn: string; + uin?: string; + estimatedIncome?: number | null; +} + +export interface NewTdsChallan { + fy: string; + month: string; + amount: number; + challanNo: string; + bsrCode: string; + depositedOn: string; + paidFromGlAccountId: number; + remarks?: string; +} /** - * Deposit interest for the TDS reports. + * Deposit interest, declarations and challans for the TDS screens. * - * The platform returns the interest it actually credited — nothing else. A deposit is the - * customer's own money arriving and a fee is a charge, so neither is income and neither is sent. - * Working the figure out here from a balance and a rate would produce a number the passbook does - * not show. + * On an FD or RD the platform returns the interest that reached the customer's savings account and + * the TDS debited there for it — TDS is tracked and taken where the interest is received. A deposit + * is the customer's own money arriving and a fee is a charge, so neither is income and neither is + * sent. Working the figure out here from a balance and a rate would produce a number the passbook + * does not show. */ @Injectable({ providedIn: 'root' }) export class TdsReportsService { @@ -32,57 +68,129 @@ export class TdsReportsService { * @param financialYear the opening calendar year — 2026 means 1 Apr 2026 to 31 Mar 2027. * Omit to let the platform use the year the business date falls in. */ - getDepositInterest(financialYear?: number): Observable { - const params = financialYear ? new HttpParams().set('financialYear', String(financialYear)) : undefined; + getDepositInterest(financialYear?: number, clientId?: number): Observable { + let params = new HttpParams(); + if (financialYear) { + params = params.set('financialYear', String(financialYear)); + } + if (clientId) { + params = params.set('clientId', String(clientId)); + } return this.http .get('/tds/deposit-interest', { params }) .pipe(map((rows) => (rows || []).map((r) => this.toDeductee(r)))); } + getDeclarations(clientId: number): Observable { + return this.http.get('/tds/declarations', { + params: new HttpParams().set('clientId', String(clientId)) + }); + } + + createDeclaration(declaration: NewTdsDeclaration): Observable { + return this.http.post('/tds/declarations', declaration); + } + + revokeDeclaration(id: number, reason: string): Observable { + return this.http.post(`/tds/declarations/${id}/revoke`, { reason }); + } + + /** Tax actually withheld per month, and the business date the books are on. */ + getDeducted(fy: string): Observable<{ asAt: string; months: TdsDeductedMonth[] }> { + return this.http.get('/tds/deducted', { params: new HttpParams().set('fy', fy) }).pipe( + map((res) => ({ + asAt: res?.asAt ?? '', + months: (res?.months || []).map((m: any) => ({ + month: m.month, + amount: Number(m.amount) || 0, + postings: Number(m.postings) || 0 + })) + })) + ); + } + + getChallans(fy: string): Observable { + return this.http + .get('/tds/challans', { params: new HttpParams().set('fy', fy) }) + .pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); + } + + getChallanTemplate(): Observable<{ paidFromOptions: { id: number; glCode: string; name: string }[] }> { + return this.http.get('/tds/challans/template'); + } + + createChallan(challan: NewTdsChallan): Observable { + return this.http.post('/tds/challans', challan).pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); + } + + reverseChallan(id: number): Observable { + return this.http + .post(`/tds/challans/${id}/reverse`, {}) + .pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); + } + + private toChallan(r: any): TdsChallan { + return { + id: r.id, + fy: r.fy, + month: r.month, + amount: Number(r.amount) || 0, + challanNo: r.challanNo, + bsrCode: r.bsrCode, + depositedOn: (r.depositedOn || '').slice(0, 10), + paidFrom: r.paidFrom || '', + remarks: r.remarks ?? null, + reversed: !!r.reversed + }; + } + /** - * Shapes one platform row into what the section 194A computation expects. + * Shapes one platform row into what the TDS computation expects. * * Dates arrive as [y, M, d] arrays and are flattened to ISO here, because the computation - * compares them as strings when deciding which financial year a credit falls in. + * compares them as strings when deciding which financial year a credit falls in. The platform + * has already grouped a minor's deposit under the guardian and a joint deposit under its primary + * holder, so every account here belongs to the deductee it is listed under. */ private toDeductee(row: any): TdsDeductee { + const dob = toIsoDate(row.dateOfBirth); + const credit = (a: any) => (c: any) => ({ + date: toIsoDate(c.date), + amount: Number(c.amount) || 0, + accountNo: c.accountNo || a.accountNo || '' + }); return { clientId: row.clientId, customerId: row.customerId || '', name: row.name || '', pan: row.pan || '', - dob: toIsoDate(row.dateOfBirth), + dob, branchCode: row.branchName || '', - // A 15G/15H on the customer's KYC record covers the year it was accepted for. The platform - // carries the kind but not the acceptance date, so the year's start stands in — a - // declaration is operative for the year it was given, which is what the computation needs. - declarations: this.declarationsOf(row), + declarations: this.declarationsOf(row.declarations || [], dob), accounts: (row.accounts || []).map((a: any) => ({ id: a.id, accountNo: a.accountNo || '', depositTypeCode: a.depositTypeCode || '', - accountStatus: a.accountStatus || undefined, + // The deductee, not the holder: the grouping above already resolved whose PAN it is. clientId: a.clientId ?? row.clientId, - credits: (a.credits || []).map((c: any) => ({ - date: toIsoDate(c.date), - amount: Number(c.amount) || 0, - accountNo: c.accountNo || a.accountNo || '' - })) + holderClientId: a.holderClientId ?? undefined, + holderName: a.holderName ?? undefined, + credits: (a.credits || []).map(credit(a)), + deductions: (a.deductions || []).map(credit(a)) })) }; } - private declarationsOf(row: any): Record { - const kind = (row.form15Type || '').trim().toUpperCase(); - if (kind !== '15G' && kind !== '15H') { - return {}; - } - const out: Record = {}; - // Applied to every configured year. Without an acceptance date on the record the declaration is - // treated as operative from the start of each year rather than mid-year. Keyed '2026-27', the - // form the computation looks it up by - an 'FY 2026-27' key was never found. - for (const fy of configuredFys()) { - out[fy] = { kind, acceptedOn: `${fy.slice(0, 4)}-04-01`, fy }; + /** + * Declarations keyed by year, the form the computation looks them up by ('2026-27'). A Form 121 + * carries no G/H split, so the band follows the customer's age in that year. + */ + private declarationsOf(rows: any[], dob: string): Record { + const out: Record = {}; + for (const d of rows) { + const fy = d.fy; + const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(dob, fy); + out[fy] = { kind, acceptedOn: toIsoDate(d.acceptedOn), fy, uin: d.uin || undefined }; } return out; } diff --git a/src/app/tds-reports/tds-reports.shared.scss b/src/app/tds-reports/tds-reports.shared.scss index 8a35dd430..1fa00d346 100644 --- a/src/app/tds-reports/tds-reports.shared.scss +++ b/src/app/tds-reports/tds-reports.shared.scss @@ -726,3 +726,43 @@ margin-top: 16px; } } + +/* ── Challan form (remittance report) ── */ +.tdsr__form { + padding-bottom: 14px; +} + +.tdsr__filters--flat { + margin: 0; + border: 0; + box-shadow: none; +} + +.tdsr__form-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + padding: 0 16px; +} + +.tdsr__btn--primary { + border-color: var(--tdsr-blue); + background: var(--tdsr-blue); + color: #fff; +} + +.tdsr__btn--sm { + padding: 3px 10px; + font-size: 11.5px; +} + +.tdsr__link { + margin-left: 6px; + padding: 0; + border: 0; + background: none; + color: var(--tdsr-amber); + font: inherit; + text-decoration: underline; + cursor: pointer; +} diff --git a/src/app/tds-reports/tds-statutory.compute.ts b/src/app/tds-reports/tds-statutory.compute.ts index bbb50e748..87f74f0dc 100644 --- a/src/app/tds-reports/tds-statutory.compute.ts +++ b/src/app/tds-reports/tds-statutory.compute.ts @@ -32,7 +32,6 @@ import { quarterEnd, quarterOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TDS_AS_AT } from './tds-reports-data'; import { TdsInterestRow, tdsInterestRowsForFy } from './tds-reports.compute'; /** The bank's own deductor particulars, which head every filing. */ @@ -131,7 +130,7 @@ function monthLabel(key: string): string { * the portal rejects the line, and a silently dropped row is how a bank ends up * reporting fewer declarations than it holds. */ -export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): DeclarationStatement { +export function declarationStatement(fy: string, asAt: string): DeclarationStatement { const year = normaliseFy(fy); const cfg = FY_TAX_CONFIG[year]; if (!cfg) { diff --git a/src/app/web-app.component.ts b/src/app/web-app.component.ts index c27a3984f..5645ad422 100644 --- a/src/app/web-app.component.ts +++ b/src/app/web-app.component.ts @@ -8,7 +8,7 @@ /* eslint-disable @angular-eslint/prefer-inject */ /** Angular Imports */ -import { Component, OnInit, HostListener, HostBinding, OnDestroy } from '@angular/core'; +import { Component, OnInit, HostListener, HostBinding, OnDestroy, inject } from '@angular/core'; import { Router, NavigationEnd, ActivatedRoute } from '@angular/router'; import { Title } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -30,6 +30,7 @@ import { ThemeStorageService } from './shared/theme-picker/theme-storage.service import { AlertService } from './core/alert/alert.service'; import { AuthenticationService } from './core/authentication/authentication.service'; import { SettingsService } from './settings/settings.service'; +import { SystemService } from './system/system.service'; import { DocumentationLinksService } from 'app/shared/services/documentation-links.service'; import { IdleTimeoutService } from './home/timeout-dialog/idle-timeout.service'; import { SessionTimeoutDialogComponent } from './home/timeout-dialog/session-timeout-dialog.component'; @@ -102,6 +103,7 @@ export class WebAppComponent implements OnInit, OnDestroy { private authSubscription: Subscription; private destroy$ = new Subject(); + private systemService = inject(SystemService); /** * @param {Router} router Router for navigation. @@ -254,6 +256,27 @@ export class WebAppComponent implements OnInit, OnDestroy { } // Set default max date picker as Today this.settingsService.setBusinessDate(this.dateUtils.formatDate(new Date(), SettingsService.businessDateFormat)); + // ...then replace it with the bank's own "today" (business date when enabled, server date when + // not) once signed in, and again whenever the business date or its on/off switch changes. + // isAuthenticated$ fires at the start of a fresh login, before the auth header is set - a call + // then gets a 401 Basic challenge and the browser's own sign-in box. So it only covers a restored + // session (credentials already saved); a fresh login is caught by 'Authentication Success'. + this.authenticationService.isAuthenticated$.pipe(takeUntil(this.destroy$)).subscribe((loggedIn) => { + if (loggedIn && this.authenticationService.isAuthenticated()) { + this.refreshBusinessToday(); + } + }); + this.alertService.alertEvent + .pipe( + filter( + (alertEvent: Alert) => + alertEvent.type === 'Authentication Success' || + alertEvent.type === SettingsService.businessDateType + ' Set' || + alertEvent.type === SettingsService.businessDateType + ' Set Config' + ), + takeUntil(this.destroy$) + ) + .subscribe(() => this.refreshBusinessToday()); // Set the server list from the env var FINERACT_API_URLS, but avoid overwriting "Add new server" user choice if (!this.settingsService.servers) { this.settingsService.setServers(environment.baseApiUrls.split(',')); @@ -298,6 +321,19 @@ export class WebAppComponent implements OnInit, OnDestroy { } } + /** Stores the server's "today" where every date default reads it (see core/utils/business-today). */ + private refreshBusinessToday(): void { + this.systemService.getBusinessToday().subscribe({ + next: (res) => { + if (res?.today) { + this.settingsService.setBusinessDate(res.today); + } + }, + // Unreachable server or older backend: keep whatever date is already stored. + error: () => {} + }); + } + ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 3e34ed8a3..888a7a52b 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '261005', - 'hash': '1ffac4b1' + 'version': '261006', + 'hash': '5ab4e9ea' }, 'allow_switching_backend_instance': true };