From b184a0909c60f05e0a00aa713faf70baaadc894d Mon Sep 17 00:00:00 2001 From: satyamsingh_8888 Date: Mon, 14 Sep 2026 18:22:00 +0530 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 `