From 0fbf6ec58c4e13bd6882dfbea4f3bffae9c14948 Mon Sep 17 00:00:00 2001 From: LincPay Admin Date: Mon, 14 Sep 2026 11:48:46 +0000 Subject: [PATCH 01/18] Delete .drone.yml --- .drone.yml | 288 ----------------------------------------------------- 1 file changed, 288 deletions(-) delete mode 100644 .drone.yml diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 565892284..000000000 --- a/.drone.yml +++ /dev/null @@ -1,288 +0,0 @@ -kind: pipeline -type: docker -name: auto-deploy - -trigger: - branch: - - dev - event: - - push - -steps: - - # ============================================================ - # DOCKER BUILD & PUSH - # - # Angular build (npm ci + ng build) Dockerfile ke andar - # multi-stage me hota hai, isliye alag build step nahi chahiye. - # ============================================================ - - name: docker-build-push - image: docker - - volumes: - - name: dockersock - path: /var/run/docker.sock - - commands: - - | - set -e - - # -------------------------------------------------------- - # Repository name comes from Drone - # -------------------------------------------------------- - REPO="$${DRONE_REPO_NAME}" - REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')" - - IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}" - - echo "==========================================" - echo "DOCKER BUILD & PUSH" - echo "==========================================" - echo "Repository : $$REPO" - echo "Build : $${DRONE_BUILD_NUMBER}" - echo "Image : $$IMAGE" - echo "==========================================" - - # -------------------------------------------------------- - # Harbor login - # -------------------------------------------------------- - echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \ - docker login harbor.lincpay:8082 \ - -u 'robot$lincpay+drone' \ - --password-stdin - - # -------------------------------------------------------- - # Build - # -------------------------------------------------------- - echo "Building Docker image..." - - docker build \ - -t "$$IMAGE" \ - . - - # -------------------------------------------------------- - # Push - # -------------------------------------------------------- - echo "Pushing Docker image..." - - docker push "$$IMAGE" - - echo "Docker image pushed successfully." - - - # ============================================================ - # DEPLOY - # ============================================================ - - name: deploy - image: docker - - volumes: - - name: dockersock - path: /var/run/docker.sock - - - name: envfiles - path: /platform-env - - commands: - - | - set -e - - # ======================================================== - # REPOSITORY / SERVICE / IMAGE - # ======================================================== - - REPO="$${DRONE_REPO_NAME}" - REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')" - - SERVICE_NAME="$${REPO}_app" - - IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}" - - echo "==========================================" - echo "DEPLOYMENT" - echo "==========================================" - echo "Repository : $$REPO" - echo "Service : $$SERVICE_NAME" - echo "Image : $$IMAGE" - echo "Placement : node.labels.environment == dev" - echo "==========================================" - - - # ======================================================== - # VALIDATION - # ======================================================== - - if [ -z "$$REPO" ]; then - echo "ERROR: DRONE_REPO_NAME is empty." - exit 1 - fi - - if [ -z "$$SERVICE_NAME" ]; then - echo "ERROR: Service name is empty." - exit 1 - fi - - if [ -z "$$IMAGE" ]; then - echo "ERROR: Docker image is empty." - exit 1 - fi - - echo "Repository verified : $$REPO" - echo "Service verified : $$SERVICE_NAME" - echo "Image verified : $$IMAGE" - - - # ======================================================== - # EXISTING SERVICE - # ======================================================== - - if docker service ls --format '{{.Name}}' | grep -Fxq "$$SERVICE_NAME"; then - - echo "" - echo "==========================================" - echo "EXISTING SERVICE" - echo "==========================================" - echo "Service: $$SERVICE_NAME" - - - # ------------------------------------------------------ - # Check whether dev constraint already exists - # ------------------------------------------------------ - - CONSTRAINT_EXISTS=$$(docker service inspect "$$SERVICE_NAME" \ - --format '{{range .Spec.TaskTemplate.Placement.Constraints}}{{println .}}{{end}}' \ - | grep -Fx 'node.labels.environment == dev' || true) - - - # ------------------------------------------------------ - # Add constraint if missing - # ------------------------------------------------------ - - if [ -z "$$CONSTRAINT_EXISTS" ]; then - - echo "Constraint missing." - echo "Adding: node.labels.environment == dev" - - docker service update \ - --constraint-add 'node.labels.environment == dev' \ - "$$SERVICE_NAME" - - else - - echo "Constraint already exists." - - fi - - - # ------------------------------------------------------ - # Update image - # ------------------------------------------------------ - - echo "Updating service image..." - - docker service update \ - --image "$$IMAGE" \ - --replicas 1 \ - --with-registry-auth \ - "$$SERVICE_NAME" - - - # ======================================================== - # NEW SERVICE - # ======================================================== - - else - - echo "" - echo "==========================================" - echo "NEW SERVICE" - echo "==========================================" - echo "Creating: $$SERVICE_NAME" - - - # ------------------------------------------------------ - # Traefik rule - # - # Construct it without allowing Drone to consume - # the shell backticks. - # ------------------------------------------------------ - - TRAEFIK_RULE='Host(`'$$REPO'.lincpay`)' - - echo "Traefik rule: $$TRAEFIK_RULE" - - - # ------------------------------------------------------ - # Create service - # ------------------------------------------------------ - - docker service create \ - --name "$$SERVICE_NAME" \ - --network edge-network \ - --replicas 1 \ - --constraint 'node.labels.environment == dev' \ - --with-registry-auth \ - --env-file /platform-env/global.env \ - --env-file /platform-env/dev.env \ - --label traefik.enable=true \ - --label "traefik.docker.network=edge-network" \ - --label "traefik.http.routers.$$REPO.rule=$$TRAEFIK_RULE" \ - --label "traefik.http.services.$$REPO.loadbalancer.server.port=80" \ - "$$IMAGE" - - fi - - - # ======================================================== - # FINAL STATUS - # ======================================================== - - echo "" - echo "==========================================" - echo "FINAL SERVICE STATUS" - echo "==========================================" - - docker service ls \ - --filter "name=$$SERVICE_NAME" - - - echo "" - echo "==========================================" - echo "PLACEMENT CONSTRAINT" - echo "==========================================" - - docker service inspect "$$SERVICE_NAME" \ - --format '{{json .Spec.TaskTemplate.Placement}}' - - - echo "" - echo "==========================================" - echo "SERVICE TASKS" - echo "==========================================" - - docker service ps "$$SERVICE_NAME" --no-trunc - - - echo "" - echo "==========================================" - echo "DEPLOYMENT COMPLETE" - echo "==========================================" - - -volumes: - - # ============================================================ - # DOCKER SOCKET - # ============================================================ - - name: dockersock - host: - path: /var/run/docker.sock - - - # ============================================================ - # ENVIRONMENT FILES - # ============================================================ - - name: envfiles - host: - path: /opt/platform/env - From 0cef1b5a503f60bc142324463113daa23c418c53 Mon Sep 17 00:00:00 2001 From: keshav Date: Wed, 16 Sep 2026 12:51:24 +0530 Subject: [PATCH 02/18] feat: KYC risk profiling, AML flagged transactions bulk review, Risk Review report & Board Resolution register --- 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 ++++ .../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 | 55 +-- src/app/fiu/fiu.component.ts | 24 +- .../aml-bulk-review-dialog.component.ts | 309 ++++++++++++ .../aml-flagged-transactions.component.html | 272 ++++++++++- .../aml-flagged-transactions.component.scss | 367 ++++++++++++++- .../aml-flagged-transactions.component.ts | 438 +++++++++++++++++- .../aml-review-alert-dialog.component.ts | 19 +- src/app/reports/reports-routing.module.ts | 5 + .../risk-change-approvals.component.ts | 390 ++++++++++++++++ .../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 | 102 +++- .../checker-inbox-and-tasks.component.html | 6 + src/app/tasks/tasks-routing.module.ts | 6 + src/environments/.env.ts | 4 +- 26 files changed, 3309 insertions(+), 159 deletions(-) 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/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 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/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index dd1e065c3..44b97a309 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 ecfdd0210..84743bb5b 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: [''], @@ -221,6 +228,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. @@ -446,6 +458,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; @@ -484,7 +518,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) { @@ -530,6 +565,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() }; @@ -729,7 +765,8 @@ export class KycTabComponent implements OnInit, OnDestroy { dateFormat, riskCategory: this.kycForm.value.riskCategory || 1, ...declarationFields, - ...this.buildExceptionFields() + ...this.buildRiskProfileFields(), + ...this.buildExceptionFields() }; this.clientsService @@ -889,7 +926,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..7d8c63ad2 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 @@ + +
+
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 = ''; + + constructor(@Inject(MAT_DIALOG_DATA) public data: AmlBulkReviewData) { + 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..d566f2142 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 @@ -60,23 +60,113 @@ - +
-
+
-
+ +
-
+ +
+
- + +@if (selectedCount > 0) { +
+ {{ selectedCount }} selected + Low / medium only — a transaction that tripped a high-severity rule is decided on its own. + + + + +
+} + + +
+ + + + + + @@ -113,6 +203,142 @@
{{ alert.ruleCode }} {{ alert.ruleDescription }} + + @if (matchedRuleCount(alert) > 1) { + + } +
+ + + + + + + @@ -132,7 +358,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 + @@ -180,15 +406,33 @@
- + +
- {{ 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..2070f6a36 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,48 @@ 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; +} 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..df87f805a 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,6 +15,7 @@ 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 { Subject, timer, of } from 'rxjs'; import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators'; @@ -24,6 +25,7 @@ import { AmlService } from '../../../aml/aml.service'; import { AlertService } from '../../../core/alert/alert.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'; /** * Live feed of transactions the AML rule engine has flagged. @@ -48,10 +50,11 @@ 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); @@ -62,6 +65,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { alerts = new MatTableDataSource([]); displayedColumns: string[] = [ + 'select', 'raisedOn', 'transaction', 'client', @@ -71,6 +75,283 @@ 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; + + + 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[] = []; @@ -84,12 +365,19 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { activeStatus = ''; private statusCounts: Record = {}; + /** + * 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 +394,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 +458,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,47 +518,63 @@ 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 + /** + * @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 { + const byStatus = this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus) : this.allAlerts; + this.alerts.data = + this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity); 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. + */ get summaryTotal(): number { - return this.alerts.data.length; + return this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus).length : this.allAlerts.length; } private recomputeStatusCounts(): void { @@ -330,4 +661,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..2d6825d64 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,12 @@ 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..51a3e1798 --- /dev/null +++ b/src/app/risk-review/risk-change-approvals.component.ts @@ -0,0 +1,390 @@ +/** + * 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); + + form: FormGroup; + busy = false; + + constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) { + 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 8f0775fc3..3b4fd2fb4 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); + }); } /** @@ -336,9 +357,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; @@ -364,10 +387,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[] = [ { @@ -378,25 +454,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 "no change needed", 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 rate them, and grouping them under "no change needed" buried the + // one action the report was asking for. + key: 'unrated', + title: 'Rating needed', + 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. "No change needed" read as though + // these customers were fine, which is not what it means: a High-rated customer with four open alerts is on + // this list, and the reason is that the conditions for lowering the rating are not met - not that there is + // nothing to look at. 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: 'Rating holds — no change this period', + hint: 'An alert brought these customers onto the report, but the conditions for moving the rating are not met. 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') } ]; @@ -575,6 +668,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. * @@ -589,25 +743,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..558b80ab5 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, @@ -109,6 +109,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 { @@ -158,7 +160,7 @@ function recommendRating( ruleCodes: string[] ): { 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; @@ -271,7 +273,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 +328,53 @@ 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) + ); // 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 +397,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 +529,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/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/environments/.env.ts b/src/environments/.env.ts index c3226b95c..4509e26ef 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260914', - 'hash': 'ee5a430b' + 'version': '260916', + 'hash': 'c01615ee' }, 'allow_switching_backend_instance': true }; From d18bd9cf37ffa02e95ea21ba848f636be42fdc0b Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 15 Sep 2026 11:13:20 +0530 Subject: [PATCH 03/18] fix(print): fix app-wide printing and remove a global print-CSS leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of blank/clipped print output across the app: the document root (html/body) is globally locked to one viewport height with overflow hidden, and Angular Material's drawer/tab-body containers each add their own on-screen scroll clip on top of that. main.scss now frees the full chain for print (document root, CDK drawer/sidenav containers, mat-tab-body, and the account-view's mat-tab-nav-bar which was rendering as a stray grey scrollable strip since it isn't covered by the mat-tab-group header rule already hidden). Also fixed both certificate print dialogs (share-certificate-dialog, deposit-certificate-dialog) reusing a `::ng-deep body > :not(.cdk-overlay- container)` rule to hide the shell during their own print: under Angular's style encapsulation this style is emitted fully unscoped into the global stylesheet and is never removed once the component has loaded once in a session, silently blanking every other page's print output for the rest of the session. Dropped `::ng-deep` from that one selector so it goes back to being properly scoped (and therefore inert, matching the other two dialogs that never had this bug) — the app shell is already hidden for every print via the global main.scss rules, so no dialog needs its own global override. --- .../certificate-preview-dialog.component.scss | 16 +- .../share-certificate-dialog.component.scss | 181 +++++++++++++++++- src/main.scss | 113 +++++++++-- 3 files changed, 283 insertions(+), 27 deletions(-) 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/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/main.scss b/src/main.scss index 387d35837..c6f7900ea 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 `
- + @if (acTypLockedByToken && row === selectedRow) { + {{ acTypLockedLabel }} + } @else { + + }
@@ -206,14 +214,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' " /> @@ -1523,22 +1537,30 @@
- + @if (acTypLockedByToken && row === selectedRow) { + {{ acTypLockedLabel }} + } @else { + + }
@@ -1556,10 +1578,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' " /> 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..c7daa9584 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -1703,15 +1703,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 +1729,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' @@ -2321,14 +2351,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') + ); } /** @@ -2408,19 +2445,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.' @@ -2510,29 +2552,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 +2585,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; @@ -3044,6 +3090,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 +3311,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 +3353,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 +3384,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 +3400,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 +3443,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 +3569,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 +4754,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 ( 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/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-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/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index 707a5a9da..9ee34c484 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); From 41540ad24ef4823b02bcc18997c9881f5eb83da3 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 17 Sep 2026 13:04:09 +0530 Subject: [PATCH 05/18] feat(share-products): retire the Share Reference mapping, remove dead Cashier Share Collection screen Share Products: - "Share Reference (Cash Vault or Suspense)" removed from the accounting step (create, edit, preview and the read-only view tab) -- every register now books a share transaction against the account the money actually reached, so no product needs a placeholder asset mapping any more. The field is now optional server-side, not required. Cashier: - Removed the Share Collection screen and its three service methods. It read m_share_account.cashier_collected, a column that has never existed in this schema, and no other screen ever called it -- it could not have produced a working entry. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/cashier/cashier-routing.module.ts | 6 - src/app/cashier/cashier.module.ts | 4 +- src/app/cashier/cashier.service.ts | 13 - .../share-collection.component.html | 375 ---------- .../share-collection.component.scss | 701 ------------------ .../share-collection.component.ts | 282 ------- ...share-product-accounting-step.component.ts | 35 +- .../share-product-preview-step.component.html | 8 - .../share-product-general-tab.component.html | 10 - 9 files changed, 2 insertions(+), 1432 deletions(-) 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 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 4ef40be49..ad24b948b 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -364,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 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/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 }}

Date: Thu, 17 Sep 2026 17:45:05 +0530 Subject: [PATCH 06/18] feat(shares, cashier): Entry Type tagging on Transaction Entry, real GL details on receipts Adds an Entry Type field to Transaction Entry's GL-crediting rows (Receipt / Collection), fetched from the backend's GLEntryType code, so the counter can flag what a GL credit is actually for (General / Share Collection / DD Issue) instead of leaving every GL account picker throughout Share Advance Collection guessing from the whole chart of accounts. Apply Share / Additional Share screens: - GL Account picker narrowed to accounts that actually have an eligible receipt, and only auto-selected when exactly one candidate exists -- no more defaulting to whichever account sorts first (Cash at Vault). - Voucher tables show Description and Collected By, and a Detail view exposes the receipt's real Dr/Cr GL legs instead of the GL Account dropdown's current unrelated selection. - Additional Share funds from a single voucher (radio-style selection); ticking a receipt syncs the GL Account picker to its own actual account. - Checker's Additional Share Request review dialog gets the same Description + real GL entry detail, expandable per voucher, so approval is never a guess about what will post. Co-Authored-By: Claude Sonnet 5 --- src/app/cashier/cashier.service.ts | 16 + .../transaction-entry.component.html | 38 + .../transaction-entry.component.ts | 45 +- .../apply-shares/apply-shares.component.html | 764 +++++++---- .../apply-shares/apply-shares.component.scss | 1137 +++++++++++++++-- .../apply-shares/apply-shares.component.ts | 74 +- .../apply-collection-dialog.component.html | 6 +- .../apply-collection-dialog.component.ts | 6 + ...ition-request-detail-dialog.component.html | 54 +- ...ition-request-detail-dialog.component.scss | 90 ++ ...ddition-request-detail-dialog.component.ts | 26 + 11 files changed, 1866 insertions(+), 390 deletions(-) diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index ad24b948b..83d3ba55b 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -401,6 +401,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'); @@ -413,4 +419,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/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index b6732deb6..ee14e4589 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -191,6 +191,25 @@ }
+ @if (showsEntryType(row)) { +
+ + +
+ }
@@ -1562,6 +1581,25 @@ }
+ @if (showsEntryType(row)) { +
+ + +
+ }
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index c7daa9584..a96bf4f00 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; @@ -1864,6 +1877,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'); @@ -2379,6 +2398,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row); } + /** + * 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 }[] = []; + /** GST on DD charges only (never on the DD face value), at the admin-configured rate. */ ddGstAmountFor(row: TxnRow): number { const charges = Number(row.ddCharges) || 0; @@ -2848,7 +2884,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; @@ -5905,6 +5944,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/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..4e7601cb0 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,112 +6,189 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- +
+ + +
+
+ add_card +
+
+

{{ 'Apply Additional Shares' | translate }}

+

Purchase additional share tranches via cash receipts or savings debit

+
+
+
- -
- - {{ 'labels.inputs.Request Date' | translate }} - - - - @if (applySharesForm.controls.requestedDate.hasError('required')) { - - {{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - - {{ 'labels.inputs.Resolution Number' | translate }} - - @if (applySharesForm.controls.resolutionNumber.hasError('required')) { - - {{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ '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 - + + +
+
+ assignment + Application & Resolution Details
- @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. -

+
+ + + {{ 'labels.inputs.Request Date' | translate }} + + + + @if (applySharesForm.controls.requestedDate.hasError('required')) { + + {{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + -
+ + + {{ 'labels.inputs.Resolution Number' | translate }} + + @if (applySharesForm.controls.resolutionNumber.hasError('required')) { + + {{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + + {{ 'labels.inputs.Resolution Date' | translate }} + + + + @if (applySharesForm.controls.resolutionDate.hasError('required')) { + + {{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
+
+ + +
+
+ calculate + Share Purchase Calculation +
+ +
+ + + {{ '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 }} + + per share + + + +
+ {{ 'labels.inputs.Amount' | translate }} + {{ amount | number: '1.2-2' }} + {{ applySharesForm.get('requestedShares').value || 0 }} shares × + {{ sharesAccountData?.currentMarketPrice || 0 }} +
+
+
+ + +
+
+ account_balance_wallet + {{ 'labels.inputs.Payment Option' | translate }} +
+ +
+
+
+ payments +
+
+ Apply Collection (Cash) + Link receipts paid at counter sitting in Share Sundry +
+ @if (paymentMode === 'CASH') { + check_circle + } +
+ +
+
+ account_balance +
+
+ Debit from Savings + Direct debit from member's linked savings account +
+ @if (paymentMode === 'SAVINGS') { + check_circle + } +
+
+
+ + + @if (paymentMode === 'CASH') { +
+
+ + + Select the one receipt that covers this payment, or enter a voucher number to search. Click + visibility on any row to view full details popup. + +
+ + +
+
+ - -
- - @if (voucherError) { -
- error_outline - {{ voucherError }} +
+ +
+ +
- } +
+
+ @if (voucherError) { +
+ error_outline + {{ voucherError }} +
+ } + + +
@if (loadingVouchers) {
- Loading the member's receipts… + Loading member 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. -
+ receipt_long +
+ No unapplied receipts found for this member. +

+ Collect cash at Cashier › Transaction Entry as a deposit to Share Sundry GL. Once + posted, it will appear 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' }} -
+
+ +
+
+ receipt + Vouchers / Receipts + {{ vouchers.length }} +
+
+ + +
+ + + + + + + + + + + + + + + @for (v of vouchers; track v.id) { + + + + + + + + + + } + +
#Voucher / Receipt NoDateDescriptionCollected ByAmountDetail
+ + + {{ v.voucher_no || v.receipt_no }} + {{ v.collection_date }}{{ v.note || 'Share Collection' }} + + person_outline + {{ v.collected_by_name || 'System' }} + + + {{ v.amount | 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. + Selected total {{ selectedVoucherTotal | number: '1.2-2' }} is + {{ voucherShortfall | number: '1.2-2' }} short of tranche target + {{ amount | number: '1.2-2' }}. Tick another receipt or collect balance.
} }
- } +
+ } - @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 (paymentMode === 'SAVINGS') { +
+
+ + + The tranche amount will be automatically debited from the member's linked savings account into Share + Sundry upon submission. + +
- @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 (checkingSavings) { +
+ + Checking linked savings account eligibility… +
+ } @else if (savingsEligibility) { +
+ @if (savingsEligibility.savingsAccountNo) { +
+ Savings Account No. + {{ savingsEligibility.savingsAccountNo }}
-
- - @if (!savingsEligibility.eligible) { -
- error_outline - {{ savingsEligibility.reason }} +
+ Available Balance + {{ savingsEligibility.savingsBalance | number: '1.2-2' }}
} +
+ Amount to Debit + {{ amount | number: '1.2-2' }} +
+
+ + @if (!savingsEligibility.eligible) { +
+ error_outline + {{ savingsEligibility.reason }} +
} -
- } + } +
+ } - @if (errorMsg) { -
- error_outline - {{ errorMsg }} -
- } + @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. -

+ +
+ schedule + + Submitting records a request for approval. Shares are issued and funds moved out of Share Sundry only upon + manager approval. +
- - -
+ + +@if (viewingVoucherDetail) { + +} 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..80603efdc 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,201 +6,1027 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -$primary: #1a3a6b; -$border: #d8e0ec; -$muted: #6b7a90; -$danger: #b3261e; +// Modern Color Tokens +$primary: #1e3a8a; +$primary-light: #eff6ff; +$primary-border: #bfdbfe; +$accent-blue: #2563eb; +$text-main: #0f172a; +$text-muted: #64748b; +$border-color: #e2e8f0; +$border-light: #f1f5f9; +$bg-surface: #fff; +$bg-slate: #f8fafc; -.container { - max-width: 37rem; +$success: #059669; +$success-bg: #ecfdf5; +$success-border: #a7f3d0; + +$warning: #d97706; +$warning-bg: #fffbeb; +$warning-border: #fde68a; + +$danger: #dc2626; +$danger-bg: #fef2f2; +$danger-border: #fecaca; + +.action-container { + max-width: 72rem; + margin: 0.75rem auto; + padding: 0 0.5rem; } -.payment-mode { - margin: 4px 0 16px; +.action-card { + border-radius: 12px; + border: 1px solid $border-color; + background: $bg-surface; + box-shadow: 0 4px 16px -4px rgb(15 23 42 / 6%); + overflow: hidden; } -.payment-mode-label { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.4px; - color: $muted; - margin-bottom: 8px; +/* Compact Header */ +.card-header-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + color: #fff; + + .header-icon-box { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + background: rgb(255 255 255 / 20%); + border-radius: 8px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #fff; + } + } + + .header-text { + h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: #fff; + } + + p { + margin: 1px 0 0; + font-size: 0.78rem; + color: rgb(255 255 255 / 85%); + } + } } -.payment-mode-options { +.card-body { + padding: 0.85rem !important; +} + +/* Form Sections */ +.form-section { + background: $bg-surface; + border: 1px solid $border-light; + border-radius: 10px; + padding: 0.75rem 0.85rem; + margin-bottom: 0.65rem; + + &.financial-section { + background: $bg-slate; + border-color: $border-color; + } +} + +.section-title { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + font-weight: 700; + color: $text-main; + margin-bottom: 0.65rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid $border-light; + + .sec-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } +} + +/* Compact Form Grids */ +.form-grid { + display: grid; + gap: 0.65rem; + + &.form-grid-3 { + grid-template-columns: repeat(3, 1fr); + + @media (width <= 680px) { + grid-template-columns: 1fr; + } + } +} + +.align-items-center { + align-items: center; +} + +mat-form-field { + width: 100%; +} + +.shares-count-input { + text-align: left; +} + +// Disabled read-back field (auto-filled market price) — right-aligned so the figure sits right +// next to its "per share" suffix instead of stranded at the far left of an otherwise-empty field. +.price-input { + text-align: right; +} + +// Material fades a disabled outline field almost to invisibility by default, which next to the +// enabled "Total No. of Shares" field (a solid dark border) reads as a missing box rather than a +// deliberately read-only one. Matched back to the same border weight and label/value contrast. +.price-field ::ng-deep { + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: $border-color !important; + opacity: 1 !important; + } + + .mdc-floating-label { + color: $text-muted !important; + } + + input:disabled { + color: $text-main !important; + -webkit-text-fill-color: $text-main !important; + } +} + +.currency-label { + color: $text-muted; + font-size: 0.8rem; + white-space: nowrap; + margin-left: 0.35rem; +} + +/* Financial Stat Display Card */ +.amount-display-card { display: flex; flex-direction: column; - gap: 4px; + justify-content: center; + background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); + border: 1px solid $primary-border; + border-radius: 8px; + padding: 0.45rem 0.75rem; + height: 48px; + box-sizing: border-box; + + .amount-label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #1e40af; + font-weight: 700; + } + + .amount-value { + font-size: 1.1rem; + font-weight: 700; + color: #1e3a8a; + font-family: 'Courier New', monospace; + line-height: 1.1; + } + + .amount-subtext { + font-size: 0.65rem; + color: #3b82f6; + } } -/* 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. */ +/* Payment Option Radio Cards */ +.payment-card-group { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.65rem; + + @media (width <= 580px) { + grid-template-columns: 1fr; + } +} + +.payment-card-option { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.65rem 0.75rem; + border: 1.5px solid $border-color; + border-radius: 10px; + background: $bg-surface; + cursor: pointer; + user-select: none; + transition: all 0.15s ease; + + .option-icon-box { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: $bg-slate; + color: $text-muted; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } + + .option-content { + display: flex; + flex-direction: column; + + .option-title { + font-size: 0.82rem; + font-weight: 600; + color: $text-main; + } + + .option-desc { + font-size: 0.72rem; + color: $text-muted; + } + } + + .selected-check { + margin-left: auto; + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + + &:hover { + border-color: #93c5fd; + background: #f8fafc; + } + + &.active { + border-color: $accent-blue; + background: $primary-light; + + .option-icon-box { + background: $accent-blue; + color: #fff; + } + } +} + +/* Funding Panel */ .funding-panel { - border: 1px solid $border; + border: 1px solid $border-color; + border-radius: 10px; + padding: 0.75rem; + background: #fcfdfe; + margin-top: 0.4rem; + margin-bottom: 0.65rem; +} + +.funding-intro-banner { + display: flex; + align-items: center; + gap: 0.5rem; + background: #f0f9ff; + border: 1px solid #bae6fd; border-radius: 6px; - padding: 14px; - margin-bottom: 16px; - background: #fbfcfe; + padding: 0.45rem 0.65rem; + font-size: 0.78rem; + color: #0369a1; + margin-bottom: 0.65rem; + + .banner-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #0284c7; + flex-shrink: 0; + } + + .inline-icon { + font-size: 14px; + width: 14px; + height: 14px; + vertical-align: middle; + } } -.funding-intro { - font-size: 13px; - color: $muted; - margin: 0 0 12px; +/* Lookup Toolbar */ +.lookup-toolbar { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; + background: $bg-slate; + padding: 0.65rem; + border-radius: 8px; + border: 1px solid $border-color; + margin-bottom: 0.65rem; + + @media (width <= 600px) { + grid-template-columns: 1fr; + } } -.gl-select-field { - margin: 0 0 10px; +.toolbar-label { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + color: $text-muted; + margin-bottom: 4px; + + .mini-icon { + font-size: 14px; + width: 14px; + height: 14px; + } } -.gl-select { +.custom-select { width: 100%; - padding: 9px 11px; - border: 1px solid $border; + height: 34px; + padding: 0 0.65rem; + border: 1px solid #cbd5e1; border-radius: 6px; - font-size: 13px; - color: $primary; + font-size: 0.8rem; + color: $text-main; background: #fff; &:focus { outline: none; - border-color: $primary; - } - - &:disabled { - background: #f4f7fb; - color: $muted; + border-color: $accent-blue; } } -.voucher-lookup { +.lookup-input-group { display: flex; - gap: 8px; - align-items: center; - margin: 0 0 12px; -} + gap: 0.4rem; -.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; + .lookup-input { + flex: 1; + height: 34px; + padding: 0 0.65rem; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 0.8rem; + color: $text-main; + background: #fff; - &:focus { - outline: none; - border-color: $primary; + &:focus { + outline: none; + border-color: $accent-blue; + } } - &:disabled { - background: #f4f7fb; - color: $muted; + .find-btn { + height: 34px; + border-radius: 6px; + padding: 0 0.75rem; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.8rem; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } } } -.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; +/* Vouchers Array Card & Table */ +.vouchers-card { + border: 1px solid $border-color; + border-radius: 8px; background: #fff; - padding: 10px 12px; - font-size: 13px; + overflow: hidden; } -.funding-row { +.vouchers-header { display: flex; justify-content: space-between; - gap: 12px; - padding: 4px 0; - color: $primary; + align-items: center; + padding: 0.5rem 0.75rem; + background: #f1f5f9; + border-bottom: 1px solid $border-color; - > span:first-child { - color: $muted; + .vouchers-title { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.84rem; + font-weight: 700; + color: $text-main; + + .v-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: $accent-blue; + } + + .badge-count { + background: $accent-blue; + color: #fff; + font-size: 0.7rem; + font-weight: 700; + padding: 0 6px; + border-radius: 999px; + } + } + + .select-all-btn { + font-size: 0.75rem; + color: $accent-blue; + font-weight: 600; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + margin-right: 2px; + } } } -.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; - 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; +.table-responsive { + width: 100%; + overflow-x: auto; } .receipt-table { width: 100%; - border-collapse: collapse; - font-size: 13px; - background: #fff; - border: 1px solid $border; - border-radius: 6px; + border-collapse: separate; + border-spacing: 0; + font-size: 0.78rem; th { - background: #eef2f8; - color: $primary; - font-size: 11px; + background: $bg-slate; + color: $text-muted; + font-size: 0.7rem; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.4px; - padding: 8px; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid $border-color; } td { - padding: 8px; - border-bottom: 1px solid #eceff4; + padding: 0.45rem 0.65rem; + border-bottom: 1px solid $border-light; + vertical-align: middle; } - tbody tr { + .receipt-row { cursor: pointer; + transition: background 0.12s ease; &:hover { - background: #f4f7fb; + background: #f8fafc; } &.selected { - background: #e7effb; + background: #eff6ff; + + .voucher-badge { + background: #dbeafe; + color: #1e40af; + border-color: #bfdbfe; + } } } - /* 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; + .col-checkbox { + width: 36px; + } + + .col-actions { + width: 44px; + } + + .custom-checkbox { + width: 16px; + height: 16px; + accent-color: $accent-blue; + cursor: pointer; + } + + .voucher-badge { + display: inline-block; + background: #f1f5f9; + color: #334155; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid #cbd5e1; + font-weight: 600; + font-size: 0.78rem; + } + + .desc-cell { + color: $text-muted; + max-width: 160px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .user-chip { + display: inline-flex; + align-items: center; + gap: 3px; + background: $bg-slate; + border: 1px solid $border-color; + padding: 1px 6px; + border-radius: 999px; + font-size: 0.72rem; + color: $text-muted; + + .chip-icon { + font-size: 12px; + width: 12px; + height: 12px; + } + } + + .amount-cell { + color: $text-main; + font-size: 0.84rem; + } + + .view-detail-btn { + width: 28px; + height: 28px; + line-height: 28px; + + .eye-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + } +} + +/* Vouchers Footer Bar */ +.vouchers-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background: $bg-slate; + border-top: 1.5px solid #cbd5e1; + flex-wrap: wrap; + gap: 0.5rem; + + .selected-summary { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + + .summary-text { + font-size: 0.78rem; + color: $text-muted; + } + + .summary-amounts { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; + + .total-label { + color: $text-muted; + } + + .total-value { + color: $primary; + font-family: 'Courier New', monospace; + } + + .target-divider { + color: $text-muted; + } + + .target-value { + color: $text-muted; + } + } + } +} + +/* Coverage Status Badges */ +.coverage-status { + .status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &.badge-neutral { + background: #f1f5f9; + color: #64748b; + border: 1px solid #cbd5e1; + } + + &.badge-warning { + background: $warning-bg; + color: $warning; + border: 1px solid $warning-border; + } + + &.badge-success { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } +} + +/* Savings Panel Details */ +.savings-card { + background: #fff; + border: 1px solid $border-color; + border-radius: 8px; + padding: 0.65rem 0.85rem; +} + +.savings-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.35rem 0; + + .lbl { + font-size: 0.78rem; + color: $text-muted; + } + + .val { + font-size: 0.82rem; + color: $text-main; + } + + &.savings-row-total { + border-top: 1px dashed $border-color; + margin-top: 0.2rem; + padding-top: 0.5rem; + + .lbl { + font-weight: 600; + color: $text-main; + } + + .val { + font-size: 0.95rem; + font-weight: 700; + } + } +} + +/* Alert Boxes */ +.alert-box { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 8px; + font-size: 0.78rem; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + flex-shrink: 0; + } + + &.alert-error { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } + + &.alert-warning { + background: $warning-bg; + color: #92400e; + border: 1px solid $warning-border; + } +} + +/* Pending Notice Banner */ +.pending-notice { + display: flex; + align-items: center; + gap: 0.5rem; + background: #f8fafc; + border: 1px solid $border-color; + border-radius: 8px; + padding: 0.5rem 0.75rem; + font-size: 0.76rem; + color: $text-muted; + + .notice-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: $accent-blue; + flex-shrink: 0; + } +} + +/* Action Buttons Bar */ +.card-actions-bar { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.65rem; + padding: 0.75rem 1rem !important; + background: $bg-slate; + border-top: 1px solid $border-color; + + .action-btn { + height: 36px; + padding: 0 1.25rem; + border-radius: 6px; + font-size: 0.82rem; + font-weight: 600; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + margin-right: 4px; + } + } +} + +/* POPUP MODAL DIALOG STYLES */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 50%); + backdrop-filter: blur(4px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.voucher-modal-dialog { + background: #fff; + border-radius: 12px; + width: 100%; + max-width: 36rem; + box-shadow: 0 20px 40px -10px rgb(15 23 42 / 25%); + border: 1px solid $border-color; + overflow: hidden; + animation: modal-scale-in 0.2s ease-out; + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.85rem 1.25rem; + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + color: #fff; + + .header-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.95rem; + font-weight: 700; + + .m-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + + .modal-badge { + background: rgb(255 255 255 / 20%); + padding: 2px 8px; + border-radius: 6px; + font-size: 0.8rem; + } + } + + .close-btn { + color: #fff; + width: 32px; + height: 32px; + line-height: 32px; + } + } + + .modal-body { + padding: 1rem 1.25rem; + max-height: 70vh; + overflow-y: auto; + } + + .detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + + @media (width <= 480px) { + grid-template-columns: 1fr; + } + } + + .detail-item { + display: flex; + flex-direction: column; + gap: 2px; + background: $bg-slate; + padding: 0.5rem 0.75rem; + border-radius: 8px; + border: 1px solid $border-light; + + .detail-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + font-weight: 600; + } + + .detail-val { + font-size: 0.84rem; + color: $text-main; + } + + &.full-width { + grid-column: 1 / -1; + } + + &.highlight-amount-box { + background: $primary-light; + border-color: $primary-border; + + .detail-label { + color: #1e40af; + } + + .amount-val { + font-size: 1.25rem; + font-weight: 700; + color: #1e3a8a; + } + } + } + + .status-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + width: fit-content; + + &.status-unapplied { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } + + .note-box { + background: #fff; + border: 1px solid $border-color; + border-radius: 6px; + padding: 0.5rem 0.65rem; + font-size: 0.8rem; + color: $text-main; + line-height: 1.4; + margin-top: 4px; + white-space: pre-wrap; + } + + .gl-entries-loading, + .gl-entries-empty { + font-size: 0.8rem; + color: $text-muted; + padding: 0.5rem 0; + } + + .gl-entries-table { + width: 100%; + border-collapse: collapse; + margin-top: 4px; + font-size: 0.8rem; + background: #fff; + border: 1px solid $border-color; + border-radius: 6px; + overflow: hidden; + + th { + background: $bg-slate; + color: $text-muted; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid $border-color; + } td { - padding-top: 10px; - color: #1f3d7a; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid $border-light; + color: $text-main; } + + tr:last-child td { + border-bottom: none; + } + } + + .drcr-pill { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + + &.drcr-pill--dr { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } + + &.drcr-pill--cr { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.65rem; + padding: 0.75rem 1.25rem; + background: $bg-slate; + border-top: 1px solid $border-color; + + .modal-action-btn { + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } + } +} + +@keyframes modal-scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); } } @@ -216,21 +1042,50 @@ $danger: #b3261e; 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; +.font-mono { + font-family: 'Courier New', monospace; } -.pending-hint { - font-size: 12px; - color: $muted; - margin: 0; +.font-semibold { + font-weight: 600; +} + +.font-accent { + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; +} + +.font-success { + color: $success !important; +} + +.mt-8 { + margin-top: 8px; +} + +.mt-10 { + margin-top: 10px; +} + +.mt-12 { + margin-top: 12px; +} + +.animate-fade-in { + animation: fade-in 0.2s ease-in-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } } 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..8922da149 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 @@ -107,6 +107,15 @@ export class ApplySharesComponent implements OnInit { glAccounts: any[] = []; loadingGlAccounts = true; + /** Currently selected voucher for viewing in the detail popup. */ + viewingVoucherDetail: any = null; + + /** The receipt's own real Dr/Cr GL legs, fetched fresh per voucher — never the GL Account + * dropdown above (that is just what the by-voucher search box is currently set to, which can + * name a completely different account than the one this specific receipt actually sits in). */ + voucherGlEntries: { glCode: string; accountName: string; drCr: string; amount: number }[] = []; + loadingGlEntries = false; + /** What the member's savings can cover, for the savings route. */ savingsEligibility: { eligible: boolean; @@ -134,10 +143,43 @@ export class ApplySharesComponent implements OnInit { .pipe(catchError(() => of(null))) .subscribe((res: any) => { this.glAccounts = res?.glAccounts || []; + // Auto-picked only when there is exactly one live candidate -- the backend already narrowed + // this list to accounts that actually hold an eligible receipt today, so a single option is + // unambiguous. Two or more still means the counter chooses by hand: picking "whichever + // sorts first" among several real candidates is the same blind guess that used to land on + // Cash at Vault, just with a shorter, filtered list to guess from. + if (this.glAccounts.length === 1 && !this.glAccountNo) { + this.glAccountNo = this.glAccounts[0].gl_code; + } this.loadingGlAccounts = false; }); } + setPaymentMode(mode: string): void { + this.applySharesForm.get('paymentMode')?.setValue(mode); + } + + openVoucherDetail(voucher: any, event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.viewingVoucherDetail = voucher; + this.voucherGlEntries = []; + this.loadingGlEntries = true; + this.cashierService + .getShareAdvanceGlEntries(voucher.id) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.voucherGlEntries = res?.entries || []; + this.loadingGlEntries = false; + }); + } + + closeVoucherDetail(): void { + this.viewingVoucherDetail = null; + this.voucherGlEntries = []; + } + /** * Creates the apply shares form. * @@ -254,12 +296,24 @@ export class ApplySharesComponent implements OnInit { return this.selectedVoucherIds.includes(id); } + /** + * One receipt funds one tranche — no combining instalments here (unlike the first-application + * Apply Collection dialog, which still allows several). Picking a different receipt replaces the + * current one rather than adding to it, so {@link selectedVoucherIds} never holds more than one id. + */ toggleVoucher(id: number): void { - this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [ - ...this.selectedVoucherIds, - id - ]; + const wasSelected = this.isVoucherSelected(id); + this.selectedVoucherIds = wasSelected ? [] : [id]; this.voucherError = ''; + // The GL Account picker above follows whichever receipt is ticked — its own actual GL account + // (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt + // that plainly does have one is already chosen. + if (!wasSelected) { + const voucher = this.vouchers.find((v) => v.id === id); + if (voucher?.gl_code) { + this.glAccountNo = voucher.gl_code; + } + } } /** @@ -307,19 +361,13 @@ export class ApplySharesComponent implements OnInit { 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. + // Replaces the selection rather than adding to it — one receipt funds one tranche here, so + // finding a voucher by number is a different way to pick THE receipt, not a second one. this.vouchers = [ res, ...this.vouchers.filter((v) => v.id !== res.id) ]; - if (!this.isVoucherSelected(res.id)) { - this.selectedVoucherIds = [ - ...this.selectedVoucherIds, - res.id - ]; - } + this.selectedVoucherIds = [res.id]; }); } 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..756d2d39e 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 @@ -168,6 +168,8 @@ Voucher / Receipt Date + Description + Collected By Amount @@ -186,6 +188,8 @@ {{ r.voucher_no || r.receipt_no }} {{ r.collection_date }} + {{ r.note || '—' }} + {{ r.collected_by_name || '—' }} {{ r.amount | number: '1.2-2' }} } @@ -197,7 +201,7 @@ - + {{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected {{ selectedTotal | number: '1.2-2' }} 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..b556591c7 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 @@ -135,6 +135,12 @@ export class ApplyCollectionDialogComponent implements OnInit { ) .subscribe((res: any) => { this.glAccounts = res?.glAccounts || []; + // Auto-picked only when there is exactly one live candidate -- the backend already narrowed + // this list to accounts that actually hold an eligible receipt today, so a single option is + // unambiguous. Two or more still means the counter chooses by hand. + if (this.glAccounts.length === 1 && !this.glAccountNo) { + this.glAccountNo = this.glAccounts[0].gl_code; + } this.loadingGlAccounts = false; }); 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'); } From f9b73a4930aa7effdeac88aa5a152df7b1df676e Mon Sep 17 00:00:00 2001 From: keshav Date: Fri, 18 Sep 2026 10:32:47 +0530 Subject: [PATCH 07/18] feat(aml,risk-review,accounting): update financial activity mapping options, AML flagged transactions filters, and risk review report --- ...te-financial-activity-mapping.component.ts | 21 +- ...it-financial-activity-mapping.component.ts | 21 +- .../financial-activity-account-options.ts | 38 ++ src/app/fiu/fiu.component.html | 139 +++---- src/app/fiu/fiu.component.ts | 82 ++-- .../aml-bulk-review-dialog.component.ts | 8 +- .../aml-flagged-transactions.component.html | 173 ++++++-- .../aml-flagged-transactions.component.scss | 109 +++++ .../aml-flagged-transactions.component.ts | 384 +++++++++++++++++- .../aml-review-alert-dialog.component.ts | 7 +- .../risk-change-approvals.component.ts | 19 +- .../risk-review-report.component.ts | 29 +- src/app/risk-review/risk-review.compute.ts | 63 ++- src/environments/.env.ts | 4 +- 14 files changed, 863 insertions(+), 234 deletions(-) create mode 100644 src/app/accounting/financial-activity-mappings/financial-activity-account-options.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/fiu/fiu.component.html b/src/app/fiu/fiu.component.html index d2f31d2ec..3c4946314 100644 --- a/src/app/fiu/fiu.component.html +++ b/src/app/fiu/fiu.component.html @@ -167,7 +167,9 @@
-
Due: {{ ctrDueDateLabel }}
+
+ Due: {{ ctrDueDateLabel }} +
@@ -220,9 +222,7 @@ @if (ctrLinesLoading) {
Loading the generated CTR lines…
} @else if (ctrLinesError) { -
- {{ ctrLinesError }} -
+
{{ ctrLinesError }}
} @else if (!ctrTxns.length) {
@@ -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 7e45eb931..245162f51 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -220,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 = ''; @@ -633,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 || [])); @@ -679,7 +694,8 @@ export class FiuComponent implements OnInit { this.ctrLinesLoading = false; this.ctrTxns = []; this.ctrLinesError = - err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.'; + err?.error?.defaultUserMessage || + 'Could not load the generated CTR lines. Generate a report for the month first.'; } }); } @@ -779,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'); @@ -1039,12 +1013,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'); @@ -1707,7 +1675,9 @@ export class FiuComponent implements OnInit { private writeLiveStrXml(cases: any[], filename: string): void { const xml: string[] = []; xml.push(''); - xml.push(``); + xml.push( + `` + ); xml.push(` `); if (this.principalOfficerName) { xml.push(` `); diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts index 02a8097d4..e49fa73b5 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts @@ -57,8 +57,8 @@ export interface AmlBulkReviewData {

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

@@ -218,7 +218,9 @@ export class AmlBulkReviewDialogComponent { submitting = false; errorMessage = ''; - constructor(@Inject(MAT_DIALOG_DATA) public data: AmlBulkReviewData) { + data: AmlBulkReviewData = inject(MAT_DIALOG_DATA); + + constructor() { this.form = this.fb.group({ evidence: this.fb.array(this.evidenceTypes.map(() => false)), evidenceRef: [ 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 d566f2142..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,6 +117,34 @@
+ +
+ 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 }} + + } +
+} +
@@ -103,7 +188,9 @@ @if (selectedCount > 0) {
{{ selectedCount }} selected - Low / medium only — a transaction that tripped a high-severity rule is decided on its own. + Low / medium only — a transaction that tripped a high-severity rule is decided on its own. -
-
- - {{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }} - - - - description - - - → STR - -
+ + @if (heldByAnother(alert)) { +
+ + lock + + with {{ heldBy(alert) }} +
+ } @else { +
+ + {{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }} + + + + description + + + → STR + +
+ }
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 2070f6a36..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 @@ -902,3 +902,112 @@ 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 df87f805a..6f5b070ea 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 @@ -17,15 +17,21 @@ 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. @@ -43,6 +49,7 @@ import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component standalone: true, imports: [ CommonModule, + FormsModule, MatTableModule, MatPaginatorModule, MatIconModule, @@ -58,6 +65,10 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O 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. */ @@ -146,7 +157,15 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O /** 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. @@ -164,7 +183,9 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * 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 }[] { + matchedRules( + alert: any + ): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] { if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) { return alert.matchedRules; } @@ -358,13 +379,226 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O 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 @@ -530,11 +764,16 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * it — doing that on the first pass left pageSize at 0 and emptied the table. */ private applyStatusFilter(resetPage = false): void { - const byStatus = this.activeStatus - ? this.allAlerts.filter((a) => a.status === this.activeStatus) - : this.allAlerts; - this.alerts.data = + // 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) => a.status === 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; } @@ -573,15 +812,119 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * 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.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus).length : this.allAlerts.length; + const pool = this.allAlerts.filter((a) => this.inActivePeriod(a)); + return this.activeStatus ? pool.filter((a) => a.status === 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) => a.status === s.key).length; } } } @@ -633,7 +976,26 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * 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 { 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 2d6825d64..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 @@ -196,7 +196,12 @@ export class AmlReviewAlertDialogComponent { // 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.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: () => { diff --git a/src/app/risk-review/risk-change-approvals.component.ts b/src/app/risk-review/risk-change-approvals.component.ts index 51a3e1798..677928e01 100644 --- a/src/app/risk-review/risk-change-approvals.component.ts +++ b/src/app/risk-review/risk-change-approvals.component.ts @@ -92,7 +92,9 @@ const CATEGORY_LABEL: Record = { 1: 'Low', 2: 'Medium', 3: 'High {{ r.reason }} - {{ r.status }} + {{ + r.status + }} {{ r.makerUsername }} {{ r.createdDate }} @@ -250,7 +252,12 @@ export class RiskChangeApprovalsComponent implements OnInit { private snackBar = inject(MatSnackBar); rows: any[] = []; - readonly statuses = ['PENDING', 'APPROVED', 'REJECTED', '']; + readonly statuses = [ + 'PENDING', + 'APPROVED', + 'REJECTED', + '' + ]; selectedStatus = 'PENDING'; ngOnInit(): void { @@ -363,11 +370,15 @@ export class RejectRiskChangeDialogComponent { private snackBar = inject(MatSnackBar); dialogRef = inject(MatDialogRef); + data: { request: any } = inject(MAT_DIALOG_DATA); form: FormGroup; busy = false; - constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) { - this.form = this.fb.group({ rejectionReason: ['', Validators.required] }); + constructor() { + this.form = this.fb.group({ rejectionReason: [ + '', + Validators.required + ] }); } submit(): void { diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts index 3b4fd2fb4..ee9d5f2b9 100644 --- a/src/app/risk-review/risk-review-report.component.ts +++ b/src/app/risk-review/risk-review-report.component.ts @@ -264,7 +264,12 @@ export class RiskReviewReportComponent implements OnInit { ) ) ).subscribe((results) => { - const byClient = new Map(results.map((r) => [r.id, r])); + const byClient = new Map( + results.map((r) => [ + r.id, + r + ]) + ); this.liveAlerts = rows.map((r) => { const info = byClient.get(r.clientId); return { @@ -470,24 +475,24 @@ export class RiskReviewReportComponent implements OnInit { ] }, { - // Its own section, and above "no change needed", 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 rate them, and grouping them under "no change needed" buried the - // one action the report was asking for. + // 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: 'Rating needed', + 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. "No change needed" read as though - // these customers were fine, which is not what it means: a High-rated customer with four open alerts is on - // this list, and the reason is that the conditions for lowering the rating are not met - not that there is - // nothing to look at. The reason on each row says which condition fell short. + // 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: 'Rating holds — no change this period', - hint: 'An alert brought these customers onto the report, but the conditions for moving the rating are not met. The reason on each row says what is missing.', + 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.currentRisk && c.direction === 'same') } diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts index 558b80ab5..215fcac21 100644 --- a/src/app/risk-review/risk-review.compute.ts +++ b/src/app/risk-review/risk-review.compute.ts @@ -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; @@ -157,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) => 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'; @@ -177,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'; @@ -220,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 { @@ -345,7 +392,9 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo // 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; + raw?.riskCategoryAtReview != null && + currentRisk != null && + RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk; const window = staleWindow ? undefined : raw; const recommendation = window?.suggestedDirection ? { @@ -358,7 +407,11 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo largestAmount, totalAmount, escalated, - alerts.map((a) => a.ruleCode) + 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 diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 4509e26ef..6f0ee1ce1 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260916', - 'hash': 'c01615ee' + 'version': '260918', + 'hash': '0cef1b5a' }, 'allow_switching_backend_instance': true }; From c7428d0cb26f9957678736545762decfa4210f28 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 13:06:02 +0530 Subject: [PATCH 08/18] feat(shares): dynamic Share Reports module, voucher attach/GL sync fixes - Add the full Share Reports surface (final balance, account statement, issued/status/nominal/share-wise/guarantor/member-interest reports, enquiry, payment certificate, share detail, dividend statement, folio statement, distinctive numbers, area-wise, mailing labels/letters, share transfer) as standalone routed components, wired into the Share menu - Wire the unattributed Share Collection receipt list into Apply Collection and Apply Additional Shares, with an Attach action independent of account/client - Sync the GL Account and Voucher No fields to whichever receipt is ticked or found, and surface the receipt's real Dr/Cr GL entries in its detail popup - Block Apply Additional Shares until the account's original application is settled Co-Authored-By: Claude Sonnet 5 --- src/app/cashier/cashier.service.ts | 11 + src/app/core/shell/shell.component.html | 42 +- .../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 ++++ .../shares/reports/share-report.shared.scss | 494 ++++++++ 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 +++ .../apply-shares/apply-shares.component.html | 64 +- .../apply-shares/apply-shares.component.scss | 63 + .../apply-shares/apply-shares.component.ts | 125 +- .../apply-collection-dialog.component.html | 748 ++++++++--- .../apply-collection-dialog.component.scss | 1099 ++++++++++++++--- .../apply-collection-dialog.component.ts | 178 ++- .../shares-account-view.component.ts | 8 +- src/app/shares/shares-routing.module.ts | 133 ++ src/app/shares/shares.service.ts | 172 +++ src/environments/.env.ts | 4 +- src/main.scss | 11 + 51 files changed, 10721 insertions(+), 429 deletions(-) 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-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 diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 83d3ba55b..bab2c1ff7 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -390,6 +390,17 @@ export class CashierService { return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } }); } + /** + * Approved share-collection receipts that name no member yet. + * + * A receipt taken straight into a GL account carries no member of its own, so it is never + * attributed automatically. This lists them so the counter can see the voucher they just raised + * and attach it deliberately, instead of being told there is nothing to apply. + */ + getUnattributedShareCollections(): Observable { + return this.http.get('/shareadvancecollection/unattributed'); + } + /** Receipts still sitting in Share Sundry, optionally for a single member. */ getUnappliedShareAdvances(clientId?: number): Observable { const suffix = clientId ? `?clientId=${clientId}` : ''; diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 7d8c63ad2..aa9516dbe 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1300,72 +1300,76 @@

Share Operations
- - - - -
Share Documents
- - - - -
Share Reports
- - - -
Share Admin
- - - - + + -
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-report.shared.scss b/src/app/shares/reports/share-report.shared.scss new file mode 100644 index 000000000..26eabff50 --- /dev/null +++ b/src/app/shares/reports/share-report.shared.scss @@ -0,0 +1,494 @@ +/** + * 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; +} + +/* 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/shares-account-actions/apply-shares/apply-shares.component.html b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html index 4e7601cb0..e4b31cf58 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 @@ -208,20 +208,20 @@

+ + @if (unattributed.length) { +
+
+ person_add + Unattached Share Collection Receipts — Attach to This Tranche +
+
+ + + + + + + + + + + + + @for (u of unattributed; track u.id) { + + + + + + + + + } + +
Voucher NoDateDeposited ToDescriptionAmountAction
+ {{ u.voucherNo }} + @if (isOwnClientVoucher(u)) { + This member's receipt + } + {{ u.txnDate }}{{ u.glAccountNo }} — {{ u.glAccountName }}{{ u.description || '—' }}{{ u.amount | number: '1.2-2' }} + +
+
+
+ } + @if (voucherError) {
error_outline 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 80603efdc..fdb52947f 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 @@ -481,6 +481,69 @@ mat-form-field { overflow-x: auto; } +/* Unattached share-collection receipts. Set apart in amber so it never reads as this member's own + settled list above: these belong to nobody until somebody attaches one. */ +.unattributed-section { + margin: 0 0 0.75rem; + padding: 0.65rem; + border: 1px solid $warning-border; + border-radius: 8px; + background: $warning-bg; + + .section-title { + border-bottom: none; + margin-bottom: 0.5rem; + padding-bottom: 0; + + &.text-amber { + color: $warning; + + .sec-icon { + color: $warning; + } + } + } + + .unattributed-table { + background: $bg-surface; + + .col-attach { + width: 96px; + } + } + + .attach-btn { + height: 28px; + line-height: 28px; + font-size: 0.75rem; + padding: 0 0.65rem; + } + + // A row already linked to the member whose screen this is -- the same voucher is also sitting, + // pre-loaded and tickable, in the Vouchers/Receipts table below. This is not a duplicate to + // clear up; it is the same receipt shown two ways for two different purposes (find it here, + // select it there), so the row is called out instead of removed. + .own-client-row { + background: $success-bg; + + &:hover { + background: $success-bg; + } + + .voucher-badge { + border-color: $success-border; + } + } + + .own-client-badge { + display: block; + margin-top: 2px; + font-size: 0.65rem; + font-weight: 700; + color: $success; + } +} + .receipt-table { width: 100%; border-collapse: separate; 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 8922da149..98e09d124 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 @@ -100,6 +100,24 @@ export class ApplySharesComponent implements OnInit { lookingUpVoucher = false; voucherError = ''; + /** Voucher / Receipt No shown in the "Find Specific Voucher" box. Mirrors whichever receipt is + * ticked below (see {@link toggleVoucher}) so a tick is visibly confirmed up there too, and + * carries whatever the counter has typed in when they search by hand instead. */ + voucherNo = ''; + + /** + * Share-collection receipts that are approved but belong to nobody yet. + * + * A receipt paid straight into a GL account names no member of its own — a GL account is nobody's + * account — so it is never attributed automatically, and never filtered by this member's client id + * (the cashier's typed description is free text and can easily be wrong; nothing here is trusted + * to match a member by parsing it). Listing it is not attribution: it only makes the voucher + * findable, so a counter that tagged a receipt "Share Collection" moments ago is not told there is + * nothing to fund this tranche with. Attaching it below is still a deliberate click against the + * member on screen. + */ + unattributed: any[] = []; + /** 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. */ @@ -242,6 +260,7 @@ export class ApplySharesComponent implements OnInit { this.vouchers = []; this.selectedVoucherIds = []; this.voucherError = ''; + this.voucherNo = ''; this.errorMsg = ''; this.savingsEligibility = null; if (mode === 'SAVINGS') { @@ -266,8 +285,9 @@ export class ApplySharesComponent implements OnInit { /** * 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. + * Deliberately nothing ticked, 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 radio + * they never looked at. */ private loadVouchers(): void { this.loadingVouchers = true; @@ -286,16 +306,49 @@ export class ApplySharesComponent implements OnInit { return; } this.vouchers = res.unappliedCollections || []; - if (this.vouchers.length === 1) { - this.selectedVoucherIds = [this.vouchers[0].id]; - } }); + + this.cashierService + .getUnattributedShareCollections() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => (this.unattributed = res?.rows || [])); + } + + /** + * Attaches one of the unattributed share-collection receipts to this member's tranche. + * + * Goes straight to {@link resolveVoucher} rather than through {@link lookupVoucher}: a row already + * sitting in this list is either a fresh cashier deposit (which does carry its own GL account) or + * an existing, already-adopted {@code m_share_advance_collection} row (the "linked to …" ones), + * whose GL account can genuinely come back blank — its subquery reads the posting the ORIGINAL + * adoption made, which a clerk-disbursement-funded receipt does not always leave behind cleanly. + * Either way the backend needs no GL account to resolve a voucher it can already find by number, so + * a blank one here must never block the click the way it rightly blocks a hand-typed search with + * nothing chosen yet. + */ + attachUnattributedVoucher(row: any): void { + if (this.lookingUpVoucher || this.submitting) { + return; + } + this.resolveVoucher(row.voucherNo, row.glAccountNo || ''); } isVoucherSelected(id: number): boolean { return this.selectedVoucherIds.includes(id); } + /** + * Whether an unattributed-list row is already linked to the member this screen belongs to. + * + * Such a row also appears, pre-loaded and ready to tick, in the Vouchers/Receipts table below — + * that is not a bug to hide, it is the same receipt reachable two ways. This only flags it so the + * counter understands why it shows up here too, rather than reading it as an unrelated stray + * voucher that happens to look identical. + */ + isOwnClientVoucher(u: any): boolean { + return u?.linkedClientId != null && Number(u.linkedClientId) === Number(this.sharesAccountData.clientId); + } + /** * One receipt funds one tranche — no combining instalments here (unlike the first-application * Apply Collection dialog, which still allows several). Picking a different receipt replaces the @@ -305,15 +358,18 @@ export class ApplySharesComponent implements OnInit { const wasSelected = this.isVoucherSelected(id); this.selectedVoucherIds = wasSelected ? [] : [id]; this.voucherError = ''; - // The GL Account picker above follows whichever receipt is ticked — its own actual GL account - // (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt - // that plainly does have one is already chosen. - if (!wasSelected) { - const voucher = this.vouchers.find((v) => v.id === id); - if (voucher?.gl_code) { - this.glAccountNo = voucher.gl_code; - } + if (wasSelected) { + this.voucherNo = ''; + return; } + // The GL Account and Find Specific Voucher fields above follow whichever receipt is ticked -- + // their own actual GL account and voucher number (see gl_code / voucher_no on the row), not left + // showing an unrelated value or blank once a receipt that plainly has them is already chosen. + const voucher = this.vouchers.find((v) => v.id === id); + if (voucher?.gl_code) { + this.glAccountNo = voucher.gl_code; + } + this.voucherNo = voucher?.voucher_no || voucher?.receipt_no || ''; } /** @@ -330,24 +386,40 @@ export class ApplySharesComponent implements OnInit { * 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. + * the slip in their hand, which is faster than finding it among their receipts. The lookup is not + * restricted to this member — any receipt found this way can be attached here, whichever member it + * was originally recorded against. */ - lookupVoucher(voucherNo: string): void { - const trimmed = (voucherNo || '').trim(); + lookupVoucher(): void { + const trimmed = this.voucherNo.trim(); const glAccountNo = this.glAccountNo.trim(); + // A hand-typed search genuinely needs both fields: an empty GL account here means nothing has + // been chosen yet, not that one isn't needed — unlike {@link attachUnattributedVoucher}, which + // already knows exactly which receipt it means and never has this ambiguity. if (!trimmed || !glAccountNo || this.lookingUpVoucher) { return; } + this.resolveVoucher(trimmed, glAccountNo); + } + + /** + * Resolves a voucher number (with whatever GL account, if any, is known for it) and adds the + * receipt it finds to the list, selected. + * + * Shared by {@link lookupVoucher} (a hand-typed search, where both fields are guaranteed non-blank + * by the Find button's own disabled state) and {@link attachUnattributedVoucher} (a click on a row + * already on screen, whose GL account can legitimately be blank) — the actual lookup and its + * bookkeeping afterwards are identical either way. + */ + private resolveVoucher(voucherNo: string, glAccountNo: string): void { this.lookingUpVoucher = true; this.voucherError = ''; this.cashierService - .getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId) + .getShareAdvanceByVoucher(voucherNo, glAccountNo, this.sharesAccountData.clientId) .pipe( catchError((err: any) => { - this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`); + this.voucherError = this.readError(err, `Voucher ${voucherNo} could not be found.`); this.lookingUpVoucher = false; return of(null); }) @@ -357,10 +429,8 @@ export class ApplySharesComponent implements OnInit { 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; - } + // No client-match check here by deliberate choice: any receipt can be attached to any + // share account, regardless of which member it was originally recorded against. // Replaces the selection rather than adding to it — one receipt funds one tranche here, so // finding a voucher by number is a different way to pick THE receipt, not a second one. this.vouchers = [ @@ -368,6 +438,13 @@ export class ApplySharesComponent implements OnInit { ...this.vouchers.filter((v) => v.id !== res.id) ]; this.selectedVoucherIds = [res.id]; + this.voucherNo = res.voucher_no || res.receipt_no || voucherNo; + if (res.gl_code) { + this.glAccountNo = res.gl_code; + } + // Once attached it belongs to this member, so it leaves the unattributed list rather than + // sitting there inviting a second, conflicting attachment. + this.unattributed = this.unattributed.filter((u) => u.voucherNo !== voucherNo); }); } 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 756d2d39e..12a30dadf 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,103 +6,94 @@ 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 Advance Collection

+

+ Settling share account {{ data.accountNo }} +

+
- - @if (requirement && requirement.chargeAmount > 0) { -
- + + +
+ + + Tick the receipts that make up this payment to settle share account {{ data.accountNo }}. Multiple receipts can be selected for instalment payments. Click + visibility to view full receipt popup details. + +
- @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 - } - - + + @if (requirement) { +
+
+ calculate + Tranche Requirement
- } @else { -
+ +
+
+ Shares Value + {{ requirement.shareAmount | number: '1.2-2' }} +
+ + @if (requirement.chargeAmount > 0) { + + +
+ Entry Fee + {{ requirement.chargeAmount | number: '1.2-2' }} +
+ } + + = +
+ Total Required + {{ requirement.required | number: '1.2-2' }} +
+
+ + @if (selectedIds.length > 0) { +
+ @if (shortfall === 0) { + + check_circle Share receipts fully covered + + } @else { + + warning {{ selectedTotal | number: '1.2-2' }} of + {{ shareRequired | number: '1.2-2' }} collected — {{ shortfall | number: '1.2-2' }} short + + } +
+ } +
+ } + + +
+ + +
+
+
-
- - -
- -
- 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 (requirement && requirement.chargeAmount > 0) { +
+ + + @if (chargeReceipt) { +
+ {{ chargeShortfall > 0 ? 'error_outline' : 'check_circle' }} + + {{ 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 { +
+
+ + +
+ +
+ +
+ + +
+
+
+
+ Leave this blank if the entry fee was already paid as part of the main share receipt above. +
} -
- - @if (selectedIds.length > 0) { - - - - - - - + + } + + + @if (unattributed.length) { +
+ +
+
Voucher / ReceiptDateDescriptionCollected ByAmount
- - - {{ r.voucher_no || r.receipt_no }}{{ r.collection_date }}{{ r.note || '—' }}{{ r.collected_by_name || '—' }}{{ r.amount | number: '1.2-2' }}
- {{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected - {{ selectedTotal | number: '1.2-2' }}
+ + + + + + + + + + + + @for (u of unattributed; track u.id) { + + + + + + + + + } + +
Voucher NoDateDeposited ToDescriptionAmountAction
+ {{ u.voucherNo }} + @if (isOwnClientVoucher(u)) { + This member's receipt + } + {{ u.txnDate }}{{ u.glAccountNo }} — {{ u.glAccountName }}{{ u.description || '—' }}{{ u.amount | number: '1.2-2' }} + +
+
+
+ } + + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
+ } + + +
+ @if (loading) { +
+ + Loading member receipts… +
+ } @else if (receipts.length === 0) { +
+ receipt_long +
+ No unapplied receipt found for this member today. +

+ If cash was taken on an earlier day, enter that voucher number above. Otherwise collect cash first at + Cashier › Transaction Entry into Share Sundry GL. +

+
+
+ } @else { +
+ +
+
+ receipt + Member Unapplied Receipts + {{ receipts.length }} +
+ + @if (receipts.length > 1) { +
+ +
+ } +
+ + +
+ + + + + + + + + + + + + + @for (r of receipts; track r.id) { + + + + + + + + + + } + +
+ @if (receipts.length > 1) { + + } @else { + # + } + Voucher / Receipt NoDateDescriptionCollected ByAmountDetail
+ + + {{ r.voucher_no || r.receipt_no }} + {{ r.collection_date }}{{ r.note || 'Share Collection' }} + + person_outline + {{ r.collected_by_name || 'System' }} + + + {{ r.amount | 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 (viewingReceiptDetail) { + +} 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..361dd3acc 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,232 +6,976 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -$primary: #1a3a6b; -$border: #d8e0ec; -$muted: #6b7a90; -$danger: #b3261e; -$success: #1e7d3c; +// Modern Color Tokens +$primary: #1e3a8a; +$primary-light: #eff6ff; +$primary-border: #bfdbfe; +$accent-blue: #2563eb; +$text-main: #0f172a; +$text-muted: #64748b; +$border-color: #e2e8f0; +$border-light: #f1f5f9; +$bg-surface: #fff; +$bg-slate: #f8fafc; -.intro { - font-size: 13px; - color: $muted; - margin: 0 0 12px; +$success: #059669; +$success-bg: #ecfdf5; +$success-border: #a7f3d0; + +$warning: #d97706; +$warning-bg: #fffbeb; +$warning-border: #fde68a; + +$danger: #dc2626; +$danger-bg: #fef2f2; +$danger-border: #fecaca; + +:host { + display: block; + width: 100%; } -.requirement-summary { +.dialog-wrapper { + margin: -24px; 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; + background: #fff; + border-radius: 16px; + overflow: hidden; } -.requirement-progress { - font-size: 12px; - font-weight: 500; - color: $danger; +/* Header Banner - Spacious & Premium */ +.dialog-header-banner { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.1rem 1.5rem; + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + color: #fff; - &.covered { - color: $success; + .header-icon-box { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + background: rgb(255 255 255 / 20%); + border-radius: 10px; + backdrop-filter: blur(4px); + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + color: #fff; + } + } + + .header-text { + flex: 1; + + h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -0.01em; + color: #fff; + } + + p { + margin: 2px 0 0; + font-size: 0.85rem; + color: rgb(255 255 255 / 88%); + } + } + + .close-header-btn { + color: #fff; + width: 36px; + height: 36px; + line-height: 36px; + border-radius: 8px; + + &:hover { + background: rgb(255 255 255 / 15%); + } } } -.voucher-section { - margin: 0 0 14px; +.dialog-body { + padding: 1.25rem 1.5rem !important; + max-height: 76vh; + overflow-y: auto; + box-sizing: border-box; + + /* Custom Scrollbar */ + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 999px; + } } -.voucher-section-label { - font-size: 11px; +/* Intro Callout */ +.dialog-intro-banner { + display: flex; + align-items: flex-start; + gap: 0.65rem; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 10px; + padding: 0.75rem 1rem; + font-size: 0.84rem; + color: #0369a1; + margin-bottom: 1rem; + + .banner-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #0284c7; + margin-top: 1px; + flex-shrink: 0; + } + + .inline-icon { + font-size: 15px; + width: 15px; + height: 15px; + vertical-align: middle; + } +} + +/* Requirement Summary Stat Card */ +.requirement-summary-card { + background: $bg-slate; + border: 1px solid $border-color; + border-radius: 12px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; + + .req-header { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.88rem; + font-weight: 700; + color: $text-main; + margin-bottom: 0.65rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid $border-light; + + .req-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + } + + .req-metrics { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + + .metric-item { + display: flex; + flex-direction: column; + + .m-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + font-weight: 600; + } + + .m-val { + font-size: 0.98rem; + color: $text-main; + } + + &.highlight-total { + background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); + border: 1px solid $primary-border; + border-radius: 8px; + padding: 0.35rem 0.85rem; + + .m-label { + color: #1e40af; + } + + .m-val { + color: #1e3a8a; + font-size: 1.1rem; + } + } + } + + .metric-plus, + .metric-equals { + font-size: 1.1rem; + font-weight: 700; + color: $text-muted; + } + } + + .req-status-bar { + border-top: 1px solid $border-light; + padding-top: 0.5rem; + margin-top: 0.5rem; + } +} + +/* Search Sections */ +.search-section { + margin-bottom: 1rem; + + .section-label { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.88rem; + font-weight: 700; + color: $text-main; + margin-bottom: 0.5rem; + + .sec-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + } +} + +/* Lookup Toolbar - Wide Layout */ +.lookup-toolbar { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 1rem; + background: $bg-slate; + padding: 0.85rem 1rem; + border-radius: 10px; + border: 1px solid $border-color; + + @media (width <= 680px) { + grid-template-columns: 1fr; + } +} + +.toolbar-label { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.4px; - color: $muted; - margin: 0 0 6px; + letter-spacing: 0.04em; + color: $text-muted; + margin-bottom: 5px; + + .mini-icon { + font-size: 15px; + width: 15px; + height: 15px; + } +} + +.custom-select { + width: 100%; + height: 38px; + padding: 0 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 8px; + font-size: 0.88rem; + color: $text-main; + background: #fff; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: $accent-blue; + box-shadow: 0 0 0 3px rgb(37 99 235 / 10%); + } +} + +.lookup-input-group { + display: flex; + gap: 0.5rem; + + .lookup-input { + flex: 1; + height: 38px; + padding: 0 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 8px; + font-size: 0.88rem; + color: $text-main; + background: #fff; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: $accent-blue; + box-shadow: 0 0 0 3px rgb(37 99 235 / 10%); + } + } + + .find-btn { + height: 38px; + border-radius: 8px; + padding: 0 1rem; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.85rem; + font-weight: 600; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } } .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; + gap: 0.75rem; + background: $success-bg; + border: 1px solid $success-border; + border-radius: 10px; + padding: 0.65rem 1rem; + font-size: 0.85rem; color: $success; &.short { - background: #fdecea; - border-color: #f5c6c2; + background: $danger-bg; + border-color: $danger-border; color: $danger; } mat-icon { + font-size: 20px; + width: 20px; + height: 20px; flex-shrink: 0; } -} -.charge-receipt-detail { - flex: 1; - min-width: 0; + .change-btn { + margin-left: auto; + font-size: 0.8rem; + height: 32px; + line-height: 32px; + padding: 0 0.85rem; + } } .charge-receipt-hint { - font-size: 11px; - color: $muted; + font-size: 0.76rem; + color: $text-muted; margin-top: 4px; } -.voucher-lookup { - display: flex; - gap: 8px; - align-items: center; - margin: 0 0 14px; -} +/* Unattributed Section */ +.unattributed-section { + background: #fffdf5; + border: 1px solid $warning-border; + border-radius: 10px; + padding: 0.85rem; + margin-bottom: 1rem; -.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; + .attach-btn { + height: 30px; + line-height: 30px; + font-size: 0.78rem; + padding: 0 0.75rem; } - &:disabled { - background: #f4f7fb; - color: $muted; + // A row already linked to the member this dialog is settling -- the same voucher also sits, + // pre-loaded and tickable, in the Member Unapplied Receipts table below. Called out rather than + // removed, since it is the same receipt reachable two ways for two different purposes. + .own-client-row { + background: $success-bg; + + &:hover { + background: $success-bg; + } + + .voucher-badge { + border-color: $success-border; + } + } + + .own-client-badge { + display: block; + margin-top: 2px; + font-size: 0.65rem; + font-weight: 700; + color: $success; } } -.gl-select-field { - margin: 0 0 10px; -} - -.gl-select { - width: 100%; - padding: 9px 11px; - border: 1px solid $border; - border-radius: 6px; - font-size: 13px; - color: $primary; +/* Vouchers Array Card & Table */ +.vouchers-card { + border: 1px solid $border-color; + border-radius: 10px; background: #fff; - - &:focus { - outline: none; - border-color: $primary; - } - - &:disabled { - background: #f4f7fb; - color: $muted; - } + overflow: hidden; + box-shadow: 0 1px 3px rgb(0 0 0 / 5%); } -.loading { +.vouchers-header { display: flex; + justify-content: space-between; align-items: center; - gap: 10px; - padding: 16px 0; - font-size: 13px; - color: $muted; + padding: 0.65rem 1rem; + background: #f1f5f9; + border-bottom: 1px solid $border-color; + + .vouchers-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + font-weight: 700; + color: $text-main; + + .v-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + + .badge-count { + background: $accent-blue; + color: #fff; + font-size: 0.75rem; + font-weight: 700; + padding: 1px 8px; + border-radius: 999px; + } + } + + .select-all-btn { + font-size: 0.8rem; + color: $accent-blue; + font-weight: 600; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } } -.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; +.table-responsive { + width: 100%; + overflow-x: auto; } .receipt-table { width: 100%; - border-collapse: collapse; - font-size: 13px; - border: 1px solid $border; - border-radius: 6px; - overflow: hidden; + border-collapse: separate; + border-spacing: 0; + font-size: 0.84rem; th { - background: #eef2f8; - color: $primary; - font-size: 11px; - font-weight: 600; + background: $bg-slate; + color: $text-muted; + font-size: 0.72rem; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.4px; - padding: 9px 12px; - white-space: nowrap; + letter-spacing: 0.04em; + padding: 0.65rem 0.85rem; + border-bottom: 1px solid $border-color; } td { - padding: 10px 12px; - border-bottom: 1px solid #eceff4; - color: #1f2937; + padding: 0.6rem 0.85rem; + border-bottom: 1px solid $border-light; + vertical-align: middle; } - 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 { + .receipt-row { cursor: pointer; + transition: background 0.12s ease; &:hover { - background: #f4f7fb; + background: #f8fafc; } &.selected { - background: #e7effb; + background: #eff6ff; + + .voucher-badge { + background: #dbeafe; + color: #1e40af; + border-color: #bfdbfe; + } } } - /* 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; + .col-checkbox { + width: 40px; } - tfoot .total-row { - background: #f4f7fb; - border-top: 2px solid #cbd5e1; + .col-actions { + width: 48px; + } + + .custom-checkbox { + width: 17px; + height: 17px; + accent-color: $accent-blue; + cursor: pointer; + } + + .voucher-badge { + display: inline-block; + background: #f1f5f9; + color: #334155; + padding: 3px 8px; + border-radius: 6px; + border: 1px solid #cbd5e1; + font-weight: 600; + font-size: 0.82rem; + } + + .desc-cell { + color: $text-muted; + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .user-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: $bg-slate; + border: 1px solid $border-color; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.78rem; + color: $text-muted; + + .chip-icon { + font-size: 13px; + width: 13px; + height: 13px; + } + } + + .amount-cell { + color: $text-main; + font-size: 0.9rem; + } + + .view-detail-btn { + width: 32px; + height: 32px; + line-height: 32px; + + .eye-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: $accent-blue; + } + } +} + +/* Footer & Status Badges */ +.vouchers-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.65rem 1rem; + background: $bg-slate; + border-top: 1.5px solid #cbd5e1; + flex-wrap: wrap; + gap: 0.75rem; + + .selected-summary { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: wrap; + + .summary-text { + font-size: 0.82rem; + color: $text-muted; + } + + .summary-amounts { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + + .total-label { + color: $text-muted; + } + + .total-value { + color: $primary; + font-family: 'Courier New', monospace; + } + + .target-divider { + color: $text-muted; + } + + .target-value { + color: $text-muted; + } + } + } +} + +.coverage-status { + .status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &.badge-neutral { + background: #f1f5f9; + color: #64748b; + border: 1px solid #cbd5e1; + } + + &.badge-warning { + background: $warning-bg; + color: $warning; + border: 1px solid $warning-border; + } + + &.badge-success { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } +} + +.funding-loading, +.funding-empty { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + font-size: 0.85rem; + background: $bg-slate; + border: 1px solid $border-color; + border-radius: 10px; + color: $text-muted; +} + +/* Alert Boxes */ +.alert-box { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.65rem 1rem; + border-radius: 10px; + font-size: 0.84rem; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + flex-shrink: 0; + } + + &.alert-error { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } +} + +/* Dialog Actions Bar */ +.dialog-actions-bar { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.75rem; + padding: 0.85rem 1.5rem !important; + background: $bg-slate; + border-top: 1px solid $border-color; + + .action-btn { + height: 40px; + padding: 0 1.5rem; + border-radius: 8px; + font-size: 0.88rem; + font-weight: 600; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } +} + +/* POPUP MODAL DIALOG STYLES */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 55%); + backdrop-filter: blur(4px); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.voucher-modal-dialog { + background: #fff; + border-radius: 14px; + width: 100%; + max-width: 36rem; + box-shadow: 0 25px 50px -12px rgb(15 23 42 / 30%); + border: 1px solid $border-color; + overflow: hidden; + animation: modal-scale-in 0.2s ease-out; + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + color: #fff; + + .header-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 700; + + .m-icon { + font-size: 22px; + width: 22px; + height: 22px; + } + + .modal-badge { + background: rgb(255 255 255 / 20%); + padding: 2px 8px; + border-radius: 6px; + font-size: 0.82rem; + } + } + + .close-btn { + color: #fff; + width: 32px; + height: 32px; + line-height: 32px; + } + } + + .modal-body { + padding: 1.25rem; + max-height: 70vh; + overflow-y: auto; + } + + .detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.85rem; + + @media (width <= 480px) { + grid-template-columns: 1fr; + } + } + + .detail-item { + display: flex; + flex-direction: column; + gap: 2px; + background: $bg-slate; + padding: 0.6rem 0.85rem; + border-radius: 8px; + border: 1px solid $border-light; + + .detail-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + font-weight: 600; + } + + .detail-val { + font-size: 0.88rem; + color: $text-main; + } + + &.full-width { + grid-column: 1 / -1; + } + + &.highlight-amount-box { + background: $primary-light; + border-color: $primary-border; + + .detail-label { + color: #1e40af; + } + + .amount-val { + font-size: 1.35rem; + font-weight: 700; + color: #1e3a8a; + } + } + } + + /* The receipt's own real Dr/Cr GL legs -- what this deposit actually posted, never the GL account + currently picked in the by-voucher search box, which is unrelated screen state. */ + .gl-entries-loading, + .gl-entries-empty { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: $text-muted; + margin-top: 4px; + } + + .gl-entries-table { + width: 100%; + border-collapse: collapse; + margin-top: 6px; + font-size: 0.82rem; + + th { + text-align: left; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + font-weight: 700; + padding: 4px 8px; + border-bottom: 1px solid $border-color; + } td { - padding-top: 10px; - font-size: 13px; - font-weight: 600; - color: #1f3d7a; + padding: 6px 8px; + color: $text-main; + border-bottom: 1px solid $border-light; + } + + tbody tr:last-child td { border-bottom: none; } } + + .drcr-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + + &.dr { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } + + &.cr { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } + + .status-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + width: fit-content; + + &.status-unapplied { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } + + .note-box { + background: #fff; + border: 1px solid $border-color; + border-radius: 8px; + padding: 0.65rem; + font-size: 0.84rem; + color: $text-main; + line-height: 1.45; + margin-top: 4px; + white-space: pre-wrap; + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 0.85rem 1.25rem; + background: $bg-slate; + border-top: 1px solid $border-color; + + .modal-action-btn { + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } + } +} + +@keyframes modal-scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } } .ta-left { @@ -246,25 +990,50 @@ $success: #1e7d3c; text-align: center; } -.mono { +.font-mono { font-family: 'Courier New', monospace; } -.note { - margin-top: 10px; - font-size: 12px; - color: $muted; +.font-semibold { + font-weight: 600; } -.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; +.font-accent { + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; +} + +.font-warning { + color: $warning !important; +} + +.mt-6 { + margin-top: 6px; +} + +.mt-8 { + margin-top: 8px; +} + +.text-amber { + color: #b45309; +} + +.animate-fade-in { + animation: fade-in 0.2s ease-in-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } } 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 b556591c7..370bc5708 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,7 +13,7 @@ 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'; @@ -97,6 +97,18 @@ export class ApplyCollectionDialogComponent implements OnInit { requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null; loadingRequirement = true; + /** + * Share-collection receipts that are approved but belong to nobody yet. + * + *

+ * A receipt paid straight into a GL account names no member — a GL account is nobody's account — + * so it is never attributed on its own. Listing it here is not attribution: it only makes the + * voucher findable, so a counter that tagged a receipt "Share Collection" ten minutes ago is not + * told there is nothing to apply. Attaching it is still a click a person makes against a member + * they can see. + */ + unattributed: any[] = []; + public dialogRef = inject(MatDialogRef); public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA); @@ -124,6 +136,11 @@ export class ApplyCollectionDialogComponent implements OnInit { this.loading = false; }); + this.cashierService + .getUnattributedShareCollections() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => (this.unattributed = res?.rows || [])); + this.cashierService .getShareAdvanceGlAccounts() .pipe( @@ -135,12 +152,17 @@ export class ApplyCollectionDialogComponent implements OnInit { ) .subscribe((res: any) => { this.glAccounts = res?.glAccounts || []; - // Auto-picked only when there is exactly one live candidate -- the backend already narrowed - // this list to accounts that actually hold an eligible receipt today, so a single option is - // unambiguous. Two or more still means the counter chooses by hand. + // Auto-picked only when it is genuinely the only option. The list now carries every + // postable GL account again (see the backend's own comment), so picking index 0 whenever + // one loads would silently select whichever account sorts first by gl_code -- Cash at + // Vault, almost always -- the exact bug this was fixed for once already. Two or more + // candidates always means the counter chooses by hand. if (this.glAccounts.length === 1 && !this.glAccountNo) { this.glAccountNo = this.glAccounts[0].gl_code; } + if (this.glAccounts.length === 1 && !this.chargeGlAccountNo) { + this.chargeGlAccountNo = this.glAccounts[0].gl_code; + } this.loadingGlAccounts = false; }); @@ -159,6 +181,92 @@ export class ApplyCollectionDialogComponent implements OnInit { }); } + /** Receipt object currently being viewed in detail popup. */ + viewingReceiptDetail: any = null; + + /** + * The receipt's own real Dr/Cr GL legs, under its own {@code gl_transaction_id} — never the GL + * account currently picked in the by-voucher search box above, which is unrelated screen state + * that can name a completely different account than the one this receipt actually sits in. + */ + viewingReceiptGlEntries: any[] | null = null; + loadingReceiptGlEntries = false; + + openReceiptDetail(receipt: any, event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.viewingReceiptDetail = receipt; + this.viewingReceiptGlEntries = null; + this.loadingReceiptGlEntries = true; + this.cashierService + .getShareAdvanceGlEntries(receipt.id) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.viewingReceiptGlEntries = res?.entries || []; + this.loadingReceiptGlEntries = false; + }); + } + + closeReceiptDetail(): void { + this.viewingReceiptDetail = null; + this.viewingReceiptGlEntries = null; + } + + toggleAll(): void { + if (this.isAllSelected()) { + this.selectedIds = []; + } else { + this.selectedIds = this.receipts.map((r) => r.id); + } + this.syncLookupFieldsToSelection(); + } + + /** + * Mirrors a ticked receipt's own GL account and voucher number into the Share Receipt Lookup + * fields above, so a tick is visibly confirmed rather than leaving those fields blank while the + * table below shows the real selection. Particularly worth seeing across instalments, where two + * receipts making up one settlement can sit in two different GL accounts, exactly like this + * member's own 200902 and 400105 receipts do. + * + * Shows the most recently ticked receipt when several are selected — there is no single GL + * account to show once more than one is chosen, and the most recent tick is the one the counter + * just made. Clears both fields once nothing is ticked. + */ + private syncLookupFieldsToSelection(): void { + if (!this.selectedIds.length) { + this.glAccountNo = ''; + this.voucherNo = ''; + return; + } + const lastId = this.selectedIds[this.selectedIds.length - 1]; + const receipt = this.receipts.find((r) => r.id === lastId); + if (receipt) { + this.glAccountNo = receipt.gl_code || this.glAccountNo; + this.voucherNo = receipt.voucher_no || receipt.receipt_no || ''; + } + } + + /** + * Whether an unattributed-list row is already linked to the member this dialog is settling. + * + * Such a row also appears, pre-loaded, in the Member Unapplied Receipts table below — that is not + * a duplicate to clear up, it is the same receipt reachable two ways. This only flags it so the + * counter understands why it shows up here too, rather than reading it as an unrelated stray + * voucher that happens to look identical. + */ + isOwnClientVoucher(u: any): boolean { + return u?.linkedClientId != null && Number(u.linkedClientId) === Number(this.data.clientId); + } + + isAllSelected(): boolean { + return this.receipts.length > 0 && this.selectedIds.length === this.receipts.length; + } + + isSomeSelected(): boolean { + return this.selectedIds.length > 0 && this.selectedIds.length < this.receipts.length; + } + /** * 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 @@ -221,6 +329,7 @@ export class ApplyCollectionDialogComponent implements OnInit { ...this.selectedIds, id ]; + this.syncLookupFieldsToSelection(); } /** @@ -243,12 +352,48 @@ export class ApplyCollectionDialogComponent implements OnInit { * voucher belonging to somebody else is surfaced here as a clear mismatch rather than failing * later inside apply(). */ + /** + * Attaches one of the unattributed share-collection receipts to this member. + * + *

+ * Goes straight to {@link resolveVoucher} rather than through {@link lookupVoucher}: a row already + * on this list is either a fresh cashier deposit (which does carry its own GL account) or an + * already-adopted {@code m_share_advance_collection} row (the "linked to …" ones), whose GL account + * can legitimately come back blank — its subquery reads the posting the original adoption made, + * which a clerk-disbursement-funded receipt does not always leave behind cleanly. Either way the + * backend needs no GL account to resolve a voucher it can already find by number, so a blank one + * here must never block the click the way it rightly blocks a hand-typed search with nothing chosen + * yet. The list never decides whose money it is; the click does. + */ + attachUnattributed(row: any): void { + if (this.lookingUp || this.submitting) { + return; + } + this.resolveVoucher(row.voucherNo, row.glAccountNo || ''); + } + lookupVoucher(): void { const voucher = this.voucherNo.trim(); const glAccountNo = this.glAccountNo.trim(); + // A hand-typed search genuinely needs both fields: an empty GL account here means nothing has + // been chosen yet, not that one isn't needed — unlike {@link attachUnattributed}, which already + // knows exactly which receipt it means and never has this ambiguity. if (!voucher || !glAccountNo || this.lookingUp) { return; } + this.resolveVoucher(voucher, glAccountNo); + } + + /** + * Resolves a voucher number (with whatever GL account, if any, is known for it) and adds the + * receipt it finds to the table, ticked. + * + * Shared by {@link lookupVoucher} (a hand-typed search, where both fields are guaranteed non-blank + * by the Find button's own disabled state) and {@link attachUnattributed} (a click on a row already + * on screen, whose GL account can legitimately be blank) — the actual lookup and its bookkeeping + * afterwards are identical either way. + */ + private resolveVoucher(voucher: string, glAccountNo: string): void { this.lookingUp = true; this.errorMsg = ''; this.findVoucher(voucher, glAccountNo).subscribe((res) => { @@ -257,8 +402,8 @@ export class ApplyCollectionDialogComponent implements OnInit { 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. + // are left for the clerk: attaching a specific voucher 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) @@ -269,7 +414,19 @@ export class ApplyCollectionDialogComponent implements OnInit { res.id ]; } + // Once attached it belongs to this member, so it leaves the unattributed list rather than + // sitting there inviting a second, conflicting attachment. + this.unattributed = this.unattributed.filter((u) => u.voucherNo !== voucher); + // Cleared, not left showing this voucher: an instalment payment often means searching for a + // second receipt right after this one, and the box should be ready for that number, not still + // holding the one just added. this.voucherNo = ''; + // The GL Account picker still follows this receipt, exactly as it does when an already-listed + // receipt is ticked — res.gl_code is this receipt's own real GL account (see findByVoucher()'s + // subquery on the server), not whatever the search box happened to hold before this search. + if (res.gl_code) { + this.glAccountNo = res.gl_code; + } }); } @@ -311,14 +468,9 @@ export class ApplyCollectionDialogComponent implements OnInit { * 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 here by deliberate choice: any receipt can be attached to any share + // account, regardless of which member it was originally 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.`); return of(null); 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..d97a3125c 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 @@ -149,6 +149,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 @@ -202,7 +206,9 @@ export class SharesAccountViewComponent implements OnInit { */ private applyCollection() { const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, { - width: '560px', + width: '820px', + maxWidth: '92vw', + panelClass: 'apply-collection-dialog-panel', data: { shareAccountId: this.sharesAccountData.id, clientId: this.sharesAccountData.clientId, diff --git a/src/app/shares/shares-routing.module.ts b/src/app/shares/shares-routing.module.ts index a2f7f614f..d2c17ecd1 100644 --- a/src/app/shares/shares-routing.module.ts +++ b/src/app/shares/shares-routing.module.ts @@ -41,6 +41,139 @@ 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/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.service.ts b/src/app/shares/shares.service.ts index 0bf0f7d71..a7d277584 100644 --- a/src/app/shares/shares.service.ts +++ b/src/app/shares/shares.service.ts @@ -190,4 +190,176 @@ 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) }); + } + + /** 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/environments/.env.ts b/src/environments/.env.ts index 4509e26ef..858410ec4 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260916', - 'hash': 'c01615ee' + 'version': '260918', + 'hash': 'e53dfb5a' }, 'allow_switching_backend_instance': true }; diff --git a/src/main.scss b/src/main.scss index c6f7900ea..7b96bfb34 100644 --- a/src/main.scss +++ b/src/main.scss @@ -1960,3 +1960,14 @@ mifosx-client-datatable-step * { display: none !important; } } + +/* Apply Collection Dialog Panel Styling */ +.apply-collection-dialog-panel { + .mat-mdc-dialog-container, + .mdc-dialog__surface { + border-radius: 16px !important; + padding: 0 !important; + overflow: hidden !important; + box-shadow: 0 25px 60px -15px rgb(15 23 42 / 30%) !important; + } +} From dc7f66805683c60161164b363a491839b0983207 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 13:57:14 +0530 Subject: [PATCH 09/18] feat(shares): move voucher search behind a popup, not an always-visible toolbar The GL Account / Voucher No lookup is for the exception -- a receipt the unattributed list and the member's own list do not already carry -- so it now sits behind a "Search a Voucher Not Listed Below" button in both Apply Collection and Apply Additional Shares, opening the same lookup in a popup (matching the existing receipt-detail popup) instead of occupying screen space whether or not anyone needs it. A successful find closes the popup and the result appears ticked in the list behind it, same as before. --- .../apply-shares/apply-shares.component.html | 148 ++++++++++++------ .../apply-shares/apply-shares.component.scss | 30 ++++ .../apply-shares/apply-shares.component.ts | 22 +++ .../apply-collection-dialog.component.html | 143 +++++++++++------ .../apply-collection-dialog.component.scss | 30 ++++ .../apply-collection-dialog.component.ts | 22 +++ 6 files changed, 294 insertions(+), 101 deletions(-) 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 e4b31cf58..45cc66466 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 @@ -178,61 +178,25 @@

- Select the one receipt that covers this payment, or enter a voucher number to search. Click + Select the one receipt that covers this payment. Click visibility on any row to view full details popup.
- -
-
- - -
- -
- -
- - -
-
-
+ + +@if (voucherSearchOpen) { + +} 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 fdb52947f..7e7360995 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 @@ -335,6 +335,36 @@ mat-form-field { } } +.voucher-search-toggle-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + font-weight: 600; + color: $accent-blue; + border-color: $primary-border; + margin-bottom: 1rem; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + } +} + +.voucher-search-hint { + font-size: 0.8rem; + color: $text-muted; + margin: 0 0 0.85rem; +} + +// The shared .lookup-toolbar normally goes two-column above its own breakpoint of BROWSER width, +// which says nothing about how wide this popup itself is -- at the popup's width, two columns crowd +// the GL Account select against the voucher input. Single column reads better at this popup's width. +.voucher-search-dialog .lookup-toolbar { + grid-template-columns: 1fr; +} + /* Lookup Toolbar */ .lookup-toolbar { display: grid; 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 98e09d124..099c1031f 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 @@ -100,6 +100,25 @@ export class ApplySharesComponent implements OnInit { lookingUpVoucher = false; voucherError = ''; + /** + * Whether the voucher-search popup is open. + * + * The GL Account / Voucher No lookup is for the exception — a receipt the unattributed list and + * this member's own list do not carry — so it sits behind a button rather than on screen whether + * or not anyone needs it, exactly like the receipt-detail popup already does for the same reason. + */ + voucherSearchOpen = false; + + openVoucherSearch(): void { + this.voucherError = ''; + this.voucherSearchOpen = true; + } + + closeVoucherSearch(): void { + this.voucherSearchOpen = false; + this.voucherError = ''; + } + /** Voucher / Receipt No shown in the "Find Specific Voucher" box. Mirrors whichever receipt is * ticked below (see {@link toggleVoucher}) so a tick is visibly confirmed up there too, and * carries whatever the counter has typed in when they search by hand instead. */ @@ -445,6 +464,9 @@ export class ApplySharesComponent implements OnInit { // Once attached it belongs to this member, so it leaves the unattributed list rather than // sitting there inviting a second, conflicting attachment. this.unattributed = this.unattributed.filter((u) => u.voucherNo !== voucherNo); + // Found it -- the popup has done its job, so it closes rather than sitting open over a + // receipt that now already shows, ticked, in the table behind it. + this.voucherSearchOpen = 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 12a30dadf..9e35e3841 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 @@ -80,57 +80,21 @@
} - +
- - -
-
- - -
- -
- -
- - -
-
-
+
@@ -575,3 +539,82 @@
} + + +@if (voucherSearchOpen) { + +} 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 361dd3acc..e0ff13114 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 @@ -253,6 +253,36 @@ $danger-border: #fecaca; } } +.voucher-search-toggle-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + font-weight: 600; + color: $accent-blue; + border-color: $primary-border; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + } +} + +.voucher-search-hint { + font-size: 0.8rem; + color: $text-muted; + margin: 0 0 0.85rem; +} + +// The shared .lookup-toolbar normally goes two-column above 680px of BROWSER width, which says +// nothing about how wide this popup itself is -- at the popup's own ~36rem, two columns crowd the +// GL Account select against the voucher input. Single column reads better at this popup's width, +// full stop, rather than reintroducing a container query for one dialog. +.voucher-search-dialog .lookup-toolbar { + grid-template-columns: 1fr; +} + /* Lookup Toolbar - Wide Layout */ .lookup-toolbar { display: grid; 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 370bc5708..51298aee5 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 @@ -72,6 +72,25 @@ export class ApplyCollectionDialogComponent implements OnInit { voucherNo = ''; lookingUp = false; + /** + * Whether the voucher-search popup is open. + * + * The GL Account / Voucher No lookup is for the exception — a receipt the unattributed list and + * the member's own list do not carry — so it sits behind a button rather than on screen whether or + * not anyone needs it, exactly like the receipt-detail popup already does for the same reason. + */ + voucherSearchOpen = false; + + openVoucherSearch(): void { + this.errorMsg = ''; + this.voucherSearchOpen = true; + } + + closeVoucherSearch(): void { + this.voucherSearchOpen = false; + this.errorMsg = ''; + } + /** 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. */ @@ -427,6 +446,9 @@ export class ApplyCollectionDialogComponent implements OnInit { if (res.gl_code) { this.glAccountNo = res.gl_code; } + // Found it -- the popup has done its job, so it closes rather than sitting open over a + // receipt that now already shows, ticked, in the table behind it. + this.voucherSearchOpen = false; }); } From 3c363d037226424f8b0b02c985048841c093e7c0 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 18:56:34 +0530 Subject: [PATCH 10/18] feat(shares): one-voucher-per-slot receipt dialogs, Apply Additional Shares as a popup Apply Collection and Apply Additional Shares settle the same Share Collection receipts for the same member, so they are now deliberately the same screen. Apply Collection takes exactly two receipts and no more: one for the share value, one for the entry fee, in two separate slots. They are different things -- the share receipt is money moving into share capital, while the fee receipt only proves the fee was already collected and is never posted a second time -- and picking both out of one ticked list made that distinction invisible. Apply Additional Shares moves from its own route to a dialog. It was a full page a clerk had to leave the account for and come back from, to fill in a short form with the member standing at the counter. Its old route, resolver case and action- host branch are removed rather than left behind as dead paths. Both screens now draw their receipts from a single pool, whether a receipt already names the member or is a voucher paid straight into a GL account; an unattributed one is attached at the moment it is picked, so what used to be an Attach in one table and a tick in another is one deliberate click. The by-voucher search and the GL-entry detail both sit behind buttons, as exceptions rather than permanent furniture. Dialog chrome and the receipt shape are shared (share-dialog.shared.scss, share-receipt-option.ts) so the two cannot drift apart again. Also fixes content being clipped at the dialog edges: the wrapper cancelled a 24px surface padding that the panel class had already zeroed, pushing every edge outside the surface where overflow:hidden cut it off, and the height cap sat on the scrolling body rather than the wrapper, letting the footer stack past the bottom of the viewport. Adds the product-wise share summary report (authorised, issued, available and unpaid shares, distinctive range and account count per product). Co-Authored-By: Claude Opus 5 (1M context) --- src/app/cashier/cashier.service.ts | 21 +- src/app/core/shell/shell.component.html | 3 + .../share-account-actions.resolver.ts | 1 - .../share-product-summary.component.html | 168 +++ .../share-product-summary.component.ts | 168 +++ .../shares/reports/share-report.shared.scss | 17 + src/app/shares/share-dialog.shared.scss | 996 +++++++++++++ src/app/shares/share-receipt-option.ts | 85 ++ .../apply-shares/apply-shares.component.html | 953 ++++-------- .../apply-shares/apply-shares.component.scss | 1274 ++--------------- .../apply-shares/apply-shares.component.ts | 768 +++++----- .../shares-account-actions.component.html | 3 - .../shares-account-actions.component.ts | 4 - .../apply-collection-dialog.component.html | 921 +++++------- .../apply-collection-dialog.component.scss | 1060 +------------- .../apply-collection-dialog.component.ts | 578 +++----- .../shares-account-view.component.ts | 63 +- src/app/shares/shares-routing.module.ts | 8 + src/app/shares/shares.module.ts | 2 - src/app/shares/shares.service.ts | 8 + src/environments/.env.ts | 2 +- src/main.scss | 32 +- 22 files changed, 2906 insertions(+), 4229 deletions(-) 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/share-dialog.shared.scss create mode 100644 src/app/shares/share-receipt-option.ts diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index bab2c1ff7..3078701f8 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -391,20 +391,17 @@ export class CashierService { } /** - * Approved share-collection receipts that name no member yet. + * 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. * - * A receipt taken straight into a GL account carries no member of its own, so it is never - * attributed automatically. This lists them so the counter can see the voucher they just raised - * and attach it deliberately, instead of being told there is nothing to apply. + * 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. */ - getUnattributedShareCollections(): Observable { - return this.http.get('/shareadvancecollection/unattributed'); - } - - /** 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}`); + 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. */ diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index aa9516dbe..3351e3022 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1353,6 +1353,9 @@ +
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-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 index 26eabff50..b837ce60f 100644 --- a/src/app/shares/reports/share-report.shared.scss +++ b/src/app/shares/reports/share-report.shared.scss @@ -278,6 +278,23 @@ $srep-muted: #64748b; 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 { 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 45cc66466..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,692 +6,367 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -

- - -
-
- add_card +
+
+
+ trending_up +
+
+

Apply Additional Shares

+

+ Share account {{ data.accountNo }} +

+
+ +
+ + +
+
+
+ Shares +
-
-

{{ 'Apply Additional Shares' | translate }}

-

Purchase additional share tranches via cash receipts or savings debit

+
+ Unit Price + {{ unitPrice | number: '1.2-2' }} +
+
+ Total Amount + {{ amount | number: '1.2-2' }}
+
-
- - -
-
- assignment - Application & Resolution Details -
- -
- - - {{ 'labels.inputs.Request Date' | translate }} - - - - @if (applySharesForm.controls.requestedDate.hasError('required')) { - - {{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - - {{ 'labels.inputs.Resolution Number' | translate }} - - @if (applySharesForm.controls.resolutionNumber.hasError('required')) { - - {{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - - {{ 'labels.inputs.Resolution Date' | translate }} - - - - @if (applySharesForm.controls.resolutionDate.hasError('required')) { - - {{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - -
+ + +
+
+ 1 +
+

Board Resolution

+

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

+
- -
-
- calculate - Share Purchase Calculation -
+
+ + + +
+
-
- - - {{ '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 }} - - per share - - - -
- {{ 'labels.inputs.Amount' | translate }} - {{ amount | number: '1.2-2' }} - {{ applySharesForm.get('requestedShares').value || 0 }} shares × - {{ sharesAccountData?.currentMarketPrice || 0 }} -
-
+ +
+
+ 2 +
+

Funding

+

Approval posts Dr Sundry / Cr Share Capital either way.

+ @if (funding === 'CASH') { + + } +
- -
-
- account_balance_wallet - {{ 'labels.inputs.Payment Option' | translate }} +
+ + +
+ + @if (funding === 'CASH') { + @if (loadingReceipts) { +
+ + Loading receipts…
- -
-
-
- payments -
-
- Apply Collection (Cash) - Link receipts paid at counter sitting in Share Sundry -
- @if (paymentMode === 'CASH') { - check_circle - } + } @else if (receipt) { +
+
+ {{ shortfall > 0 ? 'warning' : 'check_circle' }}
- -
-
- account_balance -
-
- Debit from Savings - Direct debit from member's linked savings account -
- @if (paymentMode === 'SAVINGS') { - check_circle - } +
+ {{ receipt.voucherNo }} + {{ glLabel(receipt) }}
-
-
- - - @if (paymentMode === 'CASH') { -
-
- - - Select the one receipt that covers this payment. Click - visibility on any row to view full details popup. - -
- - - - - - @if (unattributed.length) { -
-
- person_add - Unattached Share Collection Receipts — Attach to This Tranche -
-
- - - - - - - - - - - - - @for (u of unattributed; track u.id) { - - - - - - - - - } - -
Voucher NoDateDeposited ToDescriptionAmountAction
- {{ u.voucherNo }} - @if (isOwnClientVoucher(u)) { - This member's receipt - } - {{ u.txnDate }}{{ u.glAccountNo }} — {{ u.glAccountName }}{{ u.description || '—' }}{{ u.amount | number: '1.2-2' }} - -
-
-
- } - - @if (voucherError) { -
- error_outline - {{ voucherError }} -
- } - - -
- @if (loadingVouchers) { -
- - Loading member receipts… -
- } @else if (vouchers.length === 0) { -
- receipt_long -
- No unapplied receipts found for this member. -

- Collect cash at Cashier › Transaction Entry as a deposit to Share Sundry GL. Once - posted, it will appear here. -

-
-
- } @else { -
- -
-
- receipt - Vouchers / Receipts - {{ vouchers.length }} -
-
- - -
- - - - - - - - - - - - - - - @for (v of vouchers; track v.id) { - - - - - - - - - - } - -
#Voucher / Receipt NoDateDescriptionCollected ByAmountDetail
- - - {{ v.voucher_no || v.receipt_no }} - {{ v.collection_date }}{{ v.note || 'Share Collection' }} - - person_outline - {{ v.collected_by_name || 'System' }} - - - {{ v.amount | number: '1.2-2' }} - - -
-
- - - -
- - @if (selectedVoucherIds.length > 0 && voucherShortfall > 0) { -
- error_outline - - Selected total {{ selectedVoucherTotal | number: '1.2-2' }} is - {{ voucherShortfall | number: '1.2-2' }} short of tranche target - {{ amount | number: '1.2-2' }}. Tick another receipt or collect balance. - -
- } - } -
+ @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 (paymentMode === 'SAVINGS') { -
-
- - - The tranche amount will be automatically debited from the member's linked savings account into Share - Sundry upon submission. + @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 (checkingSavings) { -
- - Checking linked savings account eligibility… -
- } @else if (savingsEligibility) { -
- @if (savingsEligibility.savingsAccountNo) { -
- Savings Account No. - {{ savingsEligibility.savingsAccountNo }} -
-
- Available Balance - {{ savingsEligibility.savingsBalance | number: '1.2-2' }} -
- } -
- Amount to Debit - {{ amount | number: '1.2-2' }} -
-
- - @if (!savingsEligibility.eligible) { -
- error_outline - {{ savingsEligibility.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) { +
+