Compare commits

...
Author SHA1 Message Date
tarunmalviya 57a61b3057 DDS: Agent settlement (Agent Tran), Close Party account UI, reports UI, Party-Savings link field 2026-08-25 10:56:03 +05:30
keshavandClaude Opus 5 7ef75c902d feat: add database export, welcome kit dialog, and DIP/sanctions updates
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:52:28 +05:30
niteshandClaude Sonnet 5 c18219fce5 feat(loan-origination): persist the sanctioning pipeline to the backend
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
Replaces the in-memory mock (LOAN_APPLICATIONS array + seeded RNG, reset on
every refresh) with real Fineract-backed persistence: a new
LoanOriginationService for CRUD plus one API call per stage transition, a
loan-fineract-bridge that creates a real loan account (create + approve +
disburse) at disbursement for Existing Customers, and file uploads through
Fineract's generic document store keyed by a stable checklist slot so
"On file"/View controls survive a reload.

Also fixes several gaps found while verifying full-cycle persistence:
- CIC charge-account selection never saved (missing persist() call)
- Co-borrower relationship/monthly income lived in local-only component state
  instead of on the application, so they reset on refresh
- Field Investigation "View Full Details" showed local-session photos only;
  stored photos are now downloaded and shown as real thumbnails
- "Assign Field Officer" was free text; now a dropdown of real CBS loan
  officers pulled from /staff
- site photos and cheque/signature images now open in the same zoomable,
  pannable lightgallery viewer used elsewhere in the app

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 17:05:05 +05:30
nitesh 2d93f80d50 feat(loan-origination): guarantor share-account verification, committee resolution gating, legal doc uploads, CIC score-detection fixes, and interest/share rate chart
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
- Guarantor must be a real existing client with a verified active share account, with clickable share-account chips
- Loan Committee Approval now requires Resolution No./Date plus a full application report before approving
- Loan Committee gets its own pre-filtered pipeline view via a menu link and stage query param
- Legal Approval stage gets a multi-file document upload checklist with add-more support
- CIC PDF parser: item-level score detection before line-based fallback, fixing font-size-driven line-merging misdetection
- New Interest & Share Rate Chart tab: per-product interest/tenure bands and per-amount-slab Share Capital linkage requirements
2026-08-14 14:49:22 +05:30
Anshul Rathore 1d1e76497c Update client KYC and address handling
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-08-14 12:42:29 +05:30
keshav 352a65cb5d feat: implement DIP/MNRL lookups and enhance Sanctions screening workflows
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
- Implement DIP/MNRL lookup logs table, filtering, and detail review under reports.
- Integrate DIP verification gating on client creation, allowing officers to record PROCEEDED/REJECTED decisions with reasons.
- Upgrade Sanctions verification dialog to support comparing Name, DOB, and Nationality particulars.
- Add Sanctions disposition recording (FALSE_POSITIVE / CONFIRMED_MATCH) awaiting Principal Officer approval.
- Add sanctions-alert-review-dialog for reviewers to assess and escalate/clear flagged names.
- Enforce strict screening reset logic on client name changes, preventing bypass of existing blocks.
- Update routes and shell menu navigation to include DIP/MNRL Lookups and Sanctions Logs.
2026-08-14 10:44:00 +05:30
nitesh 8d67a22f18 feat(loan-origination): tabbed CIC report dialog with real PDF parsing via pdfjs-dist
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Extracts score, accounts, DPD payment history and enquiries directly from an
uploaded bureau PDF (position-aware parsing, so blank DPD months are handled
correctly) instead of always showing a simulated report, falling back to the
simulated analysis when a file can't be read.
2026-08-13 18:05:22 +05:30
niteshandClaude Sonnet 5 997e8920e4 feat(loan-origination): product type, income/co-borrower affordability, bank statement & CIC analysis, richer Field Investigation, and meeting-scheduled Personal Discussion
Publish Image in Docker Hub / build (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
- New Application: add Product Type (Term Loan/Overdraft) driven by product eligibility.
- KYC: 3-year ITR capture with exceptions, product-specific document checklists with officer-added
  custom docs, Housing Loan co-owner Aadhaar/PAN capture+verify in a popup dialog.
- Income & Affordability: editable income/amount/tenure with product bounds validation, 40% EMI
  rule, and a full co-borrower flow (relationship, salary slips, combined-income eligibility).
- Bank Statement upload auto-opens a full CSV-driven cash-flow/FOIR/risk analysis dialog.
- CIC report upload auto-opens a bureau-style credit report analysis (score gauge, accounts,
  DPD history, enquiries) generated from the application's own credit score.
- Field Investigation reworked into per-address cards (Home/Office/Property/Shop) with officer
  assignment, 48-hour SLA tracking, GPS + multi-photo capture, verify/reject with remarks, a
  full-detail popup, real Fineract addresses for existing clients, and a calculated property
  valuation (market/guideline/realizable value, LTV).
- Personal Discussion: meeting scheduler (date/time/mode/officer) gating the discussion report,
  plus a full Application Summary Report PDF (KYC, CIC, FI, valuation, stage history) for the
  meeting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 15:14:39 +05:30
keshav 5581d4180d feat(clients, alm): add CIN/MCA verification & authorized signatories tab; refactor ALM report print and export
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
2026-08-13 15:05:16 +05:30
nitesh 75df8865dd feat(loan-origination): RBI compliance checklist, real client integration, and CIC consent/charge flows
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
- Add standalone RBI Eligibility Checklist and RBI Rules Reference pages (11 real RBI-circular
  based rules: age-at-maturity, area of operation, PSL, director/related-party, wilful defaulter,
  exposure norms, LTV/margin, fair practices code, prepayment charges, own-shares security,
  insurance bundling), wired into the Loan Origination menu.
- Bridge the mock Loan Origination Pipeline to real Fineract data wherever an application is
  raised against a real Existing Customer: client search in the pipeline intake form, real Share
  Member accounts (with a details popup), and real KYC (Aadhaar/PAN verified status + masked
  numbers + document image view, generic document lookup for Income Proof/Bank Statement/
  Electricity Bill) on the Application Detail page.
- Add local-only capture UI for New Customer / no-account applications: front/back document
  images with crop + simulated Aadhaar OTP / PAN verification, employment-type-aware income proof,
  and an expiry-validated electricity bill upload.
- Add an EMI Calculation Basis breakdown (principal, rate, tenure, total interest/payable, and the
  substituted reducing-balance formula) to the KYC stage's income assessment.
- Add a CIC/Credit Bureau Check consent flow: a generated declaration letter (print for Branch,
  share for BC/Self-Apply), gated on the signed copy being uploaded before the credit score can be
  checked, plus a CIC check charge collected either by debiting a real linked account or via a
  mock payment-link flow for customers with none.
- Let the stage stepper jump directly to any already-reached stage (forward or backward) via a new
  furthestStageIndex, without losing any data already recorded on the application.
2026-08-13 10:15:28 +05:30
niteshandClaude Sonnet 5 5a6fb1f9b5 feat(shares): route dividend cash claims through cashier token disbursement
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Cash claims now require a token at claim submission; a cashier redeems
it at Transaction Entry to record physical hand-over, mirroring the
existing clerk-withdrawal-request flow -- no double-disbursement, and
the manager never hands over cash directly at approval time.

Also: dividend-claim tokens fall back through the same TOK. NO. lookup
used for withdrawal requests, silenced against the global error
interceptor since the caller already handles every outcome; the account
summary card now shows "Dividend Claim" instead of a misleading "A/c
Savings" label when resolving one; and cash amounts are floored to
whole rupees client-side to match cash's whole-rupee denominations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 10:19:51 +05:30
nitesh f379b2415c Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
# Conflicts:
#	src/environments/.env.ts
2026-08-11 12:45:52 +05:30
niteshandClaude Sonnet 5 c34f6a0131 feat(shares): real-profit driven dividend UI, Dividend Dashboard, and member claim workflow
Frontend counterpart to the backend dividend rework:
- Create Dividend screen auto-computes the amount from real bank profit x officer
  percentage, blocks overlapping declaration periods, and adds a Total Dividend Paid
  summary card.
- View Dividend page modernized and reusable as an embedded popup from the new
  cross-product "Dividend Requests" approval-center tab.
- New consolidated Dividend Dashboard: summary stats, every declaration and its
  status, locked-dividend-vs-overdue-EMI matching with a proactive apply-to-loan
  action, full 3-year lock history, and a maker-checker claim panel letting a clerk
  split a member's unclaimed dividend across an overdue loan, savings credit, or
  cash for a manager to approve.
- Menu/routing consolidated under Share > Dividend Management; removed the
  now-unreachable Failed Dividend Payouts page and its home-dashboard alert once the
  backend stopped needing a retry path for stuck payouts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 12:43:18 +05:30
208 changed files with 35722 additions and 3118 deletions
+289
View File
@@ -44,6 +44,7 @@
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
"pdfjs-dist": "^5.7.284",
"rxjs": "^7.8.2",
"signature_pad": "^5.1.3",
"tinymce": "^8.2.2",
@@ -77,6 +78,7 @@
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"@vendure/ngx-translate-extract": "^9.4.0",
"cross-env": "^10.1.0",
"cypress": "^13.17.0",
"eslint": "^9.39.1",
"git-describe": "^4.1.1",
@@ -3559,6 +3561,13 @@
"url": "https://github.com/sponsors/JounQin"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
@@ -6126,6 +6135,256 @@
"linux"
]
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/nice": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz",
@@ -11451,6 +11710,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -19922,6 +20199,18 @@
"node": ">=8"
}
},
"node_modules/pdfjs-dist": {
"version": "5.7.284",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz",
"integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.100"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+6 -4
View File
@@ -15,10 +15,10 @@
"ng": "ng",
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
"start": "npm run env -s && ng serve --proxy-config proxy.localhost.conf.js",
"start:share": "npm run env -s && ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
"start:demo": "npm run env -s && ng serve --proxy-config proxy.conf.js",
"serve:dev": "npm run env -s && ng serve --source-map --proxy-config proxy.localhost.conf.js",
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.localhost.conf.js",
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
"serve:sw": "npm run build -s && npx http-server ./dist -p 4200",
"lint": "eslint . && stylelint \"src/**/*.scss\" && prettier . --check && htmlhint \"src\" --config .htmlhintrc",
"test": "jest --config jest.config.ts",
@@ -73,6 +73,7 @@
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
"pdfjs-dist": "^5.7.284",
"rxjs": "^7.8.2",
"signature_pad": "^5.1.3",
"tinymce": "^8.2.2",
@@ -106,6 +107,7 @@
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"@vendure/ngx-translate-extract": "^9.4.0",
"cross-env": "^10.1.0",
"cypress": "^13.17.0",
"eslint": "^9.39.1",
"git-describe": "^4.1.1",
+22
View File
@@ -32,6 +32,28 @@ export class AccountingService {
return this.http.get('/offices', { params: httpParams });
}
/**
* Real net profit for a date range, computed live from posted GL journal entries (not the
* demo data shown by the GL Reports module).
* @param {string} fromDate Period start, yyyy-MM-dd.
* @param {string} toDate Period end, yyyy-MM-dd.
* @returns {Observable<any>} `{ totalIncome, totalExpense, netProfit }`.
*/
getProfitAndLoss(fromDate: string, toDate: string): Observable<any> {
const httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
return this.http.get('/accounting/profitandloss', { params: httpParams });
}
/**
* Total share dividend actually paid to members in a date range — derived from the Dividend
* Payable GL account's own ledger (no separate "Dividend Paid" account needed).
* @returns {Observable<any>} `{ totalPaid, payoutCount, entries[], glAccountConfigured }`.
*/
getDividendPaidReport(fromDate: string, toDate: string): Observable<any> {
const httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
return this.http.get('/accounting/dividend-paid-report', { params: httpParams });
}
/**
* @param {boolean} useHttpParams True if http params should be used.
* @returns {Observable<any>} Accounting rules data.
@@ -11,6 +11,10 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { AccountsListComponent } from './accounts-list/accounts-list.component';
import { PartyMasterComponent } from '../deposits/party-master/party-master.component';
import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component';
import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component';
import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component';
const routes: Routes = [
Route.withShell([
@@ -41,6 +45,28 @@ const routes: Routes = [
title: 'Recurring Deposit Accounts',
breadcrumb: 'Recurring Deposit Accounts'
}
},
{
path: 'dds',
component: PartyMasterComponent,
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' }
},
{
path: 'dds-collection',
component: DailyTransactionEntryComponent,
data: { title: 'Daily Transaction Entry', breadcrumb: 'Daily Transaction Entry' }
},
{
path: 'dds-reports',
data: { title: 'DDS Reports', breadcrumb: 'DDS Reports' },
children: [
{ path: '', component: DdsReportsComponent },
{
path: 'view/:reportName',
component: ReportViewerComponent,
data: { title: 'DDS Report', breadcrumb: 'reportName', routeParamBreadcrumb: 'reportName' }
}
]
}
]
}
@@ -117,6 +117,14 @@ const routes: Routes = [
loadComponent: () =>
import('../clerk/today-activity/today-activity.component').then((m) => m.TodayActivityComponent),
data: { title: "Today's Activity", breadcrumb: "Today's Activity" }
},
{
path: 'dividend-dashboard',
loadComponent: () =>
import('../clerk/dividend-dashboard/dividend-dashboard.component').then(
(m) => m.DividendDashboardComponent
),
data: { title: 'Dividend Dashboard', breadcrumb: 'Dividend Dashboard' }
}
]
},
+7 -2
View File
@@ -7,8 +7,9 @@
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
/** HTTP service for Clerk Withdrawal Request maker-checker workflow. */
@Injectable({ providedIn: 'root' })
@@ -50,8 +51,12 @@ export class ClerkWithdrawalService {
return this.http.post(`/clerk/withdrawal-requests/${id}/reject`, { reason });
}
// Silenced: onTokNoBlur() probes this expecting a possible legitimate 404 (token not from this
// pool, could be a dividend claim's instead) and shows its own tailored message either way.
getByToken(token: string): Observable<any> {
return this.http.get(`/clerk/withdrawal-requests/by-token/${encodeURIComponent(token)}`);
return this.http.get(`/clerk/withdrawal-requests/by-token/${encodeURIComponent(token)}`, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable<any> {
@@ -925,42 +925,50 @@
<div class="te-acs-meta">
<span
class="te-acs-tag"
[class.te-acs-tag--active]="accountDetails?.status?.value === 'Active'"
[class.te-acs-tag--active]="activeDividendClaimRequest || accountDetails?.status?.value === 'Active'"
[class.te-acs-tag--blocked]="accountDetails?.status?.value === 'Blocked'"
[class.te-acs-tag--dormant]="accountDetails?.subStatus?.value === 'Dormant'"
>{{
accountDetails?.subStatus?.value === 'Dormant' ? 'Dormant' : (accountDetails?.status?.value ?? '—')
activeDividendClaimRequest
? 'Awaiting Disbursement'
: accountDetails?.subStatus?.value === 'Dormant'
? 'Dormant'
: (accountDetails?.status?.value ?? '—')
}}</span
>
<span class="te-acs-kv"
><b>A/c</b>
{{
accountDetails?.depositType?.value ||
(selectedRow?.acTyp === 'CA'
? 'Current'
: selectedRow?.acTyp === 'SD'
? 'Savings'
: selectedRow?.acTyp === 'FD'
? 'Fixed Dep.'
: selectedRow?.acTyp === 'RD'
? 'Recurring'
: selectedRow?.acTyp === 'GL'
? (accountDetails?.type?.value ?? 'GL Account')
: selectedRow?.acTyp === 'SHR'
? 'Share'
: selectedRow?.acTyp === 'LA'
? 'Loan'
: '—')
(activeDividendClaimRequest
? 'Dividend Claim'
: selectedRow?.acTyp === 'CA'
? 'Current'
: selectedRow?.acTyp === 'SD'
? 'Savings'
: selectedRow?.acTyp === 'FD'
? 'Fixed Dep.'
: selectedRow?.acTyp === 'RD'
? 'Recurring'
: selectedRow?.acTyp === 'GL'
? (accountDetails?.type?.value ?? 'GL Account')
: selectedRow?.acTyp === 'SHR'
? 'Share'
: selectedRow?.acTyp === 'LA'
? 'Loan'
: '—')
}}</span
>
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
<span class="te-acs-kv"
><b>Product</b>
{{
accountDetails?.savingsProductName ||
accountDetails?.depositProductName ||
accountDetails?.productName ||
'—'
activeDividendClaimRequest
? 'Dividend Cash Payout'
: accountDetails?.savingsProductName ||
accountDetails?.depositProductName ||
accountDetails?.productName ||
'—'
}}</span
>
<span class="te-acs-kv"
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
import { forkJoin, of, Subject } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
@@ -20,6 +20,7 @@ import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service';
import { ClerkDisbursementService } from '../clerk-disbursement.service';
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
import { CashierDepositService } from '../cashier-deposit.service';
@@ -749,6 +750,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Active approved withdrawal (cashier disburse flow)
activeWithdrawalRequest: any = null;
activeDividendClaimRequest: any = null;
loadingWithdrawal = false;
private http = inject(HttpClient);
@@ -757,6 +759,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private dialog = inject(MatDialog);
private clerkUiState = inject(ClerkUiStateService);
private clerkWithdrawalService = inject(ClerkWithdrawalService);
private dividendClaimRequestService = inject(DividendClaimRequestService);
private clerkDisbursementService = inject(ClerkDisbursementService);
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
private cashierDepositService = inject(CashierDepositService);
@@ -1731,10 +1734,28 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const token = row.tokNo.trim();
this.loadingWithdrawal = true;
this.activeWithdrawalRequest = null;
this.activeDividendClaimRequest = null;
this.clerkWithdrawalService
.getByToken(token)
.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 token pool before giving up, since both share the same TOK. NO. field.
if (err?.status === 404) {
return this.dividendClaimRequestService.getByToken(token).pipe(
catchError((divErr: any) => {
this.loadingWithdrawal = false;
const msg: string =
divErr?.error?.message ??
divErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
map((divReq: any) => (divReq ? { __dividendClaim: true, ...divReq } : null))
);
}
this.loadingWithdrawal = false;
const msg: string =
err?.error?.message ??
@@ -1747,6 +1768,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.subscribe((req: any) => {
this.loadingWithdrawal = false;
if (!req) return;
if (req.__dividendClaim) {
this.activeDividendClaimRequest = req;
// No real savings/current account backs a dividend cash claim (it's a pure GL
// liability settled at claim-approval time) — stub just enough of accountDetails
// and kycDetails for onSave()'s generic guards to pass without stale/unrelated data.
this.accountDetails = { id: null, accountNo: 'DIVIDEND-CLAIM-' + req.id, clientName: req.client_name };
this.kycDetails = null;
this.clientDetails = null;
this.accountError = '';
row.acNo = req.client_name ?? 'Dividend Claim #' + req.id;
row.acTyp = 'SD';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
this.selectedRow = row;
return;
}
this.activeWithdrawalRequest = req;
row.acNo = req.account_no ?? '';
row.acTyp = 'SD';
@@ -3012,7 +3051,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
// Cashier direct withdrawal BLOCKED — must use approved maker-checker token
if (!this.isClerk && !this.reviewMode && !isDeposit && !this.activeWithdrawalRequest) {
if (
!this.isClerk &&
!this.reviewMode &&
!isDeposit &&
!this.activeWithdrawalRequest &&
!this.activeDividendClaimRequest
) {
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
'Withdrawals cannot be processed directly by the cashier.',
'All withdrawals must go through the Clerk → Manager approval process.',
@@ -3021,9 +3066,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Approved withdrawal: amount/denomination must exactly match
if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) {
const approvedAmt = Number(this.activeWithdrawalRequest.amount);
// Approved withdrawal / dividend cash claim: amount/denomination must exactly match
if (!this.isClerk && (this.activeWithdrawalRequest || this.activeDividendClaimRequest) && !isDeposit) {
const rawApprovedAmt = Number(this.activeWithdrawalRequest?.amount ?? this.activeDividendClaimRequest?.amount);
// A dividend cash claim's liability is stored with paise precision (a proportional profit
// share), but cash can only be handed over in whole-rupee notes/coins -- compare against
// the floored amount so an exact denomination match is actually achievable. Ordinary
// withdrawal-request amounts are already whole rupees by convention, so this only changes
// behavior for dividend claims.
const approvedAmt = this.activeDividendClaimRequest ? Math.floor(rawApprovedAmt) : rawApprovedAmt;
if (Math.abs(amount - approvedAmt) > 0.005) {
if (isCash) {
this.showAlert('error', 'Denomination Mismatch — Approved Withdrawal', [
@@ -3116,6 +3167,30 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const isCash = !!selectedPtEarly?.isCashPayment;
const isGlType = row.acTyp === 'GL';
// Dividend cash claim token disbursement: the GL entry (Dr Dividend Payable / Cr Cash at
// Main Vault) already posted at claim-approval time — there is no real savings/current
// account behind this row, so skip balance/min-balance/KYC/dormant/facility checks entirely
// (none apply to a GL-only liability payout) and go straight to the confirm dialog.
if (this.activeDividendClaimRequest) {
if (!row.paymentTypeId) {
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
return;
}
if (isWithdrawal && this.denominationTotal > 0 && this.hasWithdrawalDenomOverdraft) {
const lines = this.denominationOverdrafts.map(
(o) => `${o.label}: Need ${o.requested}, Available ${o.available} (Short by ${o.shortfall})`
);
this.showAlert('error', 'Insufficient Denomination Stock', [
'Your cash drawer does not have enough notes/coins:',
...lines,
'Adjust denomination or request vault replenishment.'
]);
return;
}
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
return;
}
// GL transactions post as journal entries — skip account-level facility/balance checks
if (isGlType) {
if (!this.cashVaultGlId) {
@@ -3261,7 +3336,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
// Token-based cashier disbursement: balance was already deducted at manager approval
const isDisbursement = !isDeposit && !this.isClerk && !!this.activeWithdrawalRequest;
const isDisbursement =
!isDeposit && !this.isClerk && (!!this.activeWithdrawalRequest || !!this.activeDividendClaimRequest);
// Cashier deposit (savings/current/GL) goes via maker-checker: balance credits only after manager approval
const isDepositRequest =
isDeposit &&
@@ -3292,7 +3368,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
paymentType: selectedPt?.name ?? 'Cash',
chequeNo: row.chequeNo || null,
symbol: this.currencySymbol,
tokNo: this.activeWithdrawalRequest?.tok_no ?? null,
tokNo: this.activeWithdrawalRequest?.tok_no ?? this.activeDividendClaimRequest?.tok_no ?? null,
isDdCashGl: this.isDdCashGlRow(row),
ddFaceValue: row.amount ?? 0,
ddCharges: row.ddCharges ?? 0,
@@ -3354,9 +3430,52 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Dividend cash claim token disbursement → GL entry already posted at claim-approval time.
// Do NOT post any new transaction/journal entry — just record the physical hand-over via
// DividendClaimRequestService, mirroring the activeWithdrawalRequest branch just below.
if (!this.isClerk && this.activeDividendClaimRequest && !isDeposit) {
this.saving = true;
const selectedPtDisb = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const denomJson =
selectedPtDisb?.isCashPayment && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null;
const req = this.activeDividendClaimRequest;
this.dividendClaimRequestService
.disburse(req.id, denomJson, amount)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.message ??
err?.error?.errors?.[0]?.defaultUserMessage ??
'Disbursement recording failed. Please try again.';
this.showAlert('error', 'Disbursement Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
this.showAlert('success', 'Cash Disbursed Successfully', [
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} disbursed to customer.`,
`Token No: ${req.tok_no} · Client: ${req.client_name}`
]);
this.activeDividendClaimRequest = null;
this.clearDenominations();
this.loadOpeningPosition();
}
});
return;
}
// Hard block: cashier direct withdrawal never reaches here — caught in onSave()
// Extra safety net in case confirmSubmit() is ever called directly
if (!this.isClerk && !this.reviewMode && !isDeposit && !this.activeWithdrawalRequest) {
if (
!this.isClerk &&
!this.reviewMode &&
!isDeposit &&
!this.activeWithdrawalRequest &&
!this.activeDividendClaimRequest
) {
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
'Withdrawals must go through the maker-checker approval process.',
'Enter the approved token number to disburse.'
@@ -0,0 +1,86 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpContext } from '@angular/common/http';
import { Observable } from 'rxjs';
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
/**
* HTTP service for the maker-checker "claim my dividend early" workflow — a clerk submits a claim
* against a still-locked dividend on the member's behalf, a Bank Manager approves (only possible
* when the client has no overdue loan) or rejects it.
*/
@Injectable({ providedIn: 'root' })
export class DividendClaimRequestService {
private http = inject(HttpClient);
submit(
lockId: number,
breakdown: {
loanId?: number;
loanApplyAmount?: number;
savingsAccountId?: number;
savingsApplyAmount?: number;
cashApplyAmount?: number;
tokNo?: string;
}
): Observable<any> {
const body: any = { lockId };
if (breakdown.loanId != null) {
body.loanId = breakdown.loanId;
}
if (breakdown.loanApplyAmount != null) {
body.loanApplyAmount = String(breakdown.loanApplyAmount);
}
if (breakdown.savingsAccountId != null) {
body.savingsAccountId = breakdown.savingsAccountId;
}
if (breakdown.savingsApplyAmount != null) {
body.savingsApplyAmount = String(breakdown.savingsApplyAmount);
}
if (breakdown.cashApplyAmount != null) {
body.cashApplyAmount = String(breakdown.cashApplyAmount);
}
if (breakdown.tokNo != null) {
body.tokNo = breakdown.tokNo;
}
return this.http.post('/clerk/dividend-claim-requests', body);
}
// Silenced: onTokNoBlur() in Transaction Entry probes this only after the withdrawal-request
// token pool 404s, and shows its own tailored message either way.
getByToken(token: string): Observable<any> {
return this.http.get(`/clerk/dividend-claim-requests/by-token/${encodeURIComponent(token)}`, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable<any> {
return this.http.post(`/clerk/dividend-claim-requests/${id}/disburse`, {
denominationData,
transactionAmount: transactionAmount != null ? String(transactionAmount) : null
});
}
getOverdueLoans(clientId: number): Observable<any> {
return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`);
}
list(status = 'PENDING'): Observable<any> {
return this.http.get('/clerk/dividend-claim-requests', { params: { status } });
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/dividend-claim-requests/${id}/approve`, {});
}
reject(id: number, reason: string): Observable<any> {
return this.http.post(`/clerk/dividend-claim-requests/${id}/reject`, { reason });
}
}
@@ -0,0 +1,42 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* HTTP service for the Bank Manager's Dividend Dashboard — members with a locked (3-year)
* share dividend who also have an overdue loan EMI, with an action to apply the locked dividend
* towards that EMI.
*/
@Injectable({ providedIn: 'root' })
export class DividendDashboardService {
private http = inject(HttpClient);
getDashboard(): Observable<any> {
return this.http.get('/clerk/dividend-dashboard');
}
applyToLoan(lockId: number, loanId: number, amount: number): Observable<any> {
return this.http.post(`/clerk/dividend-dashboard/${lockId}/apply-to-loan`, { loanId, amount: String(amount) });
}
getSummary(): Observable<any> {
return this.http.get('/clerk/dividend-dashboard/summary');
}
getDeclarations(): Observable<any> {
return this.http.get('/clerk/dividend-dashboard/declarations');
}
getLocks(status?: string): Observable<any> {
const params = status && status !== 'ALL' ? { status } : {};
return this.http.get('/clerk/dividend-dashboard/locks', { params });
}
}
@@ -0,0 +1,416 @@
<!--
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/.
-->
@if (summary) {
<div class="dd-summary-grid">
<div class="dd-stat-card">
<div class="dd-stat-label">Declarations</div>
<div class="dd-stat-value">{{ summary.totalDeclarations }}</div>
<div class="dd-stat-sub">{{ summary.pendingApprovalDeclarations }} pending approval</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Members Affected</div>
<div class="dd-stat-value">{{ summary.membersAffected }}</div>
<div class="dd-stat-sub">{{ summary.totalMemberDividends }} member-dividends total</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Total Amount</div>
<div class="dd-stat-value">{{ summary.totalAmount | number: '1.2-2' }}</div>
<div class="dd-stat-sub">across all declared dividends</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Currently Locked</div>
<div class="dd-stat-value">{{ summary.lockedCount }}</div>
<div class="dd-stat-sub">{{ summary.lockedAmount | number: '1.2-2' }}</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Applied to Loans</div>
<div class="dd-stat-value">{{ summary.appliedToLoanCount }}</div>
<div class="dd-stat-sub">{{ summary.appliedToLoanAmount | number: '1.2-2' }}</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Claimed by Member</div>
<div class="dd-stat-value">{{ summary.claimedCount }}</div>
<div class="dd-stat-sub">{{ summary.claimedAmount | number: '1.2-2' }}</div>
</div>
<div class="dd-stat-card dd-stat-card--forfeited">
<div class="dd-stat-label">Forfeited to Profit</div>
<div class="dd-stat-value">{{ summary.forfeitedCount }}</div>
<div class="dd-stat-sub">{{ summary.forfeitedAmount | number: '1.2-2' }}</div>
</div>
</div>
}
<div class="pg-card">
<div class="pos-section-label">Dividend Declarations — Every Dividend Ever Declared, Tracked to Completion</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="declarationsLoading" (click)="loadDeclarations()">
Refresh
</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Product</th>
<th>Period</th>
<th class="num">Declared Amount</th>
<th class="num">Members</th>
<th>Status</th>
<th>Approved On</th>
</tr>
</thead>
<tbody>
@if (declarationsLoading) {
<tr>
<td colspan="6" class="pg-empty">Loading…</td>
</tr>
} @else if (declarationsLoaded && declarations.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No dividends have been declared yet.</td>
</tr>
} @else {
@for (d of declarations; track d.declarationId) {
<tr>
<td>{{ d.productName }}</td>
<td>{{ d.periodStartDate }} → {{ d.periodEndDate }}</td>
<td class="num">{{ d.declaredAmount | number: '1.2-2' }}</td>
<td class="num">{{ d.totalMembers }}</td>
<td>
<span class="dd-badge" [ngClass]="declarationStatusClass(d.status)">{{
declarationStatusLabel(d.status)
}}</span>
</td>
<td>{{ d.approvedOn ? (d.approvedOn | date: 'medium') : '—' }}</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<div class="pos-section-label">Dividend Dashboard — Locked Dividends for Members with Overdue EMI</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<th class="num">Locked Dividend</th>
<th>Locked Date</th>
<th>Release Due</th>
<th class="num">Total Overdue EMI</th>
<th></th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="6" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && rows.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No members with a locked dividend and an overdue EMI right now.</td>
</tr>
} @else {
@for (row of rows; track row.lockId) {
<tr>
<td>{{ row.clientName }}</td>
<td class="num">{{ row.lockedAmount | number: '1.2-2' }}</td>
<td>{{ row.lockedDate }}</td>
<td>{{ row.releaseDueDate }}</td>
<td class="num">{{ totalOverdue(row) | number: '1.2-2' }}</td>
<td>
<button type="button" class="pg-btn" (click)="toggleApply(row)">
{{ expandedLockId === row.lockId ? 'Cancel' : 'Apply to Loan' }}
</button>
</td>
</tr>
@if (expandedLockId === row.lockId) {
<tr class="dd-apply-row">
<td colspan="6">
<div class="dd-apply-panel">
<label class="inv-form-field">
<span>Loan</span>
<select [(ngModel)]="selectedLoanId[row.lockId]" (change)="onLoanChange(row)">
@for (loan of row.overdueLoans; track loan.loanId) {
<option [ngValue]="loan.loanId">
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Amount to Apply (max {{ maxAmountFor(row) | number: '1.2-2' }})</span>
<input
type="number"
[(ngModel)]="applyAmount[row.lockId]"
[max]="maxAmountFor(row)"
min="0.01"
step="0.01"
/>
</label>
@if (applyError) {
<div class="dd-apply-error">{{ applyError }}</div>
}
<button
type="button"
class="pg-btn dd-apply-confirm"
[disabled]="applying"
(click)="submitApply(row)"
>
{{ applying ? 'Applying…' : 'Confirm' }}
</button>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<div class="pos-section-label">All Dividend Locks — Full 3-Year Lock History</div>
<div class="inv-form-grid" style="padding: 12px 16px; gap: 8px">
<div class="dd-filter-group">
@for (opt of ['ALL', 'UNCLAIMED', 'CLAIMED', 'FORFEITED']; track opt) {
<button
type="button"
class="dd-filter-btn"
[class.dd-filter-btn--active]="lockStatusFilter === opt"
(click)="setLockStatusFilter(opt)"
>
{{ opt === 'ALL' ? 'All' : (opt | titlecase) }}
</button>
}
</div>
<label class="inv-form-field" style="align-self: end; margin-left: auto">
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<th>Savings Account</th>
<th class="num">Amount</th>
<th>Locked Date</th>
<th>Release Due</th>
<th>Status</th>
<th>Applied Loan</th>
<th></th>
</tr>
</thead>
<tbody>
@if (locksLoading) {
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (locksLoaded && locks.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No dividend locks match this filter.</td>
</tr>
} @else {
@for (l of locks; track l.lockId) {
<tr>
<td>{{ l.clientName }}</td>
<td>
<span class="pg-chip">{{ l.savingsAccountNo }}</span>
</td>
<td class="num">{{ l.amount | number: '1.2-2' }}</td>
<td>{{ l.lockedDate }}</td>
<td>{{ l.releaseDueDate }}</td>
<td>
<span class="dd-badge" [ngClass]="lockStatusClass(l.status)">{{ l.status | titlecase }}</span>
</td>
<td>{{ l.appliedLoanAccountNo || '—' }}</td>
<td>
@if (l.status === 'UNCLAIMED') {
<button type="button" class="pg-btn" (click)="toggleClaim(l)">
{{ expandedClaimLockId === l.lockId ? 'Cancel' : 'Claim' }}
</button>
}
</td>
</tr>
@if (expandedClaimLockId === l.lockId) {
<tr class="dd-apply-row">
<td colspan="8">
<div class="dd-apply-panel dd-apply-panel--wrap">
<div class="dd-apply-panel-title">
Unclaimed amount: {{ l.amount | number: '1.2-2' }} — split it below (loan, savings, cash)
</div>
@if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) {
<label class="inv-form-field">
<span>Apply to Overdue Loan</span>
<select [(ngModel)]="claimLoanId[l.lockId]">
@for (loan of claimOverdueLoans[l.lockId]; track loan.loanId) {
<option [ngValue]="loan.loanId">
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Loan Amount</span>
<input type="number" [(ngModel)]="claimLoanAmount[l.lockId]" min="0" step="0.01" />
</label>
} @else {
<div class="pg-empty-inline">No overdue loan for this client.</div>
}
<label class="inv-form-field">
<span>Credit to Savings</span>
<input type="number" [(ngModel)]="claimSavingsAmount[l.lockId]" min="0" step="0.01" />
</label>
@if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) {
<label class="inv-form-field">
<span>Savings Account ID (no linked account — enter one to credit)</span>
<input type="number" [(ngModel)]="claimSavingsAccountOverride[l.lockId]" min="1" step="1" />
</label>
}
<label class="inv-form-field">
<span>Cash</span>
<input type="number" [(ngModel)]="claimCashAmount[l.lockId]" min="0" step="0.01" />
</label>
@if (claimCashAmount[l.lockId] > 0) {
<label class="inv-form-field">
<span>Token No. (for cashier hand-over)</span>
<input type="text" [(ngModel)]="claimTokNo[l.lockId]" />
</label>
}
<div class="dd-apply-panel-total">
Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }}
</div>
@if (claimError) {
<div class="dd-apply-error">{{ claimError }}</div>
}
<button
type="button"
class="pg-btn dd-apply-confirm"
[disabled]="claimSubmitting"
(click)="submitClaim(l)"
>
{{ claimSubmitting ? 'Submitting…' : 'Submit Claim Request' }}
</button>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<div class="pos-section-label">Dividend Claim Requests — Pending Review</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="claimRequestsLoading" (click)="loadClaimRequests()">
Refresh
</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<th>Savings Account</th>
<th class="num">To Loan</th>
<th class="num">To Savings</th>
<th class="num">Cash</th>
<th class="num">Unclaimed Balance</th>
<th>Client Loan Status</th>
<th></th>
</tr>
</thead>
<tbody>
@if (claimRequestsLoading) {
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (claimRequestsLoaded && claimRequests.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No dividend claim requests pending review.</td>
</tr>
} @else {
@for (req of claimRequests; track req.id) {
<tr>
<td>{{ req.client_name }}</td>
<td>
<span class="pg-chip">{{ req.savings_account_no }}</span>
</td>
<td class="num">
{{
req.loan_apply_amount > 0
? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')'
: '—'
}}
</td>
<td class="num">{{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }}</td>
<td class="num">
{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}
@if (req.cash_apply_amount > 0 && req.tok_no) {
<div class="pg-empty-inline">Tok: {{ req.tok_no }}</div>
}
</td>
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
<td>
@if (req.has_overdue_loan) {
<span class="dd-badge dd-badge--failed">Has Overdue EMI</span>
} @else {
<span class="dd-badge dd-badge--posted">No Overdue Loan</span>
}
</td>
<td>
<button type="button" class="pg-btn" [disabled]="claimActioningId === req.id" (click)="approveClaim(req)">
{{ claimActioningId === req.id ? 'Approving…' : 'Approve' }}
</button>
<button type="button" class="pg-btn" (click)="toggleRejectClaim(req)">
{{ expandedRejectClaimId === req.id ? 'Cancel' : 'Reject' }}
</button>
@if (claimActionError[req.id]) {
<div class="dd-retry-error">{{ claimActionError[req.id] }}</div>
}
</td>
</tr>
@if (expandedRejectClaimId === req.id) {
<tr class="dd-apply-row">
<td colspan="8">
<div class="dd-apply-panel">
<label class="inv-form-field">
<span>Rejection Reason</span>
<input
type="text"
[(ngModel)]="rejectReason[req.id]"
placeholder="Why is this claim being rejected?"
/>
</label>
<button
type="button"
class="pg-btn dd-apply-confirm"
[disabled]="claimActioningId === req.id"
(click)="rejectClaim(req)"
>
{{ claimActioningId === req.id ? 'Rejecting…' : 'Confirm Reject' }}
</button>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
@@ -0,0 +1,171 @@
/**
* 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/.
*/
.dd-apply-row td {
background: #f8fafc;
padding: 0;
}
.dd-apply-panel {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
padding: 14px 16px;
.inv-form-field {
min-width: 220px;
}
}
.dd-apply-error {
color: #b42318;
font-size: 12.5px;
font-weight: 600;
}
.dd-apply-panel--wrap {
align-items: flex-end;
}
.dd-apply-panel-title {
width: 100%;
font-size: 12.5px;
font-weight: 700;
color: #475569;
}
.dd-apply-panel-total {
font-size: 13px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
.dd-apply-confirm {
background: #2563eb;
color: #fff;
border-color: #2563eb;
&:disabled {
opacity: 0.6;
}
}
.dd-retry-error {
margin-top: 4px;
color: #b42318;
font-size: 12px;
font-weight: 600;
max-width: 240px;
}
.dd-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.dd-badge--pending {
background: #fef3c7;
color: #92400e;
}
.pg-empty-inline {
color: #94a3b8;
font-size: 12.5px;
font-style: italic;
}
.dd-badge--failed {
background: #fee2e2;
color: #b42318;
}
.dd-badge--posted {
background: #dcfce7;
color: #166534;
}
.dd-badge--partial {
background: #dbeafe;
color: #1e40af;
}
/* ─── Summary stat cards ─── */
.dd-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.dd-stat-card {
background: #fff;
border: 1px solid #e8ecf0;
border-radius: 10px;
padding: 12px 14px;
border-left: 3px solid #94a3b8;
&--forfeited {
border-left-color: #7c3aed;
}
}
.dd-stat-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
}
.dd-stat-value {
font-size: 22px;
font-weight: 700;
color: #0f172a;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.dd-stat-sub {
font-size: 11.5px;
color: #94a3b8;
margin-top: 2px;
}
/* ─── Lock status filter buttons ─── */
.dd-filter-group {
display: flex;
gap: 6px;
}
.dd-filter-btn {
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
font-size: 12.5px;
font-weight: 600;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: #94a3b8;
}
&--active {
background: #1e293b;
border-color: #1e293b;
color: #fff;
}
}
@@ -0,0 +1,359 @@
/**
* 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 { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { DividendDashboardService } from '../dividend-dashboard.service';
import { DividendClaimRequestService } from '../dividend-claim-request.service';
/**
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year
* lock history, members whose unclaimed dividend sits alongside an overdue loan EMI (with a
* proactive "Apply to Loan" action), and the maker-checker claim workflow for members who come to
* claim their dividend directly (split across loan, savings, or cash).
*/
@Component({
selector: 'mifosx-dividend-dashboard',
templateUrl: './dividend-dashboard.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss',
'./dividend-dashboard.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule
]
})
export class DividendDashboardComponent implements OnInit {
private service = inject(DividendDashboardService);
private claimRequestService = inject(DividendClaimRequestService);
rows: any[] = [];
loading = false;
loaded = false;
expandedLockId: number | null = null;
selectedLoanId: Record<number, number | null> = {};
applyAmount: Record<number, number | null> = {};
applying = false;
applyError: string | null = null;
summary: any = null;
summaryLoading = false;
declarations: any[] = [];
declarationsLoading = false;
declarationsLoaded = false;
locks: any[] = [];
locksLoading = false;
locksLoaded = false;
lockStatusFilter = 'ALL';
expandedClaimLockId: number | null = null;
claimOverdueLoans: Record<number, any[]> = {};
claimLoanId: Record<number, number | null> = {};
claimLoanAmount: Record<number, number | null> = {};
claimSavingsAmount: Record<number, number | null> = {};
claimSavingsAccountOverride: Record<number, number | null> = {};
claimCashAmount: Record<number, number | null> = {};
claimTokNo: Record<number, string> = {};
claimSubmitting = false;
claimError: string | null = null;
claimRequests: any[] = [];
claimRequestsLoading = false;
claimRequestsLoaded = false;
claimActioningId: number | null = null;
claimActionError: Record<number, string> = {};
expandedRejectClaimId: number | null = null;
rejectReason: Record<number, string> = {};
ngOnInit(): void {
this.loadSummary();
this.loadDeclarations();
this.load();
this.loadLocks();
this.loadClaimRequests();
}
toggleClaim(lock: any): void {
if (this.expandedClaimLockId === lock.lockId) {
this.expandedClaimLockId = null;
return;
}
this.expandedClaimLockId = lock.lockId;
this.claimError = null;
this.claimLoanId[lock.lockId] = null;
this.claimLoanAmount[lock.lockId] = null;
this.claimSavingsAmount[lock.lockId] = Number(lock.amount);
this.claimSavingsAccountOverride[lock.lockId] = null;
this.claimCashAmount[lock.lockId] = null;
this.claimTokNo[lock.lockId] = '';
this.claimRequestService
.getOverdueLoans(lock.clientId)
.pipe(catchError(() => of([])))
.subscribe((loans: any[]) => {
this.claimOverdueLoans[lock.lockId] = loans ?? [];
if (loans && loans.length > 0) {
this.claimLoanId[lock.lockId] = loans[0].loanId;
const loanAmt = Math.min(Number(lock.amount), Number(loans[0].overdueAmount));
this.claimLoanAmount[lock.lockId] = loanAmt;
this.claimSavingsAmount[lock.lockId] = Number((Number(lock.amount) - loanAmt).toFixed(2));
}
});
}
claimTotal(lock: any): number {
return (
Number(this.claimLoanAmount[lock.lockId] || 0) +
Number(this.claimSavingsAmount[lock.lockId] || 0) +
Math.floor(Number(this.claimCashAmount[lock.lockId] || 0))
);
}
submitClaim(lock: any): void {
const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0);
const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0);
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), while a
// dividend share often works out to paise — floor to the nearest rupee so the denomination
// total the cashier counts can always match exactly; any leftover paise stays unclaimed.
const cashAmount = Math.floor(Number(this.claimCashAmount[lock.lockId] || 0));
const total = loanAmount + savingsAmount + cashAmount;
if (total <= 0) {
this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.';
return;
}
if (total > Number(lock.amount)) {
this.claimError = `Total (${total}) cannot exceed the unclaimed amount of ${lock.amount}.`;
return;
}
if (loanAmount > 0 && !this.claimLoanId[lock.lockId]) {
this.claimError = 'Select a loan to apply the loan amount towards.';
return;
}
if (savingsAmount > 0 && !lock.savingsAccountId && !this.claimSavingsAccountOverride[lock.lockId]) {
this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.';
return;
}
const tokNo = this.claimTokNo[lock.lockId]?.trim();
if (cashAmount > 0 && !tokNo) {
this.claimError = 'Enter a Token No. for the cash portion — the cashier will use it to hand over the cash.';
return;
}
this.claimSubmitting = true;
this.claimError = null;
this.claimRequestService
.submit(lock.lockId, {
loanId: loanAmount > 0 ? this.claimLoanId[lock.lockId] : undefined,
loanApplyAmount: loanAmount > 0 ? loanAmount : undefined,
savingsAccountId:
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined,
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined,
tokNo: cashAmount > 0 ? tokNo : undefined
})
.subscribe({
next: () => {
this.claimSubmitting = false;
this.expandedClaimLockId = null;
this.loadClaimRequests();
},
error: (err: any) => {
this.claimSubmitting = false;
this.claimError = err?.error?.message || 'Failed to submit claim request.';
}
});
}
loadClaimRequests(): void {
this.claimRequestsLoading = true;
this.claimRequestService
.list('PENDING')
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.claimRequests = res ?? [];
this.claimRequestsLoading = false;
this.claimRequestsLoaded = true;
});
}
approveClaim(req: any): void {
this.claimActioningId = req.id;
delete this.claimActionError[req.id];
this.claimRequestService.approve(req.id).subscribe({
next: () => {
this.claimActioningId = null;
this.loadClaimRequests();
this.loadLocks();
this.loadSummary();
},
error: (err: any) => {
this.claimActioningId = null;
this.claimActionError[req.id] = err?.error?.message || 'Failed to approve claim.';
}
});
}
toggleRejectClaim(req: any): void {
this.expandedRejectClaimId = this.expandedRejectClaimId === req.id ? null : req.id;
}
rejectClaim(req: any): void {
const reason = this.rejectReason[req.id];
if (!reason || !reason.trim()) {
this.claimActionError[req.id] = 'Rejection reason is required.';
return;
}
this.claimActioningId = req.id;
delete this.claimActionError[req.id];
this.claimRequestService.reject(req.id, reason).subscribe({
next: () => {
this.claimActioningId = null;
this.expandedRejectClaimId = null;
this.loadClaimRequests();
},
error: (err: any) => {
this.claimActioningId = null;
this.claimActionError[req.id] = err?.error?.message || 'Failed to reject claim.';
}
});
}
loadSummary(): void {
this.summaryLoading = true;
this.service
.getSummary()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.summary = res;
this.summaryLoading = false;
});
}
loadDeclarations(): void {
this.declarationsLoading = true;
this.service
.getDeclarations()
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.declarations = res ?? [];
this.declarationsLoading = false;
this.declarationsLoaded = true;
});
}
declarationStatusLabel(status: string): string {
return status === 'PENDING_APPROVAL' ? 'Pending Approval' : 'Approved';
}
declarationStatusClass(status: string): string {
return status === 'PENDING_APPROVAL' ? 'dd-badge--pending' : 'dd-badge--posted';
}
loadLocks(): void {
this.locksLoading = true;
this.service
.getLocks(this.lockStatusFilter)
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.locks = res ?? [];
this.locksLoading = false;
this.locksLoaded = true;
});
}
setLockStatusFilter(status: string): void {
this.lockStatusFilter = status;
this.loadLocks();
}
lockStatusClass(status: string): string {
switch (status) {
case 'UNCLAIMED':
return 'dd-badge--pending';
case 'CLAIMED':
return 'dd-badge--posted';
case 'FORFEITED':
return 'dd-badge--failed';
default:
return 'dd-badge--pending';
}
}
load(): void {
this.loading = true;
this.expandedLockId = null;
this.service
.getDashboard()
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.rows = res ?? [];
this.loading = false;
this.loaded = true;
});
}
totalOverdue(row: any): number {
return (row.overdueLoans || []).reduce((sum: number, l: any) => sum + Number(l.overdueAmount || 0), 0);
}
toggleApply(row: any): void {
if (this.expandedLockId === row.lockId) {
this.expandedLockId = null;
return;
}
this.expandedLockId = row.lockId;
this.applyError = null;
const firstLoan = (row.overdueLoans || [])[0];
this.selectedLoanId[row.lockId] = firstLoan ? firstLoan.loanId : null;
this.applyAmount[row.lockId] = firstLoan
? Math.min(Number(row.lockedAmount), Number(firstLoan.overdueAmount))
: Number(row.lockedAmount);
}
maxAmountFor(row: any): number {
const loanId = this.selectedLoanId[row.lockId];
const loan = (row.overdueLoans || []).find((l: any) => l.loanId === loanId);
const overdue = loan ? Number(loan.overdueAmount) : Number(row.lockedAmount);
return Math.min(Number(row.lockedAmount), overdue);
}
onLoanChange(row: any): void {
this.applyAmount[row.lockId] = this.maxAmountFor(row);
}
submitApply(row: any): void {
const loanId = this.selectedLoanId[row.lockId];
const amount = this.applyAmount[row.lockId];
if (!loanId || !amount || amount <= 0) {
this.applyError = 'Select a loan and enter a valid amount.';
return;
}
this.applying = true;
this.applyError = null;
this.service.applyToLoan(row.lockId, loanId, amount).subscribe({
next: () => {
this.applying = false;
this.load();
this.loadLocks();
this.loadSummary();
},
error: (err: any) => {
this.applying = false;
this.applyError = err?.error?.message || 'Failed to apply dividend to loan.';
}
});
}
}
@@ -136,7 +136,14 @@
| translate
}}
</mat-label>
<input matInput required formControlName="fullname" autocomplete="off" />
<input
matInput
required
formControlName="fullname"
autocomplete="off"
(blur)="capitalizeControl('fullname')"
style="text-transform: capitalize"
/>
@if (createClientForm.controls.fullname.hasError('required')) {
<mat-error>Client name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
@@ -151,10 +158,12 @@
<div class="full-name-row full-width">
<div class="full-name-preview">
<span class="full-name-label">Entity Name:</span>
<span class="full-name-value">{{ createClientForm.get('fullname')?.value }}</span>
<span class="full-name-value" style="text-transform: capitalize">{{
createClientForm.get('fullname')?.value
}}</span>
</div>
<div class="verify-inline">
@if (!sanctionsVerified && !sanctionsBlocked) {
@if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
<button
mat-stroked-button
color="primary"
@@ -164,27 +173,27 @@
(click)="verifySanctions()"
>
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
Verify Entity
{{ sanctionsNeedsRescreen ? 'Re-verify Entity' : 'Verify Entity' }}
</button>
}
@if (sanctionsVerified) {
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 &amp; PMLA 2002</span>
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
<span class="verify-status verified"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
<mat-icon
class="sanctions-info-icon"
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
[matTooltip]="sanctionsTooltip"
matTooltipClass="sanctions-tooltip"
>info</mat-icon
>
}
@if (sanctionsBlocked) {
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 &amp; PMLA 2002</span>
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
<span class="verify-status blocked">
<fa-icon icon="exclamation-triangle"></fa-icon> Sanctions Match
<fa-icon icon="exclamation-triangle"></fa-icon> {{ sanctionsStatusLabel }}
</span>
<mat-icon
class="sanctions-info-icon blocked-info"
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
[matTooltip]="sanctionsTooltip"
matTooltipClass="sanctions-tooltip"
>info</mat-icon
>
@@ -214,7 +223,14 @@
<mat-form-field appearance="outline" class="name-field first-name">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
<input matInput required formControlName="firstname" autocomplete="off" />
<input
matInput
required
formControlName="firstname"
autocomplete="off"
(blur)="capitalizeControl('firstname')"
style="text-transform: capitalize"
/>
@if (createClientForm.controls.firstname.hasError('required')) {
<mat-error>First name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
@@ -228,7 +244,13 @@
<mat-form-field appearance="outline" class="name-field middle-name">
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
<input matInput formControlName="middlename" autocomplete="off" />
<input
matInput
formControlName="middlename"
autocomplete="off"
(blur)="capitalizeControl('middlename')"
style="text-transform: capitalize"
/>
@if (
createClientForm.controls.middlename?.hasError('pattern') &&
createClientForm.controls.middlename?.touched
@@ -241,7 +263,18 @@
<mat-form-field appearance="outline" class="name-field last-name">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
<input matInput required formControlName="lastname" autocomplete="off" />
<<<<<<< Updated upstream
<input
matInput
required
formControlName="lastname"
autocomplete="off"
(blur)="capitalizeControl('lastname')"
style="text-transform: capitalize"
/>
=======
<input matInput required formControlName="lastname" autocomplete="off" (input)="formatName('lastname')" />
>>>>>>> Stashed changes
@if (createClientForm.controls.lastname.hasError('required')) {
<mat-error>Last name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
@@ -261,7 +294,7 @@
<span class="full-name-value">{{ fullNamePreview }}</span>
</div>
<div class="verify-inline">
@if (!sanctionsVerified && !sanctionsBlocked) {
@if (!sanctionsVerified && (!sanctionsBlocked || sanctionsNeedsRescreen)) {
<button
mat-stroked-button
color="primary"
@@ -271,27 +304,27 @@
(click)="verifySanctions()"
>
<fa-icon icon="shield-alt" class="verify-name-icon"></fa-icon>
Verify Client
{{ sanctionsNeedsRescreen ? 'Re-verify Client' : 'Verify Client' }}
</button>
}
@if (sanctionsVerified) {
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 &amp; PMLA 2002</span>
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
<span class="verify-status verified"> <fa-icon icon="check-circle"></fa-icon> Verified </span>
<mat-icon
class="sanctions-info-icon"
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
[matTooltip]="sanctionsTooltip"
matTooltipClass="sanctions-tooltip"
>info</mat-icon
>
}
@if (sanctionsBlocked) {
<span class="sanctions-citation">RBI KYC Master Direction (2016) — Section 51 &amp; PMLA 2002</span>
<span class="sanctions-citation">{{ sanctionsCitation }}</span>
<span class="verify-status blocked">
<fa-icon icon="exclamation-triangle"></fa-icon> Sanctions Match
<fa-icon icon="exclamation-triangle"></fa-icon> {{ sanctionsStatusLabel }}
</span>
<mat-icon
class="sanctions-info-icon blocked-info"
matTooltip="All customer names must be verified against the UN Security Council Consolidated List before account opening and periodically thereafter. Positive matches must be escalated to the Principal Officer immediately."
[matTooltip]="sanctionsTooltip"
matTooltipClass="sanctions-tooltip"
>info</mat-icon
>
@@ -694,6 +727,9 @@
}
</mat-form-field>
<!-- Date of Incorporation is captured on the KYC step, next to the entity PAN it is
verified against — see client-kyc-step's `incorporationDate` control. -->
<!-- Authorized Signatories (multiple, registered clients) -->
<div class="form-field full-width signatory-section">
<h4 class="section-title signatory-title">
@@ -822,7 +858,8 @@
<div class="guardian-result-item__info">
<span class="guardian-result-item__name">{{ result.displayName }}</span>
<span class="guardian-result-item__meta"
>ID: {{ result.id }} &middot; A/C: {{ result.accountNumber }} &middot;
>ID: {{ result.id }} &middot; A/C: {{ result.accountNumber }}
&middot;
{{ result.officeName }}
@if (result.dateOfBirth) {
&middot; DOB: {{ result.dateOfBirth | dateFormat }}
@@ -1148,6 +1185,79 @@
</div>
}
<!--
Section: Turnover & Risk (Non-Individual only)
An entity has no salary to declare, so the individual Financial & Classification section below
never applied to it — which left a company with no turnover on file at all, and its risk stuck
on the flat entity default. Turnover is the size signal the branch actually has at onboarding,
so it is captured here and the risk band it produces is shown right beside it: the officer sees
the grade move as they type, instead of first meeting it on the KYC tab.
-->
@if (createClientForm.get('legalFormId')?.value === LegalFormId.ENTITY) {
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="chart-line" class="section-icon"></fa-icon>
Turnover &amp; Risk Profile
</h4>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Monthly Turnover</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
<mat-hint>Yearly turnover fills in automatically (× 12)</mat-hint>
@if (
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
createClientForm.get('monthlyTurnover')?.touched
) {
<mat-error>Enter a valid amount</mat-error>
} @else if (
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
) {
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Annual Turnover</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
<input matInput type="number" formControlName="annualTurnover" min="0" />
<mat-hint>Drives the risk grade below</mat-hint>
@if (
createClientForm.get('annualTurnover')?.hasError('pattern') &&
createClientForm.get('annualTurnover')?.touched
) {
<mat-error>Enter a valid amount</mat-error>
} @else if (
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
) {
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<div
class="full-width entity-risk-card"
[class.entity-risk-card--low]="entityTurnoverRisk.category === 1"
[class.entity-risk-card--medium]="entityTurnoverRisk.category === 2"
[class.entity-risk-card--high]="entityTurnoverRisk.category === 3"
>
<div class="entity-risk-card__head">
<fa-icon icon="shield-alt" class="entity-risk-card__icon"></fa-icon>
<span class="entity-risk-card__title">Auto-calculated Risk Category</span>
<span class="entity-risk-card__badge">{{ riskCategoryLabel(entityTurnoverRisk.category) }}</span>
</div>
<p class="entity-risk-card__reason">{{ entityTurnoverRisk.reason }}</p>
<p class="entity-risk-card__scale">
Up to ₹1 crore — Low &nbsp;·&nbsp; ₹1 crore to ₹10 crore — Medium &nbsp;·&nbsp; Above ₹10 crore — High
</p>
<p class="entity-risk-card__note">
Carried into the KYC tab, where it can still be overridden with a recorded reason.
</p>
</div>
</div>
</div>
}
<!-- Section: Financial & Classification (Individual only) -->
@if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
<div class="section-block">
@@ -56,6 +56,103 @@ form {
grid-column: 1 / -1;
}
/* Live risk grade beside the entity's turnover fields. Neutral by default; the accent tracks the
band so a jump from Medium to High is visible without reading the sentence. */
.entity-risk-card {
margin: 4px 0 12px;
padding: 12px 14px;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-left: 4px solid #94a3b8;
&__head {
display: flex;
align-items: center;
gap: 8px;
}
&__icon {
color: #64748b;
}
&__title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
&__badge {
margin-left: auto;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.3px;
text-transform: uppercase;
background: #e2e8f0;
color: #334155;
}
&__reason {
margin: 8px 0 0;
font-size: 13px;
color: #334155;
}
&__scale,
&__note {
margin: 4px 0 0;
font-size: 11px;
color: #64748b;
}
&--low {
background: #f0fdf4;
border-color: #bbf7d0;
border-left-color: #16a34a;
.entity-risk-card__icon {
color: #16a34a;
}
.entity-risk-card__badge {
background: #dcfce7;
color: #15803d;
}
}
&--medium {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
.entity-risk-card__icon {
color: #d97706;
}
.entity-risk-card__badge {
background: #fef3c7;
color: #b45309;
}
}
&--high {
background: #fef2f2;
border-color: #fecaca;
border-left-color: #dc2626;
.entity-risk-card__icon {
color: #dc2626;
}
.entity-risk-card__badge {
background: #fee2e2;
color: #b91c1c;
}
}
}
.senior-citizen-auto-note {
display: flex;
align-items: center;
@@ -32,6 +32,7 @@ import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { LegalFormId } from 'app/clients/models/legal-form.enum';
import { toProperNameCase } from 'app/clients/utils/name-case.util';
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
/** Custom Services */
@@ -333,6 +334,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
alternateDipLookupLogId: number | null = null;
/** Alternate DIP lookup timestamp from API */
alternateDipTimestamp: string | null = null;
/**
* DIP/MNRL status the number actually returned (CLEAN / SUSPECTED / SERVICE_UNAVAILABLE).
* Kept separately from `mobileVerified` so an unverifiable number is not indistinguishable
* from one that genuinely passed.
*/
dipStatus: string | null = null;
alternateDipStatus: string | null = null;
/** Officer rejected the number outright — it must be replaced before saving. */
dipRejected = false;
alternateDipRejected = false;
/**
* CLIENT CREATION contact-OTP verification state (mobileNo / emailAddress). Unlike the
@@ -529,8 +540,37 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
sanctionsVerified = false;
/** Sanctions match was found — blocks account opening */
sanctionsBlocked = false;
/**
* Outcome recorded against a match: 'FALSE_POSITIVE' (awaiting Principal Officer approval)
* or 'CONFIRMED_MATCH'. Null when the hit has not been reviewed yet. Both keep the client
* blocked — only the Principal Officer's approval, applied server-side, releases a name.
*/
sanctionsDisposition: string | null = null;
/**
* The name was edited after a block, so the applicant may no longer be the flagged one.
* Re-exposes the verify button; the block itself is only lifted by a CLEAR from the API.
*/
sanctionsNeedsRescreen = false;
/** Screening log ID from sanctions API */
screeningLogId: number | null = null;
/** Cites the direction actually in force — kept in step with the verification dialog. */
readonly sanctionsCitation = 'UAPA 1967 s.51-A & RBI KYC Directions, 2025 — Chapter IX';
readonly sanctionsTooltip =
'All customer names must be verified against the UN Security Council Consolidated List before account ' +
'opening and periodically thereafter. A match is not a rejection: compare the identifiers, record a ' +
'false positive with reasons, or confirm the match and escalate to the Principal Officer immediately.';
/** Badge text reflecting how far the flagged alert has progressed. */
get sanctionsStatusLabel(): string {
if (this.sanctionsDisposition === 'FALSE_POSITIVE') {
return 'Awaiting PO Approval';
}
if (this.sanctionsDisposition === 'CONFIRMED_MATCH') {
return 'Confirmed Match';
}
return 'Sanctions Match';
}
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
private verifiedName: string = '';
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
@@ -654,18 +694,29 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
if (this.createClientForm.get('guardianDetails')) {
this.createClientForm.removeControl('guardianDetails');
}
// Same reasoning as the guardian above: the reused component instance would otherwise carry the
// previous entity's authorized signatories into a brand new client.
this.signatories = [];
this.signatorySearchText = '';
this.signatorySearchResults = [];
// Verification state must not carry over — a previous client's cleared sanctions
// screening or verified OTP must not silently apply to a different new client.
this.mobileVerified = false;
this.dipLookupLogId = null;
this.dipTimestamp = null;
this.dipStatus = null;
this.dipRejected = false;
this.alternateMobileVerified = false;
this.alternateDipLookupLogId = null;
this.alternateDipTimestamp = null;
this.alternateDipStatus = null;
this.alternateDipRejected = false;
this.mobileNewOtpId = null;
this.emailNewOtpId = null;
this.sanctionsVerified = false;
this.sanctionsBlocked = false;
this.sanctionsDisposition = null;
this.sanctionsNeedsRescreen = false;
this.screeningLogId = null;
this.verifiedName = '';
}
@@ -777,8 +828,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
incorpNumber: data.clientNonPersonDetails.incorpNumber || '',
remarks: data.clientNonPersonDetails.remarks || '',
authorizedSignatory: data.clientNonPersonDetails.authorizedSignatory || '',
authorizedSignatoryClientIds: data.clientNonPersonDetails.authorizedSignatoryClientIds || '',
additionalInfo: data.clientNonPersonDetails.additionalInfo || ''
});
this.restoreSignatories(
data.clientNonPersonDetails.authorizedSignatoryClientIds,
data.clientNonPersonDetails.authorizedSignatory
);
}
}
@@ -971,7 +1027,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
/**
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
* and annual income for annual/yearly turnover).
* and annual income for annual/yearly turnover), but only if the turnover fields
* have not been manually modified or cleared.
*/
private linkIncomeToTurnover(): void {
const monthlyIncome = this.createClientForm.get('monthlyIncome');
@@ -979,23 +1036,33 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const annualIncome = this.createClientForm.get('annualIncome');
const annualTurnover = this.createClientForm.get('annualTurnover');
if (monthlyIncome && monthlyTurnover) {
const isEmpty = (val: any) => val === '' || val === null || val === undefined;
if (monthlyIncome && monthlyTurnover && annualTurnover) {
monthlyIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
if (isPristine || isTurnoverEmpty) {
const num = parseFloat(value);
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
}
});
}
if (annualIncome && annualTurnover) {
if (annualIncome && annualTurnover && monthlyTurnover) {
annualIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
if (isPristine || isTurnoverEmpty) {
const num = parseFloat(value);
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
}
});
}
}
@@ -1122,6 +1189,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
mainBusinessLineId: [''],
incorpNumber: [''],
authorizedSignatory: [''],
// Client ids behind the names above, in the same order — what lets an edit rebuild the
// signatory rows with their client links and PAN/DIN state. No input is bound to it;
// syncSignatoryNames() keeps it in step with the chips.
authorizedSignatoryClientIds: [''],
additionalInfo: [''],
remarks: ['']
})
@@ -1242,6 +1313,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.mobileVerified = false;
// The rejection applied to the previous number, not this one. Leaving it set would
// keep Save & Next dead with a message about a number no longer in the field.
this.dipRejected = false;
this.dipStatus = null;
this.dipLookupLogId = null;
// Also reset create-time contact-OTP verification — an OTP verified against a
// previous mobile number no longer applies once the value is edited. Belt-and-braces
// alongside ContactOtpBlockComponent's own destination-change reset: this fires even
@@ -1256,6 +1332,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.alternateMobileVerified = false;
this.alternateDipRejected = false;
this.alternateDipStatus = null;
this.alternateDipLookupLogId = null;
});
// Reset create-time contact-OTP verification when email address changes — see the
@@ -1296,9 +1375,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
});
dialogRef.afterClosed().subscribe((result: any) => {
if (!result) return;
if (result.verified) {
this.mobileVerified = true;
}
// A flagged number the officer chose to proceed with counts as verified — the decision and
// its reason are already stored against the lookup, which is what the save gate checks.
this.mobileVerified = result.verified === true || result.decision === 'PROCEEDED';
this.dipRejected = result.decision === 'REJECTED';
this.dipStatus = result.status || null;
this.dipLookupLogId = result.lookupLogId || null;
this.dipTimestamp = result.timestamp || null;
});
@@ -1316,9 +1397,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
});
dialogRef.afterClosed().subscribe((result: any) => {
if (!result) return;
if (result.verified) {
this.alternateMobileVerified = true;
}
this.alternateMobileVerified = result.verified === true || result.decision === 'PROCEEDED';
this.alternateDipRejected = result.decision === 'REJECTED';
this.alternateDipStatus = result.status || null;
this.alternateDipLookupLogId = result.lookupLogId || null;
this.alternateDipTimestamp = result.timestamp || null;
});
@@ -1343,6 +1424,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.removeGuardian();
}
capitalizeControl(controlName: string) {
const control = this.createClientForm.get(controlName);
if (control && control.value) {
const originalValue = control.value;
const capitalized = toProperNameCase(originalValue);
if (originalValue !== capitalized) {
control.setValue(capitalized);
}
}
}
/**
* Verify client name against sanctions lists via dialog.
* Passes the client name to the dialog for real API screening.
@@ -1350,10 +1442,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
*/
verifySanctions() {
const name = this.currentNameSnapshot();
const dob = this.createClientForm.get('dateOfBirth')?.value;
const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, {
width: '550px',
data: { name }
width: '620px',
data: {
name,
// The dialog compares these against the listed particulars — a name-only hit cannot be
// judged without the applicant's own identifiers next to it.
dateOfBirth: dob ? new Date(dob).toLocaleDateString('en-IN') : '',
nationality: this.nationalityLabel()
}
});
dialogRef.afterClosed().subscribe((result: any) => {
if (!result) return;
@@ -1362,16 +1461,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// CLEAR — no match found
this.sanctionsVerified = true;
this.sanctionsBlocked = false;
this.sanctionsDisposition = null;
this.sanctionsNeedsRescreen = false;
this.verifiedName = name;
} else if (result.verified === false) {
// FLAGGED — match found, block account opening
// FLAGGED — blocked either way. A FALSE_POSITIVE is a claim awaiting the Principal
// Officer, not a clearance; only their approval lets this name through.
this.sanctionsVerified = false;
this.sanctionsBlocked = true;
this.sanctionsDisposition = result.disposition || null;
this.sanctionsNeedsRescreen = false;
this.verifiedName = '';
}
});
}
/** Human-readable nationality for the sanctions comparison panel. */
private nationalityLabel(): string {
return this.getSelectedNationality()?.label || '';
}
/** Concatenated current name from the form (handles individual + entity) */
private currentNameSnapshot(): string {
const firstname = this.createClientForm.get('firstname')?.value || '';
@@ -1411,9 +1520,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Only invalidate if a verification was previously recorded
if (!this.sanctionsVerified && !this.sanctionsBlocked) return;
if (this.currentNameSnapshot() !== this.verifiedName) {
// Editing the name forces a fresh screening, but must NOT silently clear an existing
// block: otherwise a flagged applicant is waved through by nudging one character. The
// block is only lifted by re-running the check and getting a CLEAR back from the API.
this.sanctionsVerified = false;
this.sanctionsBlocked = false;
this.screeningLogId = null;
if (this.sanctionsBlocked) {
this.sanctionsNeedsRescreen = true;
}
}
});
}
@@ -1467,6 +1581,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
target.mobileNo = data.mobileNo || null;
target.emailAddress = data.emailAddress || null;
target.officeName = data.officeName || null;
// Filled in only when the caller has not supplied them. A signatory restored from stored
// ids starts as a bare { id } and needs both to render its row; one picked from the search
// dropdown already carries them, and a guardian must keep whatever it was given.
target.displayName = target.displayName || data.displayName;
target.status = target.status || data.status;
target._loading = false;
},
error: () => {
@@ -1480,6 +1599,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
target.panVerified = kyc?.panVerified || false;
target.aadhaarMasked = kyc?.aadhaarNumberMasked || '-';
target.aadhaarVerified = kyc?.aadhaarVerified || false;
// Same reasoning as above: a DIN verified in an earlier session lives on the signatory's own
// KYC record, so re-reading it here is what stops the edit screen asking for it again.
target.dinNumber = target.dinNumber || kyc?.dinNumber || '';
target.dinVerified = target.dinVerified || kyc?.dinVerified || false;
},
error: () => {
target.panNumber = '-';
@@ -1678,9 +1801,49 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
});
}
/**
* Rebuilds the authorized-signatory rows when an existing entity client is opened for edit.
*
* Driven by the stored client ids rather than the stored names: the ids are what make each row a
* real client again, so its CIF, PAN and DIN verification can be re-read instead of the officer
* having to re-add and re-verify signatories that were already recorded. The saved names are used
* only as the label until the lookup returns, and as the fallback for a client that can no longer
* be fetched, so a row is never silently dropped.
*
* Clients created before the ids were stored have names but no ids; those still list, as
* name-only rows, rather than showing nothing at all.
*/
private restoreSignatories(storedIds: string | null | undefined, storedNames: string | null | undefined): void {
const ids = (storedIds || '')
.split(',')
.map((id: string) => id.trim())
.filter(Boolean);
const names = (storedNames || '')
.split(',')
.map((name: string) => name.trim())
.filter(Boolean);
if (!ids.length) {
this.signatories = names.map((displayName: string) => ({ displayName }));
return;
}
this.signatories = ids.map((id: string, index: number) => ({
id: Number(id),
displayName: names[index] || '',
_loading: true
}));
this.signatories.forEach((sig: any) => this.fetchClientFullDetails(sig.id, sig));
}
private syncSignatoryNames(): void {
const names = this.signatories.map((s) => s.displayName).join(', ');
this.createClientForm.get('clientNonPersonDetails')?.patchValue({ authorizedSignatory: names });
// Ids are written in the same order as the names, so the two columns line up on reload.
const clientIds = this.signatories.map((s) => s.id).join(',');
this.createClientForm.get('clientNonPersonDetails')?.patchValue({
authorizedSignatory: names,
authorizedSignatoryClientIds: clientIds
});
}
/** Watch MSME turnover & investment for auto-classification */
@@ -2164,6 +2327,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
delete generalDetails[key];
}
}
// Entity name is normalised here as well as on blur: a pasted/auto-filled value, or one the
// user never blurred out of before submitting, would otherwise reach Fineract exactly as typed.
if (generalDetails.fullname) {
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
}
if (generalDetails.submittedOnDate instanceof Date) {
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
}
@@ -2233,15 +2402,86 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
];
unsupportedFields.forEach((f) => delete generalDetails[f]);
// Format dateOfRegistration if present
if (generalDetails.clientNonPersonDetails?.dateOfRegistration instanceof Date) {
generalDetails.clientNonPersonDetails.dateOfRegistration = this.dateUtils.formatDate(
generalDetails.clientNonPersonDetails.dateOfRegistration,
dateFormat
);
return generalDetails;
}
/**
* Turnover ladder that decides an entity's risk when no configured risk rule matches it.
*
* A company's size is the one thing about it the branch always knows at onboarding, and the
* bigger the book the more exposure it carries — so the band moves up with annual turnover
* instead of every entity landing on a flat Medium regardless of whether it turns over ten lakh
* or a hundred crore. Thresholds follow the MSME-style split the business already thinks in
* (crores), and are only a fallback: a rule configured in m_client_risk_rule with
* minAnnualTurnover / maxAnnualTurnover still wins, which is where a bank sets its own numbers.
*
* Amounts are in rupees. `upTo: null` is the open-ended top band.
*/
private static readonly ENTITY_TURNOVER_RISK_BANDS: { upTo: number | null; category: number; label: string }[] = [
{ upTo: 10000000, category: 1, label: 'up to ₹1 crore' },
{ upTo: 100000000, category: 2, label: '₹1 crore to ₹10 crore' },
{ upTo: null, category: 3, label: 'above ₹10 crore' }
];
/** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */
riskCategoryLabel(category: number): string {
if (category === 3) {
return 'High Risk';
}
return category === 2 ? 'Medium Risk' : 'Low Risk';
}
/**
* Entity risk from annual turnover, for the fallback path only.
*
* AML is deliberately left CLEAR even in the top band. A configured High-risk rule defaults to
* FLAGGED because someone decided that case warrants it; turnover on its own is a size signal,
* not a suspicion, and auto-flagging every large company would put them all in the AML queue.
*/
private entityRiskFromTurnover(annualTurnover: number): {
category: number;
reason: string;
amlCheckStatus: string;
bannedEntityMatch: string;
willfulDefaulter: string;
ecgcSalFlag: string;
dormantFlag: string;
} {
const base = {
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
// Nothing captured yet — the entity default stands until a turnover is entered.
if (!annualTurnover || annualTurnover <= 0) {
return {
category: 2,
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
...base
};
}
return generalDetails;
const band =
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.find((b) => b.upTo == null || annualTurnover <= b.upTo) ??
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS[
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.length - 1
];
return {
category: band.category,
reason: `Annual turnover ₹${annualTurnover.toLocaleString('en-IN')} — ${band.label} (${this.riskCategoryLabel(
band.category
)})`,
...base
};
}
/** Live risk band for the entity turnover card in the General step. */
get entityTurnoverRisk(): { category: number; reason: string } {
return this.calculatedRisk;
}
get calculatedRisk(): {
@@ -2338,15 +2578,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Default Fallbacks
if (legalFormId !== LegalFormId.PERSON) {
return {
category: 2,
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
return this.entityRiskFromTurnover(annualTurnover);
}
return {
category: 1,
@@ -2358,6 +2590,28 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
dormantFlag: 'ACTIVE'
};
}
/**
* Automatically capitalizes the first letter of each word and lowercases the rest.
* e.g., "sri rAm" -> "Sri Ram"
*/
formatName(controlName: string): void {
const control = this.createClientForm.get(controlName);
if (!control || typeof control.value !== 'string') return;
const val = control.value;
const formatted = val
.split(' ')
.map((word: string) => {
if (!word) return '';
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
})
.join(' ');
if (formatted !== val) {
control.setValue(formatted, { emitEvent: false });
}
}
}
/**
@@ -1,8 +1,17 @@
/**
* 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, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatButtonModule } from '@angular/material/button';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ClientsService } from 'app/clients/clients.service';
@@ -31,13 +40,18 @@ import { ClientsService } from 'app/clients/clients.service';
<span class="warning-text">This may take a few seconds. Do not close or refresh this window.</span>
</div>
}
<!--
An unreachable DoT service means the number was NOT checked. Reporting that as a green
"verification completed successfully — number is CLEAN" claimed a check that never ran and
stored the client as verified, so the outage is now shown for what it is.
-->
@if (!verifying && serviceUnavailable) {
<div class="verify-result">
<fa-icon icon="check-circle" class="result-icon"></fa-icon>
<p class="result-msg">DIP & MNRL verification completed successfully.</p>
<div class="success-detail">
<fa-icon icon="shield-alt" class="success-detail-icon"></fa-icon>
<span>Mobile number is <strong>CLEAN</strong> — not found in the revocation list.</span>
<div class="verify-result unavailable">
<fa-icon icon="exclamation-triangle" class="result-icon unavailable-icon"></fa-icon>
<p class="result-msg unavailable-msg">Could not verify — DIP/MNRL service unavailable</p>
<div class="unavailable-detail">
This number has <strong>not</strong> been checked against the DoT Digital Intelligence Platform. Retry once
the service is reachable, or proceed with a recorded reason.
</div>
</div>
}
@@ -66,11 +80,63 @@ import { ClientsService } from 'app/clients/clients.service';
<p class="result-msg error-msg">{{ error }}</p>
</div>
}
<!--
A number turned away before is a standing signal. Without this the next officer screens
it as if it had no history and can wave through what a colleague already refused.
-->
@if (!verifying && priorRejection) {
<div class="prior-rejection">
<fa-icon icon="history" class="prior-icon"></fa-icon>
<div>
<strong>This number was rejected before.</strong>
<div class="prior-detail">
Rejected by user {{ priorRejection.decidedBy }} on {{ priorRejection.decidedOn | date: 'medium' }} —
status was {{ priorRejection.lookupStatus }}.
</div>
</div>
</div>
}
@if (!verifying && needsDecision && showReasonForm) {
<div class="reason-form">
<label class="reason-label">Why is this number acceptable despite the DIP/MNRL result?</label>
<textarea
class="reason-input"
rows="3"
[(ngModel)]="decisionReason"
placeholder="e.g. Number re-issued to the applicant on 02/2026; ownership confirmed against the operator's CAF and a fresh OTP."
></textarea>
<div class="reason-hint" [class.reason-error]="reasonTouched && !reasonValid">
Minimum 10 characters — this reason is kept on the lookup record.
</div>
@if (decisionError) {
<div class="reason-hint reason-error">{{ decisionError }}</div>
}
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (!verifying) {
@if (!verifying && !needsDecision) {
<button mat-raised-button color="primary" (click)="close()">OK</button>
}
<!--
A flagged number is a decision, not a notification: the officer must either reject it or
say in writing why it is acceptable. Previously a single OK dismissed the warning and the
number was onboarded with nothing recorded.
-->
@if (!verifying && needsDecision && !showReasonForm) {
<button mat-stroked-button color="warn" (click)="reject()" [disabled]="saving">Reject this Number</button>
<button mat-raised-button color="primary" (click)="startProceed()" [disabled]="saving">
Proceed with Reason
</button>
}
@if (!verifying && needsDecision && showReasonForm) {
<button mat-stroked-button (click)="cancelProceed()" [disabled]="saving">Back</button>
<button mat-raised-button color="primary" (click)="submitProceed()" [disabled]="saving">
{{ saving ? 'Saving…' : 'Save Decision & Proceed' }}
</button>
}
</mat-dialog-actions>
`,
styles: [
@@ -228,13 +294,100 @@ import { ClientsService } from 'app/clients/clients.service';
color: #d32f2f;
}
.unavailable-icon {
color: #b45309;
}
.unavailable-msg {
color: #b45309;
}
.unavailable-detail {
font-size: 13px;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
padding: 10px 12px;
line-height: 1.6;
margin-top: 8px;
}
.prior-rejection {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 13px;
color: #7f1d1d;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 6px;
padding: 10px 12px;
margin-top: 12px;
line-height: 1.5;
}
.prior-icon {
color: #dc2626;
font-size: 16px;
margin-top: 2px;
}
.prior-detail {
font-size: 12px;
color: #991b1b;
margin-top: 2px;
}
.reason-form {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 14px;
}
.reason-label {
font-size: 12px;
font-weight: 600;
color: #374151;
}
.reason-input {
width: 100%;
box-sizing: border-box;
font-family: inherit;
font-size: 13px;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 4px;
resize: vertical;
}
.reason-input:focus {
outline: none;
border-color: #1074b9;
}
.reason-hint {
font-size: 11px;
color: #9ca3af;
}
.reason-error {
color: #dc2626;
font-weight: 600;
}
mat-dialog-actions {
padding: 8px 24px 16px;
gap: 8px;
}
`
],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatProgressBarModule,
MatButtonModule,
@@ -255,6 +408,25 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
suspectedStatus = '';
suspectedReason = '';
suspectedReportedBy = '';
/** Set when this number was rejected on an earlier client — history the officer must see. */
priorRejection: any = null;
/** Officer decision capture for a flagged or unverifiable number. */
showReasonForm = false;
decisionReason = '';
reasonTouched = false;
saving = false;
decisionError = '';
/** A hit or an unverified number cannot simply be dismissed with OK. */
get needsDecision(): boolean {
return this.suspected || this.serviceUnavailable;
}
get reasonValid(): boolean {
return this.decisionReason.trim().length >= 10;
}
private subscription: any;
private intervalId: any;
private apiResponse: any = null;
@@ -305,6 +477,7 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
}
const res = this.apiResponse;
this.priorRejection = res?.priorRejection || null;
if (res?.data?.status === 'SERVICE_UNAVAILABLE') {
this.serviceUnavailable = true;
this.resultMessage = res.message || 'DIP/MNRL lookup service is unavailable. Number treated as CLEAN by default.';
@@ -329,11 +502,68 @@ export class DipVerifyDialogComponent implements OnInit, OnDestroy {
}
}
startProceed() {
this.showReasonForm = true;
this.reasonTouched = false;
this.decisionError = '';
}
cancelProceed() {
this.showReasonForm = false;
this.decisionReason = '';
this.decisionError = '';
}
submitProceed() {
this.reasonTouched = true;
if (!this.reasonValid) {
return;
}
this.recordDecision('PROCEEDED', this.decisionReason.trim(), true);
}
reject() {
this.recordDecision('REJECTED', 'Number rejected at onboarding following DIP/MNRL result.', false);
}
/**
* Persists the decision before closing. The number is only usable downstream once the backend
* has stored PROCEEDED — the client-create gate re-checks this, so closing without saving
* would simply fail at save time with no explanation.
*/
private recordDecision(decision: string, reason: string, verified: boolean) {
const logId = this.apiResponse?.lookupLogId;
if (!logId) {
this.decisionError = 'Lookup reference missing — re-run the check before recording a decision.';
return;
}
this.saving = true;
this.decisionError = '';
this.clientsService.recordDipDecision(logId, decision, reason).subscribe({
next: () => {
this.saving = false;
this.dialogRef.close({
verified,
decision,
lookupLogId: logId,
status: this.apiResponse?.data?.status || null,
timestamp: this.apiResponse?.timestamp || null
});
},
error: (err: any) => {
this.saving = false;
this.decisionError =
err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not save the decision.';
}
});
}
close() {
const verified = !this.suspected && !this.error;
const verified = !this.suspected && !this.error && !this.serviceUnavailable;
this.dialogRef.close({
verified,
lookupLogId: this.apiResponse?.lookupLogId || null,
status: this.apiResponse?.data?.status || null,
timestamp: this.apiResponse?.timestamp || null
});
}
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { MatDialogRef, MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatButtonModule } from '@angular/material/button';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ClientsService } from 'app/clients/clients.service';
@@ -118,10 +119,78 @@ import { ClientsService } from 'app/clients/clients.service';
</div>
}
</div>
<!--
The list matches on name alone, so a hit is a "stop and verify", not a verdict. The
officer has to be able to compare the listed particulars against the applicant's own
before deciding — showing only a red banner made that impossible.
-->
<div class="compare-header">
<fa-icon icon="search"></fa-icon>
Compare the applicant against the listed particulars
</div>
<div class="applicant-card">
<div class="applicant-label">Applicant</div>
<div class="applicant-values">
<span
><strong>{{ clientName }}</strong></span
>
<span>DOB: {{ applicantDob || '—' }}</span>
<span>Nationality: {{ applicantNationality || '—' }}</span>
</div>
</div>
<div class="match-list">
@for (m of matches; track m.id) {
<div class="match-item">
<div class="match-name">{{ m.fullName }}</div>
<div class="match-meta">
<span class="match-type">{{ m.entryType }}</span>
@if (m.unListType) {
<span class="match-list-type">{{ m.unListType }}</span>
}
@if (m.referenceNumber) {
<span class="match-ref">{{ m.referenceNumber }}</span>
}
</div>
<div class="match-aliases">
DOB: {{ m.dateOfBirth || '—' }} &middot; Nationality: {{ m.nationality || '—' }} &middot; Place of
birth: {{ m.placeOfBirth || '—' }}
</div>
@if (m.aliases?.length) {
<div class="match-aliases">Aliases: {{ m.aliases.join('; ') }}</div>
}
@if (m.listedOn) {
<div class="match-aliases">Listed on: {{ m.listedOn }}</div>
}
</div>
}
</div>
<div class="escalate-banner">
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
</div>
@if (showReasonForm) {
<div class="reason-form">
<label class="reason-label">
{{
pendingDisposition === 'FALSE_POSITIVE'
? 'Why is this NOT the same person? State the identifiers you compared.'
: 'Record the basis on which this match is confirmed.'
}}
</label>
<textarea
class="reason-input"
rows="3"
[(ngModel)]="dispositionReason"
placeholder="e.g. DOB 12/03/1988 vs listed 1965; nationality IN vs listed AF; passport number does not match."
></textarea>
<div class="reason-hint" [class.reason-error]="reasonTouched && !reasonValid">
Minimum 10 characters — this text is the compliance record.
</div>
@if (dispositionError) {
<div class="reason-hint reason-error">{{ dispositionError }}</div>
}
</div>
}
<p class="regulatory-note">{{ regulatoryBasis }}</p>
</div>
}
@@ -141,9 +210,26 @@ import { ClientsService } from 'app/clients/clients.service';
</button>
}
@if (!verifying && matchFound) {
<button mat-raised-button color="warn" (click)="close(false)" class="reject-btn">
<fa-icon icon="times"></fa-icon> Flag &amp; Escalate
</button>
@if (!showReasonForm) {
<button mat-stroked-button (click)="startDisposition('FALSE_POSITIVE')" class="approve-btn-secondary">
Not the Same Person
</button>
<button mat-raised-button color="warn" (click)="startDisposition('CONFIRMED_MATCH')" class="reject-btn">
<fa-icon icon="times"></fa-icon> Confirm Match &amp; Escalate
</button>
}
@if (showReasonForm) {
<button mat-stroked-button (click)="cancelDisposition()" [disabled]="savingDisposition">Back</button>
<button
mat-raised-button
[color]="pendingDisposition === 'FALSE_POSITIVE' ? 'primary' : 'warn'"
(click)="submitDisposition()"
[disabled]="savingDisposition"
class="reject-btn"
>
{{ savingDisposition ? 'Saving…' : 'Submit for Principal Officer Approval' }}
</button>
}
}
@if (!verifying && errorOccurred) {
<button mat-raised-button (click)="close(false)">Close</button>
@@ -174,8 +260,13 @@ import { ClientsService } from 'app/clients/clients.service';
mat-dialog-content {
padding: 16px 24px;
max-height: unset;
overflow: visible;
/*
The match card lists every hit with its aliases, so this content is now tall enough to
push the action row off-screen if the dialog is allowed to grow unbounded — which left
"Not the Same Person" unreachable. Scroll the body instead and keep the buttons pinned.
*/
max-height: 62vh;
overflow-y: auto;
}
.progress-section {
@@ -489,8 +580,6 @@ import { ClientsService } from 'app/clients/clients.service';
.match-list {
width: 100%;
max-height: unset;
overflow: visible;
margin: 8px 0;
}
@@ -531,6 +620,84 @@ import { ClientsService } from 'app/clients/clients.service';
margin-top: 4px;
}
.compare-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
color: #6b7280;
margin-top: 4px;
}
.applicant-card {
width: 100%;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-left: 4px solid #1074b9;
border-radius: 4px;
padding: 8px 12px;
}
.applicant-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #1074b9;
}
.applicant-values {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #374151;
margin-top: 2px;
}
.reason-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
}
.reason-label {
font-size: 12px;
font-weight: 600;
color: #374151;
}
.reason-input {
width: 100%;
box-sizing: border-box;
font-family: inherit;
font-size: 13px;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 4px;
resize: vertical;
}
.reason-input:focus {
outline: none;
border-color: #1074b9;
}
.reason-hint {
font-size: 11px;
color: #9ca3af;
}
.reason-error {
color: #dc2626;
font-weight: 600;
}
.block-msg {
font-size: 14px;
font-weight: 600;
@@ -545,11 +712,15 @@ import { ClientsService } from 'app/clients/clients.service';
mat-dialog-actions {
padding: 8px 24px 16px;
/* Sits below a scrolling body, so mark it off clearly as the fixed action row. */
border-top: 1px solid #e5e7eb;
gap: 8px;
}
`
],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatProgressBarModule,
MatButtonModule,
@@ -558,10 +729,13 @@ import { ClientsService } from 'app/clients/clients.service';
})
export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
private dialogRef = inject(MatDialogRef<SanctionsVerifyDialogComponent>);
private data: { name: string } = inject(MAT_DIALOG_DATA);
private data: { name: string; dateOfBirth?: string; nationality?: string } = inject(MAT_DIALOG_DATA);
private clientsService = inject(ClientsService);
clientName = '';
/** Applicant's own identifiers, shown beside the listed particulars for comparison. */
applicantDob = '';
applicantNationality = '';
verifying = true;
matchFound = false;
matchCount = 0;
@@ -571,6 +745,19 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
screeningLogId: number | null = null;
listDataAsOf: string | null = null;
/** Disposition capture — a hit must be closed out, not merely acknowledged. */
showReasonForm = false;
pendingDisposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH' | null = null;
dispositionReason = '';
reasonTouched = false;
savingDisposition = false;
dispositionError = '';
/** Mirrors the backend's minimum: an unexplained assessment is not a review. */
get reasonValid(): boolean {
return this.dispositionReason.trim().length >= 10;
}
/**
* Cites the direction actually in force. Chapter IX ("Requirements/obligations under International
* Agreements") is the clause that puts the UNSC lists in scope, and the 2025 Directions superseded
@@ -599,6 +786,8 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
ngOnInit() {
this.clientName = this.data?.name || '';
this.applicantDob = this.data?.dateOfBirth || '';
this.applicantNationality = this.data?.nationality || '';
this.dialogRef.disableClose = true;
// Start API call immediately
@@ -710,6 +899,57 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
clearInterval(this.progressTimer);
}
startDisposition(disposition: 'FALSE_POSITIVE' | 'CONFIRMED_MATCH') {
this.pendingDisposition = disposition;
this.showReasonForm = true;
this.reasonTouched = false;
this.dispositionError = '';
}
cancelDisposition() {
this.showReasonForm = false;
this.pendingDisposition = null;
this.dispositionReason = '';
this.dispositionError = '';
}
submitDisposition() {
this.reasonTouched = true;
if (!this.reasonValid || !this.pendingDisposition) {
return;
}
// Without a log id there is nothing to attach the assessment to — close as a plain block
// rather than silently discarding the officer's reasoning.
if (!this.screeningLogId) {
this.dispositionError = 'Screening reference missing — re-run the check before recording an outcome.';
return;
}
this.savingDisposition = true;
this.clientsService
.recordSanctionsDisposition(this.screeningLogId, this.pendingDisposition, this.dispositionReason.trim())
.subscribe({
next: () => {
this.savingDisposition = false;
// Both outcomes keep the client blocked. A false positive only takes effect once the
// Principal Officer approves it from the Sanctions Logs screen.
this.dialogRef.close({
verified: false,
screeningLogId: this.screeningLogId,
disposition: this.pendingDisposition
});
},
error: (err: any) => {
this.savingDisposition = false;
this.dispositionError =
err?.error?.defaultUserMessage ||
err?.error?.message ||
err?.message ||
'Could not save the review outcome.';
}
});
}
close(verified: boolean) {
this.dialogRef.close({ verified, screeningLogId: this.screeningLogId });
}
@@ -122,8 +122,41 @@
}
<div class="addr-proof-field">
<label class="addr-proof-label"><mat-icon>description</mat-icon> Document type</label>
<div class="addr-chip-wrap" [class.addr-chip-wrap--locked]="readOnly">
<div
class="addr-proof-label-row"
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px"
>
<label class="addr-proof-label" style="margin-bottom: 0"
><mat-icon>description</mat-icon> Document type</label
>
@if (isAddressProofLinkedToKyc && !readOnly) {
<div
class="addr-proof-linked-note"
style="
display: flex;
align-items: center;
gap: 8px;
color: #059669;
font-size: 0.85rem;
background: #ecfdf5;
padding: 4px 12px;
border-radius: 16px;
"
>
<mat-icon style="font-size: 16px; width: 16px; height: 16px">link</mat-icon>
Automatically linked from KYC step
<button
mat-icon-button
(click)="unlinkAddressProof()"
matTooltip="Provide a different address proof"
style="width: 24px; height: 24px; padding: 0; line-height: 24px"
>
<mat-icon style="font-size: 16px; color: #047857">edit</mat-icon>
</button>
</div>
}
</div>
<div class="addr-chip-wrap" [class.addr-chip-wrap--locked]="readOnly || isAddressProofLinkedToKyc">
@for (group of addressGroups; track group.label; let last = $last) {
<div class="addr-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
<div class="addr-chip-grid">
@@ -131,11 +164,11 @@
<span
class="addr-chip"
[class.addr-chip--sel]="isAddrProofSelected(p)"
[class.addr-chip--locked]="readOnly && !isAddrProofSelected(p)"
[attr.aria-disabled]="readOnly ? 'true' : null"
(click)="readOnly ? null : selectAddrProof(p)"
(keydown.enter)="readOnly ? null : selectAddrProof(p)"
[attr.tabindex]="readOnly ? -1 : 0"
[class.addr-chip--locked]="(readOnly || isAddressProofLinkedToKyc) && !isAddrProofSelected(p)"
[attr.aria-disabled]="readOnly || isAddressProofLinkedToKyc ? 'true' : null"
(click)="readOnly || isAddressProofLinkedToKyc ? null : selectAddrProof(p)"
(keydown.enter)="readOnly || isAddressProofLinkedToKyc ? null : selectAddrProof(p)"
[attr.tabindex]="readOnly || isAddressProofLinkedToKyc ? -1 : 0"
role="button"
>
<mat-icon class="addr-chip__icon">
@@ -167,14 +200,24 @@
<input
matInput
formControlName="documentKey"
[readonly]="readOnly || isAddressProofLinkedToKyc"
[placeholder]="selectedAddrProof.documentKeyPlaceholder || ''"
/>
</mat-form-field>
@if (selectedAddrProof.requiresDate) {
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Issue / bill date</mat-label>
<input matInput [matDatepicker]="addrIssueDatePicker" formControlName="issueDate" />
<mat-datepicker-toggle matSuffix [for]="addrIssueDatePicker"></mat-datepicker-toggle>
<input
matInput
[matDatepicker]="addrIssueDatePicker"
formControlName="issueDate"
[readonly]="readOnly || isAddressProofLinkedToKyc"
/>
<mat-datepicker-toggle
matSuffix
[for]="addrIssueDatePicker"
[disabled]="readOnly || isAddressProofLinkedToKyc"
></mat-datepicker-toggle>
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
</mat-form-field>
}
@@ -55,9 +55,22 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
* NPR/Birth Certificate/School ID) from the proof-of-address picker; a company's registered
* office address is evidenced by utility bills, GST/rent documents etc., not a person's ID. */
@Input() isNonIndividual = false;
/** KYC form data passed down from the parent wizard so we can pre-fill address proof */
@Input() kycData: any;
/** KYC document code for robust matching */
@Input() kycDocCode: string | undefined;
/** KYC document files passed down to avoid re-uploading */
@Input() kycDocFiles: { front: File | null; back: File | null } | null = null;
/** Combined preview string for KYC document */
@Input() kycDocMergedPreview: string | null = null;
/** Existing server documents from KYC step (used in Edit mode) */
@Input() existingKycDocSides: { front: any | null; back: any | null } | null = null;
/** Identifier ID for existing server documents */
@Input() existingKycIdentifierId: string | null = null;
activeTab = 0;
sameAsPermanent = false;
isAddressProofLinkedToKyc = false;
permanentForm: UntypedFormGroup = this.fb.group({
addressLine1: [
@@ -240,6 +253,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
/** Address type name → tab mapping for loading existing data */
private typeToTab: Record<string, string> = {};
private existingAddressProofCheckComplete = false;
ngOnInit(): void {
this.loadProofTypes();
if (this.clientId) {
@@ -247,6 +262,73 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
}
}
private findMatchingProof(targetName: string | undefined): any {
if (!targetName) return undefined;
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const n = cleanStr(targetName);
return this.addressProofs.find((p) => {
const pname = cleanStr(p.name);
return pname === n || n.includes(pname) || pname.includes(n);
});
}
private tryLinkKycData(): void {
if (!this.kycData || !this.kycData.kycDocumentName) return;
if (this.clientId && !this.existingAddressProofCheckComplete) return;
if (this.isAddressProofLinkedToKyc || !this.permanentForm.get('documentKey')?.value) {
let matchedProof = undefined;
if (this.kycDocCode) {
matchedProof = this.addressProofs.find((p) => p.code === this.kycDocCode);
}
if (!matchedProof) {
matchedProof = this.findMatchingProof(this.kycData.kycDocumentName);
}
if (matchedProof) {
this.isAddressProofLinkedToKyc = true;
this.selectAddrProof(matchedProof);
this.permanentForm.patchValue({
documentKey: this.kycData.kycDocumentNumber,
issueDate: this.kycData.kycDocIssueDate
});
this.currentForm.patchValue({
documentKey: this.kycData.kycDocumentNumber,
issueDate: this.kycData.kycDocIssueDate
});
// Sync uploaded images if they exist in KYC step
if (this.kycDocFiles && (this.kycDocFiles.front || this.kycDocFiles.back)) {
this.addrFiles = { front: this.kycDocFiles.front || null, back: this.kycDocFiles.back || null };
if (this.kycDocMergedPreview) {
this.addrMergedPreview = this.kycDocMergedPreview;
}
} else if (this.existingKycDocSides && (this.existingKycDocSides.front || this.existingKycDocSides.back)) {
this.existingAddressProofDocuments = [];
if (this.existingKycDocSides.front) this.existingAddressProofDocuments.push(this.existingKycDocSides.front);
if (this.existingKycDocSides.back) this.existingAddressProofDocuments.push(this.existingKycDocSides.back);
if (this.existingKycDocSides.front?.previewUrl) {
this.addrMergedPreview = this.existingKycDocSides.front.previewUrl;
} else if (this.existingKycDocSides.back?.previewUrl) {
this.addrMergedPreview = this.existingKycDocSides.back.previewUrl;
}
this.existingAddressProofIdentifierId = this.existingKycIdentifierId;
}
} else if (this.isAddressProofLinkedToKyc) {
// If it was linked but the KYC document changed to something not allowed for address (e.g. PAN for non-individuals), unlink.
this.unlinkAddressProof();
}
}
}
unlinkAddressProof(): void {
this.isAddressProofLinkedToKyc = false;
this.permanentForm.patchValue({ documentKey: '', issueDate: null });
this.currentForm.patchValue({ documentKey: '', issueDate: null });
this.addrFiles = { front: null, back: null };
this.addrMergedPreview = null;
}
/**
* Resets transient address-tab state for a brand-new client.
*
@@ -277,6 +359,7 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
this.existingAddressProofIdentifierId = null;
this.existingAddressProofDocuments = [];
this.addressLoaded = false;
this.existingAddressProofCheckComplete = false;
// Re-fetch rather than re-filter `addressProofs` in place — if the previous session's
// isMinor filtering already reduced it (e.g. down to Aadhaar/Passport only), filtering
// that reduced list again can never recover the items it already removed.
@@ -293,6 +376,7 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
);
this.addressGroups = groupProofsByCategory(this.addressProofs);
this.loadingProofs = false;
this.tryLinkKycData();
},
error: () => {
this.loadingProofs = false;
@@ -426,6 +510,17 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['kycData'] || changes['existingKycDocSides']) {
this.tryLinkKycData();
}
if (changes['existingKycIdentifierId']) {
if (
this.existingAddressProofIdentifierId &&
this.existingAddressProofIdentifierId === this.existingKycIdentifierId
) {
this.isAddressProofLinkedToKyc = true;
}
}
if (changes['readOnly']) {
const forms = [
this.permanentForm,
@@ -544,19 +639,25 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
}
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
next: (identifiers: any[]) => {
if (!identifiers || identifiers.length === 0) return;
const proofNames = new Set(this.addressProofs.map((p) => p.name?.toLowerCase()).filter(Boolean));
const identifier = identifiers.find((i: any) => {
const n = i?.documentType?.name?.toLowerCase();
return n && proofNames.has(n);
});
if (!identifier?.id) return;
if (!identifiers || identifiers.length === 0) {
this.existingAddressProofCheckComplete = true;
this.tryLinkKycData();
return;
}
const identifier = identifiers.find((i: any) => this.findMatchingProof(i?.documentType?.name));
if (!identifier?.id) {
this.existingAddressProofCheckComplete = true;
this.tryLinkKycData();
return;
}
this.existingAddressProofCheckComplete = true;
this.existingAddressProofIdentifierId = identifier.id.toString();
if (this.existingKycIdentifierId && this.existingKycIdentifierId === this.existingAddressProofIdentifierId) {
this.isAddressProofLinkedToKyc = true;
}
// Restore selected chip + form values
const matchedProof = this.addressProofs.find(
(p) => p.name?.toLowerCase() === identifier.documentType?.name?.toLowerCase()
);
const matchedProof = this.findMatchingProof(identifier.documentType?.name);
if (matchedProof) {
this.selectedAddrProof = matchedProof;
this.permanentForm.patchValue({
@@ -588,95 +588,23 @@
<!-- ══ NON-INDIVIDUAL KYC ══ -->
@if (isNonIndividual) {
<!-- Incorporation Date -->
<mat-form-field appearance="outline" class="form-field" (click)="incorpDatePicker.open()">
<mat-label>Incorporation Date</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
<input matInput [max]="today" [matDatepicker]="incorpDatePicker" formControlName="incorporationDate" />
<mat-datepicker-toggle matSuffix [for]="incorpDatePicker"></mat-datepicker-toggle>
<mat-datepicker #incorpDatePicker></mat-datepicker>
<!-- Date of Incorporation — captured before PAN because Enterprise PAN verification
matches the entity on name + date of incorporation as held in the PAN records. -->
<mat-form-field appearance="outline" class="form-field" (click)="incorporationDatePicker.open()">
<mat-label>Date of Incorporation</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>event_available</mat-icon></span>
<input
matInput
[max]="today"
[matDatepicker]="incorporationDatePicker"
formControlName="incorporationDate"
readonly
/>
<mat-datepicker-toggle matSuffix [for]="incorporationDatePicker"></mat-datepicker-toggle>
<mat-datepicker #incorporationDatePicker></mat-datepicker>
<mat-hint>As per Certificate of Incorporation / Registration</mat-hint>
</mat-form-field>
<!-- GSTIN (all non-individual) with Verify -->
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>GSTIN Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
<input
matInput
formControlName="gstinNumber"
placeholder="15-character GSTIN"
maxlength="15"
style="text-transform: uppercase"
(input)="
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
}
@if (gstinVerifyStatus === 'FAILED') {
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
}
</mat-form-field>
<div class="verify-action">
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('gstinNumber')?.value ||
kycForm.get('gstinNumber')?.value.length < 15 ||
gstinVerifying ||
!clientId
"
(click)="verifyGstin()"
>
@if (gstinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
}
</button>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
</div>
</div>
@if (gstinVerifyStatus === 'VERIFIED') {
<div class="pan-result-card full-width">
<div class="pan-result-header">
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
<span>GSTIN Verified Successfully</span>
</div>
<div class="pan-result-body">
@if (gstinVerifyName) {
<div class="pan-result-row">
<span class="pan-result-label">Trade Name / Legal Name</span>
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
</div>
}
@if (gstinVerifyState) {
<div class="pan-result-row">
<span class="pan-result-label">State</span>
<span class="pan-result-value">{{ gstinVerifyState }}</span>
</div>
}
<div class="pan-result-row">
<span class="pan-result-label">Verified On</span>
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
</div>
</div>
</div>
}
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
<!-- Proprietorship: Individual PAN (proprietor's personal PAN) -->
@@ -826,8 +754,88 @@
}
}
<!-- CIN (Companies & LLP only) -->
@if (constitutionMeta?.showCin) {
<!-- GSTIN (all non-individual) with Verify -->
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>GSTIN Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
<input
matInput
formControlName="gstinNumber"
placeholder="15-character GSTIN"
maxlength="15"
style="text-transform: uppercase"
(input)="
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
}
@if (gstinVerifyStatus === 'FAILED') {
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
}
</mat-form-field>
<div class="verify-action">
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('gstinNumber')?.value ||
kycForm.get('gstinNumber')?.value.length < 15 ||
gstinVerifying ||
!clientId
"
(click)="verifyGstin()"
>
@if (gstinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
}
</button>
}
@if (gstinVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
</div>
</div>
@if (gstinVerifyStatus === 'VERIFIED') {
<div class="pan-result-card full-width">
<div class="pan-result-header">
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
<span>GSTIN Verified Successfully</span>
</div>
<div class="pan-result-body">
@if (gstinVerifyName) {
<div class="pan-result-row">
<span class="pan-result-label">Trade Name / Legal Name</span>
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
</div>
}
@if (gstinVerifyState) {
<div class="pan-result-row">
<span class="pan-result-label">State</span>
<span class="pan-result-value">{{ gstinVerifyState }}</span>
</div>
}
<div class="pan-result-row">
<span class="pan-result-label">Verified On</span>
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
</div>
</div>
</div>
}
<!-- CIN -->
@if (isNonIndividual) {
<div class="form-field verify-field">
<mat-form-field appearance="outline" class="verify-input">
<mat-label>CIN (Corporate Identity Number)</mat-label>
@@ -850,7 +858,7 @@
}
</mat-form-field>
<div class="verify-action">
@if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') {
@if (cinVerifyStatus !== 'VERIFIED') {
<button
mat-raised-button
color="primary"
@@ -862,16 +870,31 @@
@if (cinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
<fa-icon icon="shield-alt"></fa-icon> Verify CIN
<fa-icon icon="shield-alt"></fa-icon>
{{ cinVerifyStatus === 'UNCONFIRMED' ? 'Retry' : 'Verify CIN' }}
}
</button>
}
@if (cinVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
@if (cinVerifyStatus === 'UNCONFIRMED') {
<span class="verify-chip unconfirmed"
><fa-icon icon="info-circle"></fa-icon> Saved — not confirmed</span
>
}
</div>
</div>
<!-- Accepted, but MCA returned no match. Stated plainly rather than as a failure: the
number is stored and the officer has nothing to correct. -->
@if (cinUnconfirmedMessage) {
<div class="cin-unconfirmed-note full-width">
<fa-icon icon="info-circle" class="cin-unconfirmed-note__icon"></fa-icon>
<span>{{ cinUnconfirmedMessage }}</span>
</div>
}
<!-- CIN verification progress -->
@if (cinVerifying) {
<div class="cin-progress full-width">
@@ -1429,15 +1452,19 @@
</div>
}
<!--
Overriding the risk category is the one thing that makes remarks mandatory here. A verified
record still gets the box — the officer needs somewhere to record a re-edit reason, and the
server asks for one if the save actually replaces data already on the record — but it is
offered, not demanded, so a record that auto-verified mid-wizard does not open in error.
-->
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>
{{
isKycVerified
? 'Reason for Re-edit'
: kycForm.get('allowRiskOverride')?.value
? 'Remarks (Required for Risk Override)'
: 'Remarks'
kycForm.get('allowRiskOverride')?.value
? 'Remarks (Required for Risk Override)'
: 'Reason for Re-edit (optional)'
}}
</mat-label>
<textarea
@@ -1445,18 +1472,13 @@
formControlName="remarks"
rows="2"
[placeholder]="
isKycVerified
? 'Mandatory — explain why KYC is being re-edited'
: kycForm.get('allowRiskOverride')?.value
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
: ''
kycForm.get('allowRiskOverride')?.value
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
: 'Optional — needed only if you change details already recorded on this verified KYC'
"
></textarea>
@if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) {
<mat-error>
Remarks are mandatory when
{{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }}
</mat-error>
@if (kycForm.get('allowRiskOverride')?.value && !kycForm.get('remarks')?.value?.trim()) {
<mat-error> Remarks are mandatory when overriding the risk category </mat-error>
}
</mat-form-field>
}
@@ -1672,77 +1694,92 @@
</div>
<!-- ═══ SECTION: Form 97/98 ═══ -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
Form 97 / 98
@if (!isNonIndividual) {
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
Form 97 / 98
@if (!kycForm.get('hasPan')?.value) {
<span class="kyc-badge required-badge">Required — No PAN</span>
}
</h4>
@if (!kycForm.get('hasPan')?.value) {
<span class="kyc-badge required-badge">Required — No PAN</span>
}
</h4>
@if (!kycForm.get('hasPan')?.value) {
<div class="kyc-info-hint warning-hint">
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
</div>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form60Type">
<mat-option value="FORM_60">Form 97</mat-option>
<mat-option value="FORM_61">Form 98</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
<mat-label>Declaration Date</mat-label>
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
<mat-datepicker #form60DatePicker></mat-datepicker>
</mat-form-field>
</div>
} @else {
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
}
</div>
<!-- ═══ SECTION: Form 121 ═══ -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="receipt" class="section-icon"></fa-icon>
Form 121
</h4>
<div class="form-grid">
<div class="form-field checkbox-field">
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
</div>
@if (kycForm.get('form15Applicable')?.value) {
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form15Type">
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Financial Year</mat-label>
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
</mat-form-field>
<div class="kyc-info-hint warning-hint">
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
</div>
<div class="form-grid">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form60Type">
<mat-option value="FORM_60">Form 97</mat-option>
<mat-option value="FORM_61">Form 98</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
<mat-label>Declaration Date</mat-label>
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
<mat-datepicker #form60DatePicker></mat-datepicker>
</mat-form-field>
</div>
} @else {
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
}
</div>
</div>
}
<!-- ═══ SECTION: Photograph & Signature ═══ -->
<!-- ═══ SECTION: Form 121 ═══ -->
@if (!isNonIndividual) {
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="receipt" class="section-icon"></fa-icon>
Form 121
</h4>
<div class="form-grid">
<div class="form-field checkbox-field">
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
</div>
@if (kycForm.get('form15Applicable')?.value) {
<mat-form-field appearance="outline" class="form-field">
<mat-label>Form Type</mat-label>
<mat-select formControlName="form15Type">
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Financial Year</mat-label>
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
</mat-form-field>
}
</div>
</div>
}
<!-- ═══ SECTION: Photograph & Signature ═══
An entity gets the photograph box too — a shopfront, signboard or logo is what staff
recognise the client by in the ledger, and the client view has always had a photo slot in
its header with no way to fill it from the stepper. The specimen signature stays individual-
only: an entity signs through its authorised signatories, whose signatures are captured with
their own KYC rather than as one image for the company. Optional here, so a company can
still be onboarded before anyone has a usable photograph of the premises. -->
<div class="section-block">
<h4 class="section-title">
<fa-icon icon="camera" class="section-icon"></fa-icon>
Photograph &amp; Signature
<span class="section-required-tag">Required</span>
{{ isNonIndividual ? 'Entity Photograph' : 'Photograph & Signature' }}
@if (isNonIndividual) {
<span class="section-optional-tag">Optional</span>
} @else {
<span class="section-required-tag">Required</span>
}
</h4>
<div class="photo-sig-grid">
<div class="photo-sig-grid" [class.photo-sig-grid--single]="isNonIndividual">
<div
class="upload-box"
[class.upload-box--has-preview]="photographPreview"
[class.upload-box--missing]="!photographPreview && photoSigTouched"
[class.upload-box--missing]="!photographPreview && photoSigTouched && !isNonIndividual"
>
@if (photographPreview) {
<div class="upload-preview">
@@ -1758,12 +1795,16 @@
</div>
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div>
<div class="upload-box__icon"><fa-icon [icon]="isNonIndividual ? 'building' : 'user'"></fa-icon></div>
<span class="upload-box__label">
Client Photograph
<span class="upload-box__req">*</span>
{{ isNonIndividual ? 'Entity Photograph / Logo' : 'Client Photograph' }}
@if (!isNonIndividual) {
<span class="upload-box__req">*</span>
}
</span>
<span class="upload-box__hint">
{{ isNonIndividual ? 'Office premises, signboard or logo — JPG / PNG, max 2MB' : 'JPG / PNG, max 2MB' }}
</span>
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
<div class="upload-box__btn-row">
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
@@ -1773,50 +1814,52 @@
<fa-icon icon="video"></fa-icon> Use Webcam
</button>
</div>
@if (photoSigTouched) {
@if (photoSigTouched && !isNonIndividual) {
<span class="upload-box__error">Photograph is required</span>
}
}
</div>
<div
class="upload-box"
[class.upload-box--has-preview]="signaturePreview"
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
>
@if (signaturePreview) {
<div class="upload-preview">
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
<div class="upload-preview__actions">
<label class="upload-preview__btn recrop">
@if (!isNonIndividual) {
<div
class="upload-box"
[class.upload-box--has-preview]="signaturePreview"
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
>
@if (signaturePreview) {
<div class="upload-preview">
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
<div class="upload-preview__actions">
<label class="upload-preview__btn recrop">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Re-crop
</label>
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
<fa-icon icon="trash"></fa-icon> Remove
</button>
</div>
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
<span class="upload-box__label">
Specimen Signature
<span class="upload-box__req">*</span>
</span>
<span class="upload-box__hint">Scanned image, max 1MB</span>
<div class="upload-box__btn-row">
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Re-crop
<fa-icon icon="crop"></fa-icon> Choose &amp; Crop
</label>
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
<fa-icon icon="trash"></fa-icon> Remove
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
<fa-icon icon="pen"></fa-icon> Draw Signature
</button>
</div>
</div>
} @else {
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
<span class="upload-box__label">
Specimen Signature
<span class="upload-box__req">*</span>
</span>
<span class="upload-box__hint">Scanned image, max 1MB</span>
<div class="upload-box__btn-row">
<label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Choose &amp; Crop
</label>
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
<fa-icon icon="pen"></fa-icon> Draw Signature
</button>
</div>
@if (photoSigTouched) {
<span class="upload-box__error">Signature is required</span>
@if (photoSigTouched) {
<span class="upload-box__error">Signature is required</span>
}
}
}
</div>
</div>
}
</div>
</div>
@@ -36,6 +36,20 @@
letter-spacing: 0.4px;
text-transform: uppercase;
}
/* Same chip, muted — says "you may" where the red one says "you must". */
.section-optional-tag {
margin-left: 6px;
padding: 2px 9px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
}
.kyc-badge {
@@ -312,6 +326,34 @@
color: #b45309;
border: 1px solid #fcd34d;
}
// Recorded but unconfirmed by the registry. Neutral blue rather than green or red: nothing failed
// and nothing passed, so it must not read as either.
&.unconfirmed {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
}
// ── CIN saved but not confirmed by MCA ─────────────────────────────────────
.cin-unconfirmed-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin: -4px 0 12px;
padding: 10px 12px;
border-radius: 8px;
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #1e40af;
font-size: 12.5px;
line-height: 1.45;
}
.cin-unconfirmed-note__icon {
margin-top: 1px;
flex-shrink: 0;
}
// ── PAN declaration summary ────────────────────────────────────────────────
@@ -1225,6 +1267,12 @@
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
/* Entity clients get the photograph box only, so it keeps a card's width instead of
stretching across the whole step where the signature box used to sit beside it. */
&--single {
grid-template-columns: minmax(0, 420px);
}
}
.upload-box {
@@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { ENTITY_KYC_DOCUMENT_TYPES, KycDocumentType, withEntityKycDocuments } from '../../shared/entity-kyc-documents';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
@@ -110,6 +111,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
} | null = null;
today = new Date();
/**
* ClientKycType on the backend: 1 = INDIVIDUAL, 2 = NON_INDIVIDUAL. Every payload must carry the
* value matching the client's legal form — the backend rejects the entity-only fields
* (enterprisePan / gstinNumber / cinNumber / incorporationDate) on a KYC record typed INDIVIDUAL.
*/
get kycTypeValue(): number {
return this.isNonIndividual ? 2 : 1;
}
/**
* Risk category as it will actually be saved.
*
* Read via getRawValue() because the riskCategory control is disabled whenever the override
* checkbox is off — which is the normal case — and `kycForm.value` omits disabled controls.
* Reading `.value` there yields undefined and quietly falls back to 1 (Low), so a verification
* would stamp Low onto the record and the next full save would change it to the auto-calculated
* category, showing up as an edit of a verified record that the officer never made.
*/
private get currentRiskCategory(): number {
return this.kycForm.getRawValue().riskCategory || 1;
}
kycId: string = '';
/** Whether existing KYC data has been loaded (edit mode) */
kycLoaded = false;
@@ -121,12 +143,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
/** KYC Document Types — populated from API template, falls back to defaults */
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
kycDocumentTypes: KycDocumentType[] = [];
/** Chip-group state for KYC document picker (mirrors identity proof in identifier dialog) */
kycDocProofs: ProofType[] = [];
kycDocGroups: { label: string; items: ProofType[] }[] = [];
selectedKycDoc: ProofType | null = null;
/**
* Constitution whose document list is currently loaded. Distinguishes the first arrival of a
* constitution from the officer switching to a different one — see updateDocTypesForConstitution.
*/
private appliedConstitutionValue: string | null = null;
/** Front/back files for the chosen KYC document */
kycDocFiles: { front: File | null; back: File | null } = { front: null, back: null };
/**
@@ -185,6 +212,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Photo + signature are mandatory before leaving the KYC tab */
get photoSigComplete(): boolean {
if (this.isNonIndividual) {
return true;
}
return this.hasPhoto && this.hasSignature;
}
@@ -225,7 +255,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const needsCin = !!this.kycForm?.get('cinNumber')?.value;
const panOk = this.entPanVerifyStatus === 'VERIFIED';
const gstinOk = !needsGstin || this.gstinVerifyStatus === 'VERIFIED';
const cinOk = !needsCin || this.cinVerifyStatus === 'VERIFIED';
// A CIN the provider cannot confirm still counts as done. MCA lookups return no match for
// CINs that are perfectly valid, and PAN and GSTIN already establish the entity's identity —
// holding the whole KYC open on a check that cannot pass would strand the client instead.
const cinOk = !needsCin || this.cinVerifyStatus === 'VERIFIED' || this.cinVerifyStatus === 'UNCONFIRMED';
return panOk && gstinOk && cinOk;
} else {
const needsPan = !this.isMinor && this.kycForm?.get('hasPan')?.value;
@@ -250,18 +283,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
];
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
* Annexure I) — shown for non-individual clients before a specific constitution type is
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
private nonIndividualDefaultDocTypes = [
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
];
/** Constitution-based KYC document types for non-individual clients */
/** Constitution-based KYC document types for non-individual clients. Shown in addition to
* the RBI entity documents (ENTITY_KYC_DOCUMENT_TYPES), never instead of them — see
* withEntityKycDocuments. */
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
HUF: [
{ name: 'PAN Card of HUF', mandatory: true },
@@ -513,7 +537,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
gstinVerifyState = '';
/** CIN verification (dummy) */
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
/**
* UNCONFIRMED means the CIN is saved and correctly formatted, but MCA (via LincPay) would not
* confirm it. Kept distinct from VERIFIED, which is only ever set by a positive answer from the
* provider, and from FAILED, which is reserved for the provider being unreachable.
*/
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'UNCONFIRMED' | 'FAILED' = 'NONE';
/** Explanation shown beside the CIN when the provider could not confirm it. */
cinUnconfirmedMessage = '';
cinVerifying = false;
cinVerifiedOn = '';
cinCompanyName = '';
@@ -648,9 +679,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
* ever applies to individuals. */
private getDefaultDocTypes(): { name: string; code: string }[] {
return this.isNonIndividual
? this.nonIndividualDefaultDocTypes
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
return this.isNonIndividual ? ENTITY_KYC_DOCUMENT_TYPES : this.filterDocTypesForMinor(this.defaultDocumentTypes);
}
ngOnInit(): void {
@@ -673,9 +702,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
if (this.calculatedRisk) {
riskControl?.setValue(this.calculatedRisk.category);
}
if (!this.isKycVerified) {
this.kycForm.get('remarks')?.clearValidators();
}
// Overriding the risk category is the only thing this form demands a justification for.
// A verified record does not, by itself, need one — see loadExistingKycIfNeeded.
this.kycForm.get('remarks')?.clearValidators();
}
this.kycForm.get('remarks')?.updateValueAndValidity();
});
@@ -797,6 +826,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.gstinVerifiedOn = '';
this.gstinVerifyState = '';
this.cinVerifyStatus = 'NONE';
this.cinUnconfirmedMessage = '';
this.cinVerifying = false;
this.cinVerifiedOn = '';
this.cinCompanyName = '';
@@ -898,14 +928,26 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
private updateDocTypesForConstitution(): void {
const constitutionValue = this.constitutionMeta?.value;
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map((doc) => ({
name: doc.name,
code: doc.name,
mandatory: doc.mandatory
}));
// Reset selected document type since options changed
this.kycForm.get('kycDocumentName')?.setValue('');
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
// The RBI entity documents stay at the head of the list — picking a constitution adds that
// constitution's own requirements, it does not swap out the set the officer was just shown.
this.kycDocumentTypes = withEntityKycDocuments(
this.constitutionDocTypes[constitutionValue],
this.constitutionMeta?.label || null
);
// Clearing the chosen document type only makes sense when the officer switches constitution,
// which genuinely invalidates it. It must NOT happen the first time a constitution arrives:
// on edit that happens after the saved KYC has been restored, so wiping the value here would
// discard the restored document type — and with it the upload slots that display the file
// already held against the client, since they render only once a type is selected.
const constitutionChanged =
this.appliedConstitutionValue !== null && this.appliedConstitutionValue !== constitutionValue;
this.appliedConstitutionValue = constitutionValue;
if (constitutionChanged) {
this.kycForm.get('kycDocumentName')?.setValue('');
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
}
// Re-resolves the selected chip against the new list from whatever name is on the form.
this.rebuildKycDocPicker();
}
}
@@ -913,18 +955,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Rebuild chip-group picker model from kycDocumentTypes */
private rebuildKycDocPicker(): void {
this.kycDocProofs = (this.kycDocumentTypes || []).map((d, i) =>
normalizeProofType({ id: d.code || d.name || i, name: d.name, code: d.code, description: d.code || d.name })
normalizeProofType({
id: d.code || d.name || i,
name: d.name,
code: d.code,
description: d.code || d.name,
// Constitution-specific documents carry no code PROOF_META knows, so their group label
// has to be handed over explicitly or they all collect under "Other".
category: d.category
})
);
this.kycDocGroups = groupProofsByCategory(this.kycDocProofs);
// Sync selected proof with current form value (e.g. on edit-mode prefill)
const currentName = this.kycForm?.get('kycDocumentName')?.value;
if (currentName) {
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const targetName = cleanStr(currentName);
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
} else {
this.selectedKycDoc = null;
this.selectedKycDoc = currentName ? this.findKycDocProofByName(currentName) : null;
}
/**
* Resolves a stored document-type name back to a chip in the picker.
*
* A tolerant match is required because the two names are not written by the same thing: an
* identifier row carries the CodeValue label the backend holds ("Certificate of Incorporation")
* while the picker shows the fuller, constitution-specific label ("Certificate of Incorporation /
* Registration Certificate"), so one is usually contained in the other rather than equal to it.
* An exact comparison leaves the chip unselected on edit, which in turn hides the file-upload
* slots and therefore the document the officer already uploaded.
*/
private findKycDocProofByName(name: string | null | undefined): ProofType | null {
const clean = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const target = clean(name || '');
if (!target) {
return null;
}
const proofs = this.kycDocProofs || [];
const exact = proofs.find((p) => clean(p.name) === target);
if (exact) {
return exact;
}
// Closest containment wins, so a short label cannot claim a chip that a nearer one also matches.
const contained = proofs
.filter((p) => {
const candidate = clean(p.name);
return candidate.includes(target) || target.includes(candidate);
})
.sort((a, b) => Math.abs(clean(a.name).length - target.length) - Math.abs(clean(b.name).length - target.length));
return contained[0] || null;
}
selectKycDoc(proof: ProofType): void {
@@ -1102,19 +1177,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
// Set remarks as required if KYC is verified
if (this.isKycVerified) {
this.kycForm.get('remarks')?.setValidators(Validators.required);
this.kycForm.get('remarks')?.updateValueAndValidity();
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
}
// Remarks are NOT forced just because the record is verified. The server asks for them
// only when a save actually overwrites data the verified record already held
// (ClientKycWritePlatformServiceJpaRepositoryImpl.updateKyc), and it says so in the error
// it returns. Demanding them up front made every non-individual client open with a red
// "Reason for Re-edit" the moment PAN and GSTIN auto-verified — before the officer had
// changed anything, and while they still had the KYC document fields left to fill in.
this.persistedIsException = data.isException === true;
this.persistedExceptionReason = data.exceptionReason || null;
// Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) {
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const targetName = cleanStr(data.kycDocumentName);
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
}
// Set verification statuses from existing data
if (data.panVerified) {
@@ -1175,6 +1248,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
if (data.cinVerified) {
this.cinVerifyStatus = 'VERIFIED';
// The verified card shows this; without it a reopened client renders the panel blank.
this.cinCompanyName = data.cinCompanyName || 'Verified via MCA';
const rawCinDate = data.cinVerifiedOn;
if (Array.isArray(rawCinDate) && rawCinDate.length >= 3) {
this.cinVerifiedOn = `${rawCinDate[2]?.toString().padStart(2, '0')}/${rawCinDate[1]?.toString().padStart(2, '0')}/${rawCinDate[0]}`;
@@ -1243,11 +1318,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Backfill document type + number on the form in case the KYC record didn't have
// them (older clients were created via the identifier flow only).
if (!this.kycForm.get('kycDocumentName')?.value && identifier?.documentType?.name) {
const proof = this.findKycDocProofByName(identifier.documentType.name);
this.selectedKycDoc = proof;
// Store the picker's own label rather than the identifier's when one matched, so the
// selected chip and the form value agree and a later save round-trips the same name.
this.kycForm.patchValue({
kycDocumentName: identifier.documentType.name,
kycDocumentName: proof?.name || identifier.documentType.name,
kycDocumentNumber: identifier.documentKey || ''
});
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === identifier.documentType.name) || null;
}
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
@@ -1453,13 +1531,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycTemplate = template;
this.kycTemplateLoaded = true;
if (template?.requiredDocuments?.length > 0) {
this.kycDocumentTypes = this.filterDocTypesForMinor(
template.requiredDocuments.map((doc: any) => ({
name: doc.name,
code: doc.code || doc.name,
mandatory: doc.mandatory ?? false
}))
);
const templateDocs = template.requiredDocuments.map((doc: any) => ({
name: doc.name,
code: doc.code || doc.name,
mandatory: doc.mandatory ?? false
}));
// The backend template names the same entity documents differently ("Certificate of
// Incorporation" / "COI", "PAN Card of Company" / "PAN"), so for an entity it is merged
// onto the RBI list rather than replacing it — otherwise reopening a non-individual
// client for edit shows a different set of chips than the one used to create it.
this.kycDocumentTypes = this.isNonIndividual
? withEntityKycDocuments(templateDocs, template?.clientTypeName || this.constitutionMeta?.label || null)
: this.filterDocTypesForMinor(templateDocs);
// Update kycDocumentName validator based on mandatory docs
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
if (hasMandatory) {
@@ -1472,11 +1555,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
error: () => {
// Fallback to defaults if template API unavailable
this.kycTemplateLoaded = true;
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}));
// An entity keeps whatever constitution-specific documents it already has alongside the
// RBI list; a failed template call must not quietly shorten the picker.
const constitutionDocs = this.isNonIndividual
? this.constitutionDocTypes[this.constitutionMeta?.value] || []
: [];
this.kycDocumentTypes = this.isNonIndividual
? withEntityKycDocuments(constitutionDocs, this.constitutionMeta?.label || null)
: this.getDefaultDocTypes().map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}));
this.rebuildKycDocPicker();
}
});
@@ -1485,17 +1575,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Save or update KYC, then execute callback */
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
if (this.kycId) {
const { kycType, ...updatePayload } = payload;
// riskCategory is dropped alongside kycType on an update. The verify payloads only carry it to
// satisfy the initiate validator, which requires it when the record is first created; once the
// row exists, re-asserting it on every PAN/GSTIN/CIN check means a verification silently
// rewrites the risk category. On a record that has auto-verified that reads as an edit of
// verified data and is refused, blocking the verification itself. The officer's risk choice is
// persisted by the KYC step's own save, which does not come through here.
const { kycType, riskCategory, ...updatePayload } = payload;
// If KYC is VERIFIED, remarks are mandatory — use re-edit command
// If KYC is VERIFIED, changing data already on the record needs remarks — use re-edit command.
// Whether this particular save actually replaces stored data is something only the server can
// tell (it holds the current values), so it decides: a non-individual record auto-verifies as
// soon as PAN and GSTIN pass, and the officer must still be able to fill in the KYC document
// fields that are empty at that point. Remarks are forwarded when present and the server
// rejects the save if they were required.
if (this.isKycVerified) {
const remarks = this.kycForm.get('remarks')?.value?.trim();
if (!remarks) {
onError('Remarks are mandatory when updating a verified KYC record.');
this.kycForm.get('remarks')?.markAsTouched();
return;
if (remarks) {
updatePayload.remarks = remarks;
}
updatePayload.remarks = remarks;
this.clientsService.reEditClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
next: () => {
// Status transitions to IN_PROGRESS, PAN verification is invalidated
@@ -1571,12 +1669,30 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const panPayload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
panNumber: pan,
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
};
// A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is
// still legalForm=ENTITY to the backend, which matches an entity on name + date of
// incorporation. Persist that date with this save so verifyPAN() can read it off the record.
// Never sent for individuals — the backend rejects entity-only fields on an individual KYC.
if (this.isNonIndividual) {
const incorpDate = this.kycForm.value.incorporationDate;
const dateFormat = this.settingsService.dateFormat;
panPayload.dateFormat = dateFormat;
panPayload.incorporationDate =
incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate || null;
if (!panPayload.incorporationDate) {
this.panVerifying = false;
this.panVerifyError = 'Enter the Date of Incorporation before verifying the PAN.';
this.kycForm.get('incorporationDate')?.markAsTouched();
return;
}
}
this.saveKycAndDo(
panPayload,
() => {
@@ -1834,16 +1950,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const dateFormat = this.settingsService.dateFormat;
const incorpDate = this.kycForm.value.incorporationDate;
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification.
// For an entity the provider's "DOB" is the date of incorporation, so it must be persisted on the
// KYC record by this same save — verifyPAN() below reads it back off the record, not off the form.
const payload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
panNumber: pan, // Company PAN → panNumber
enterprisePan: pan,
incorporationDate: incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate,
dateFormat,
aadhaarNumberMasked: 'XXXXXXXX0000' // Placeholder — not applicable for entities
};
if (!payload.incorporationDate) {
this.entPanVerifying = false;
this.showPanErrorAlert('Enter the Date of Incorporation before verifying the Enterprise PAN.');
this.kycForm.get('incorporationDate')?.markAsTouched();
return;
}
this.saveKycAndDo(
payload,
() => {
@@ -1862,6 +1989,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
rawDate ||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
// Seeding status — mirrors verifyPan(), so the value the provider returned is held
// here too and the next save round-trips it instead of leaving this screen blank.
if (res.changes?.panAadhaarSeedingStatus) {
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
}
} else {
this.entPanVerifyStatus = 'FAILED';
this.showPanErrorAlert(
@@ -1907,8 +2039,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const payload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
gstinNumber: gstin,
dateFormat,
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
@@ -1976,7 +2108,105 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return;
}
if (!this.clientId) {
this.showPanErrorAlert('Client must be created first. Complete General tab and click Next.');
return;
}
this.cinVerifying = true;
this.startCinProgress();
// The CIN has to be on the record before the command runs: the backend reads it off the KYC row
// (and rejects with "CIN number is not set on this KYC record" otherwise) rather than taking it
// from the request. Same save-then-verify order as PAN and GSTIN.
const payload: any = {
locale: 'en',
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
cinNumber: cin,
dateFormat: this.settingsService.dateFormat,
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
aadhaarNumberMasked: 'XXXXXXXX0000'
};
this.saveKycAndDo(
payload,
() => {
this.clientsService.verifyCIN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.stopCinProgress();
const changes = res?.changes || res;
if (changes?.cinVerified === true) {
this.cinVerifyStatus = 'VERIFIED';
// The label distinguishes a CIN the registry actually matched from one accepted on
// its structure alone, so the card never claims an MCA confirmation that never came.
this.cinCompanyName =
changes?.cinCompanyName ||
(changes?.cinConfirmedByRegistry === false ? 'Format validated' : 'Verified via MCA');
this.cinUnconfirmedMessage =
changes?.cinConfirmedByRegistry === false ? changes?.cinVerificationMessage || '' : '';
const rawDate = changes?.cinVerifiedOn;
if (Array.isArray(rawDate)) {
this.cinVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else {
const now = new Date();
this.cinVerifiedOn =
rawDate ||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
} else {
this.markCinUnconfirmed(changes?.cinVerificationMessage);
}
},
error: (err: any) => {
this.stopCinProgress();
const message =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'CIN verification failed.';
// A 503 means the provider could not be reached, which is worth interrupting the officer
// for — it is likely to succeed on a retry. Anything else is the provider having
// answered without confirming the company, which is not something a retry fixes and not
// something the officer can correct, so it is reported in place instead of as an alert.
if (err?.status === 503) {
this.cinVerifyStatus = 'FAILED';
this.showPanErrorAlert(message);
} else {
this.markCinUnconfirmed(message);
}
}
});
},
(msg) => {
this.stopCinProgress();
this.showPanErrorAlert(msg);
}
);
}
/**
* The CIN is stored and well-formed, but MCA did not confirm it.
*
* Reported in place rather than as an error dialog because there is nothing for the officer to
* correct: the number is already saved and the provider simply has no record to match it against.
* Deliberately NOT recorded as verified — `cinVerified` stays false on the KYC record, so the
* distinction between a confirmed and an unconfirmed CIN survives into the client's history.
*/
private markCinUnconfirmed(message?: string): void {
this.cinVerifyStatus = 'UNCONFIRMED';
this.cinUnconfirmedMessage =
message || 'MCA could not confirm this CIN. The number has been saved against the client.';
}
/**
* Drives the stepped MCA progress display while the verification is in flight. Deliberately stops
* short of the last step and of 100%: the real outcome comes from the server, and letting the bar
* finish on its own is what used to make an unverified CIN look verified.
*/
private startCinProgress(): void {
if (this.cinProgressTimer) {
clearInterval(this.cinProgressTimer);
}
this.cinProgressValue = 0;
this.cinCurrentStep = 0;
@@ -2000,22 +2230,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
break;
}
}
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 90);
if (elapsed >= totalDuration) {
clearInterval(this.cinProgressTimer);
this.cinCurrentStep = stepDurations.length;
setTimeout(() => {
this.cinVerifying = false;
this.cinVerifyStatus = 'VERIFIED';
this.cinCompanyName = 'Verified via MCA';
const now = new Date();
this.cinVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}, 300);
this.cinProgressTimer = null;
}
}, interval);
}
private stopCinProgress(): void {
if (this.cinProgressTimer) {
clearInterval(this.cinProgressTimer);
this.cinProgressTimer = null;
}
this.cinProgressValue = 100;
this.cinVerifying = false;
}
/** Send Aadhaar OTP — saves Aadhaar-only KYC data, then sends OTP */
sendAadhaarOtp() {
if (!this.clientId) {
@@ -2041,8 +2272,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
const aadhaarPayload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
kycType: this.kycTypeValue,
riskCategory: this.currentRiskCategory,
aadhaarNumberMasked: aadhaarMasked
};
const panVal = this.kycForm.value.panNumber;
@@ -2203,7 +2434,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
getRenewalYears(): number {
switch (this.kycForm.value.riskCategory) {
switch (this.currentRiskCategory) {
case 1:
return 10;
case 2:
@@ -2387,12 +2618,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return {
locale: 'en',
dateFormat,
kycType: 1,
kycType: this.kycTypeValue,
riskCategory: v.riskCategory || 1,
// For non-individual: Company PAN is used as panNumber for verification
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
// Only ever sent when this screen actually holds a value. The seeding status is derived by
// the provider during PAN verification and written straight to the record, so sending an
// empty one back would blank a field the server filled in — which reads as an officer
// editing a verified record rather than the no-op it is.
...(this.panAadhaarSeedingStatus ? { panAadhaarSeedingStatus: this.panAadhaarSeedingStatus } : {}),
// PAN declaration — stored so the waived exception can be justified later, and so a reload
// does not silently drop the paperwork that is holding the exception off.
panDeclarationTaken: this.panDeclarationTaken,
@@ -2470,6 +2705,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
v.gstinNumber ||
v.enterprisePan ||
v.cinNumber ||
v.incorporationDate ||
v.kycDocumentName ||
v.form15Applicable ||
v.isException
@@ -442,18 +442,18 @@
<span class="preview-value mono">{{ kycData.enterprisePan }}</span>
</div>
}
@if (kycData.incorporationDate) {
<div class="preview-field">
<span class="preview-label">Date of Incorporation</span>
<span class="preview-value">{{ kycData.incorporationDate }}</span>
</div>
}
@if (kycData.cinNumber) {
<div class="preview-field">
<span class="preview-label">CIN (Corporate Identity Number)</span>
<span class="preview-value mono">{{ kycData.cinNumber }}</span>
</div>
}
@if (kycData.incorporationDate) {
<div class="preview-field">
<span class="preview-label">Incorporation Date</span>
<span class="preview-value">{{ kycData.incorporationDate | dateFormat }}</span>
</div>
}
@if (kycData.gstinNumber) {
<div class="preview-field">
<span class="preview-label">GSTIN Number</span>
+10
View File
@@ -62,6 +62,7 @@ import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
const routes: Routes = [
@@ -199,6 +200,15 @@ const routes: Routes = [
component: Form121TabComponent,
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
},
{
path: 'authorized-signatories',
component: AuthorizedSignatoriesTabComponent,
data: {
title: 'Authorized Signatories',
breadcrumb: 'Authorized Signatories',
routeParamBreadcrumb: false
}
},
{
path: 'documents',
component: DocumentsTabComponent,
@@ -41,6 +41,26 @@
>
{{ address.isActive ? 'Active' : 'Inactive' }}
</span>
<!-- Only shown on the address a welcome kit was actually posted to. -->
@if (verificationOf(address); as verification) {
<span
class="addr-panel__verify"
[class.addr-panel__verify--yes]="verification === 'VERIFIED'"
[class.addr-panel__verify--no]="verification === 'NOT_VERIFIED'"
[matTooltip]="
verification === 'VERIFIED'
? 'Welcome kit was delivered to this address'
: 'Welcome kit sent here was returned undelivered'
"
>
<i
class="fa"
[class.fa-check-circle]="verification === 'VERIFIED'"
[class.fa-times-circle]="verification === 'NOT_VERIFIED'"
></i>
{{ verification === 'VERIFIED' ? 'Verified' : 'Not Verified' }}
</span>
}
</mat-panel-title>
<mat-panel-description class="addr-panel__desc">
{{ address.city || '' }}{{ address.city && (address.countyDistrict || address.addressLine3) ? ', ' : ''
@@ -148,6 +148,32 @@ $red-light: #fce8e6;
}
}
/* Welcome kit outcome — only present on the address a kit was posted to. */
&__verify {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
white-space: nowrap;
i {
font-size: 11px;
}
&--yes {
background: $green-light;
color: $green;
}
&--no {
background: $red-light;
color: $red;
}
}
&__desc {
font-size: 12px;
color: $gray-500 !important;
@@ -33,6 +33,7 @@ import {
} from '@angular/material/expansion';
import { MatDivider } from '@angular/material/divider';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -51,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatExpansionPanelTitle,
MatExpansionPanelDescription,
MatDivider,
MatSlideToggle
MatSlideToggle,
MatTooltip
]
})
export class AddressTabComponent {
@@ -68,6 +70,11 @@ export class AddressTabComponent {
clientAddressTemplate: any;
/** Client Id */
clientId: string;
/**
* Welcome kit outcome per address id. Only addresses a kit was actually posted to appear here: delivery
* proves the address is real, a returned kit proves it is not, and an address with no kit stays unmarked.
*/
addressVerification: Record<string, 'VERIFIED' | 'NOT_VERIFIED'> = {};
/**
* @param {ActivatedRoute} route Activated Route
@@ -82,10 +89,48 @@ export class AddressTabComponent {
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
this.clientAddressTemplate = data.clientAddressTemplateData;
this.clientId = this.route.parent.snapshot.paramMap.get('clientId');
this.loadWelcomeKitVerification();
}
);
}
/**
* Marks addresses a welcome kit was posted to with the outcome of that delivery. The kit is the physical
* check on the address, so a delivered kit reads as verified and a returned one as not verified.
*/
private loadWelcomeKitVerification(): void {
if (!this.clientId) {
return;
}
this.clientService.getWelcomeKits(this.clientId).subscribe({
next: (response: any) => {
const map: Record<string, 'VERIFIED' | 'NOT_VERIFIED'> = {};
// Oldest first, so that when an address was posted to more than once the newest outcome wins.
const dispatches = [...(response?.dispatches || [])].reverse();
for (const dispatch of dispatches) {
if (dispatch?.addressId == null) {
continue;
}
if (dispatch.status === 'DELIVERED') {
map[dispatch.addressId] = 'VERIFIED';
} else if (dispatch.status === 'RETURNED') {
map[dispatch.addressId] = 'NOT_VERIFIED';
}
}
this.addressVerification = map;
},
// A client with no welcome kit permission or no kit yet simply shows no badge.
error: () => {
this.addressVerification = {};
}
});
}
/** Verification badge for an address, or null when no kit has been posted to it. */
verificationOf(address: any): 'VERIFIED' | 'NOT_VERIFIED' | null {
return this.addressVerification[address?.addressId] || null;
}
/**
* Adds a client address.
*/
@@ -0,0 +1,95 @@
<!--
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/.
-->
<div class="as-view">
@if (loading) {
<div class="as-loading">
<mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner>
<p>Loading authorized signatories...</p>
</div>
}
@if (!loading) {
<div class="as-header">
<h3><fa-icon icon="user-shield"></fa-icon> Authorized Signatories</h3>
@if (signatories.length) {
<span class="as-count">{{ signatories.length }} on record</span>
}
</div>
<!-- The tab link is entity-only, but the route can be reached directly by URL. -->
@if (!isNonIndividual) {
<div class="as-empty">
<mat-icon>person</mat-icon>
<span>Authorized signatories apply to non-individual clients only.</span>
</div>
} @else if (!signatories.length) {
<div class="as-empty">
<mat-icon>group_off</mat-icon>
<span>No authorized signatories recorded for this entity.</span>
</div>
} @else {
<p class="as-note">
The persons authorised to transact on this entity's behalf, as named in its Board Resolution / Authorization
Letter.
</p>
@for (signatory of signatories; track $index) {
<div class="as-card">
<div class="as-card__main">
<div class="as-card__avatar"><mat-icon>person</mat-icon></div>
<div class="as-card__identity">
<span class="as-card__name">{{ signatory.name || 'Unnamed signatory' }}</span>
<span class="as-card__meta">
@if (signatory.loading) {
Loading details…
} @else {
@if (signatory.cifNumber) {
<span class="as-mono">CIF {{ signatory.cifNumber }}</span>
}
@if (signatory.officeName) {
<span class="as-card__dot">•</span>{{ signatory.officeName }}
}
@if (signatory.status) {
<span class="as-card__dot">•</span>{{ signatory.status }}
}
<!-- A signatory recorded as a name only — nothing to link to, and saying so is
better than an empty row the officer cannot explain. -->
@if (!signatory.clientId) {
<span class="as-card__unlinked">Name on record only — no linked client</span>
}
}
</span>
</div>
</div>
<div class="as-card__side">
<div class="as-din">
<span class="as-din__label">DIN</span>
@if (signatory.dinNumber) {
<span class="as-din__value as-mono">{{ signatory.dinNumber }}</span>
@if (signatory.dinVerified) {
<span class="as-chip as-chip--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else {
<span class="as-chip as-chip--pending"><mat-icon>radio_button_unchecked</mat-icon> Unverified</span>
}
} @else {
<span class="as-din__value as-din__value--none">—</span>
}
</div>
@if (signatory.clientId) {
<a mat-stroked-button class="as-link-btn" [routerLink]="['/clients', signatory.clientId, 'general']">
<mat-icon>open_in_new</mat-icon> View client
</a>
}
</div>
</div>
}
}
}
</div>
@@ -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/.
*/
.as-view {
padding: 16px 0;
}
.as-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px;
color: #64748b;
}
.as-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
h3 {
font-size: 16px;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
gap: 8px;
fa-icon {
color: #1565c0;
}
}
}
.as-count {
font-size: 12px;
font-weight: 600;
color: #059669;
background: #ecfdf5;
padding: 3px 10px;
border-radius: 12px;
}
.as-note {
margin: 0 0 16px;
font-size: 13px;
color: #64748b;
}
.as-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 40px;
color: #94a3b8;
font-size: 14px;
background: #f8fafc;
border: 2px dashed #e2e8f0;
border-radius: 12px;
}
.as-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 14px 18px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
&__main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
&__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 50%;
background: #eaf2fb;
color: #1565c0;
}
&__identity {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
&__name {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
&__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 12px;
color: #64748b;
}
&__dot {
color: #cbd5e1;
}
&__unlinked {
font-style: italic;
color: #94a3b8;
}
&__side {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
}
.as-din {
display: flex;
align-items: center;
gap: 8px;
&__label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #94a3b8;
}
&__value {
font-size: 13px;
color: #1e293b;
&--none {
color: #94a3b8;
}
}
}
.as-mono {
font-family: 'Roboto Mono', 'Courier New', monospace;
}
.as-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
mat-icon {
font-size: 13px;
width: 13px;
height: 13px;
}
&--ok {
background: #ecfdf5;
color: #047857;
border: 1px solid #a7f3d0;
}
&--pending {
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
}
}
.as-link-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
}
}
@@ -0,0 +1,137 @@
/**
* 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 { ActivatedRoute } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
// MatAnchor, not MatButton: the link to the signatory's client record is an <a>, and the shared
// imports only carry the <button> directive.
import { MatAnchor } from '@angular/material/button';
import { ClientsService } from '../../clients.service';
import { LegalFormId } from '../../models/legal-form.enum';
/** One row of the tab — a name from the entity's record, enriched from the linked client. */
interface AuthorizedSignatory {
/** Client id of the individual, when the signatory was linked to a real client record. */
clientId: number | null;
name: string;
cifNumber: string;
officeName: string;
status: string;
dinNumber: string;
dinVerified: boolean;
/** Details are still being fetched for this row. */
loading: boolean;
}
@Component({
selector: 'mifosx-authorized-signatories-tab',
templateUrl: './authorized-signatories-tab.component.html',
styleUrls: ['./authorized-signatories-tab.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatIcon,
MatProgressSpinner,
MatAnchor
]
})
export class AuthorizedSignatoriesTabComponent implements OnInit {
private route = inject(ActivatedRoute);
private clientsService = inject(ClientsService);
clientId = '';
loading = true;
isNonIndividual = false;
signatories: AuthorizedSignatory[] = [];
ngOnInit(): void {
this.clientId = this.route.parent?.snapshot.paramMap.get('clientId') || '';
// Read from the parent route's resolved data — the entity's own record already carries the
// signatory names and ids, so the tab needs no request of its own to render its rows. Same
// subscription style as the other client tabs, which keeps this correct when the view is
// re-entered for a different client on a reused component instance.
this.route.parent?.data.subscribe((data: any) => {
const clientViewData = data?.clientViewData;
this.isNonIndividual = clientViewData?.legalForm?.id === LegalFormId.ENTITY;
const nonPerson = clientViewData?.clientNonPersonDetails;
this.signatories = this.buildRows(nonPerson?.authorizedSignatory, nonPerson?.authorizedSignatoryClientIds);
this.loading = false;
// DIN lives on the signatory's own KYC record, not on the entity's — the same place the
// General step reads it back from — so each linked row is filled in from there.
this.signatories.filter((s) => s.clientId != null).forEach((s) => this.loadSignatoryDetails(s));
});
}
/**
* Names and client ids are stored as two comma-separated columns written in the same order
* (see ClientGeneralStepComponent.syncSignatoryNames), so they are zipped back by position.
* A name with no id behind it is still shown: signatories recorded before linking existed, or
* typed in without a client record, are part of the entity's mandate and must not disappear.
*/
private buildRows(names: string | null | undefined, clientIds: string | null | undefined): AuthorizedSignatory[] {
const splitCsv = (value: string | null | undefined): string[] =>
(value || '')
.split(',')
.map((part) => part.trim())
.filter(Boolean);
const nameList = splitCsv(names);
const idList = splitCsv(clientIds);
// An id column longer than the name column would otherwise drop the extra links silently.
const rowCount = Math.max(nameList.length, idList.length);
return Array.from({ length: rowCount }, (_unused, index) => {
const rawId = idList[index];
const parsedId = rawId ? Number(rawId) : NaN;
const clientId = Number.isFinite(parsedId) ? parsedId : null;
return {
clientId,
name: nameList[index] || '',
cifNumber: '',
officeName: '',
status: '',
dinNumber: '',
dinVerified: false,
loading: clientId != null
};
});
}
private loadSignatoryDetails(signatory: AuthorizedSignatory): void {
const id = signatory.clientId!.toString();
this.clientsService.getClientData(id).subscribe({
next: (data: any) => {
signatory.name = signatory.name || data?.displayName || '';
signatory.cifNumber = data?.cifNumber || data?.accountNo || '';
signatory.officeName = data?.officeName || '';
signatory.status = data?.status?.value || '';
signatory.loading = false;
},
// A signatory whose client record cannot be read still shows as a row with its stored name,
// rather than spinning forever.
error: () => {
signatory.loading = false;
}
});
this.clientsService.getClientKyc(id).subscribe({
next: (kyc: any) => {
signatory.dinNumber = kyc?.dinNumber || '';
signatory.dinVerified = kyc?.dinVerified === true;
},
error: () => {}
});
}
}
@@ -168,58 +168,78 @@
<div class="cv-cell__value">{{ clientViewData.occupation.name }}</div>
</div>
}
@if (clientViewData.mobileNo && clientViewData.dipVerified !== null) {
<!--
dipLiveStatus reflects the LATEST lookup, not the flag frozen at onboarding. A customer
cleared when they joined can have their number reported for fraud or reassigned later —
this cell has to show that, otherwise the page keeps claiming "Verified" indefinitely.
-->
@if (clientViewData.mobileNo && (clientViewData.dipVerified !== null || clientViewData.dipLiveStatus)) {
<div class="cv-cell">
<div class="cv-cell__label">DIP Status</div>
<div class="cv-cell__value">
<span
class="cv-badge"
[class.cv-badge--green]="
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
"
[class.cv-badge--yellow]="
clientViewData.dipVerified !== true &&
clientViewData.dipVerified !== 'true' &&
clientViewData.dipVerified !== 1
"
[class.cv-badge--red]="isDipFlagged(clientViewData.dipLiveStatus)"
[class.cv-badge--green]="!isDipFlagged(clientViewData.dipLiveStatus) && isDipClear(clientViewData)"
[class.cv-badge--yellow]="!isDipFlagged(clientViewData.dipLiveStatus) && !isDipClear(clientViewData)"
>
{{
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
? 'Verified'
: 'Pending'
}}
{{ dipStatusLabel(clientViewData) }}
</span>
</div>
</div>
}
@if (clientViewData.nameVerified !== null) {
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
<div class="cv-cell cv-cell--full">
<div class="dip-alert">
<fa-icon icon="exclamation-triangle" class="dip-alert__icon"></fa-icon>
<div>
<strong>This mobile number is flagged on the DoT Digital Intelligence Platform.</strong>
<div class="dip-alert__detail">
OTPs and SMS alerts to this number are blocked. Contact the customer through an alternate channel and
obtain a new number. Account operations are not restricted.
</div>
</div>
</div>
</div>
}
<!--
A frozen customer must be obvious the moment anyone opens them, not something a teller
discovers when a withdrawal is refused at the counter.
-->
@if (clientViewData.sanctionsAlertStatus === 'CONFIRMED_MATCH') {
<div class="cv-cell cv-cell--full">
<div class="dip-alert">
<fa-icon icon="ban" class="dip-alert__icon"></fa-icon>
<div>
<strong>Confirmed UN Security Council sanctions match — accounts frozen.</strong>
<div class="dip-alert__detail">
Debits are blocked on this customer's deposit accounts and lending is barred. Do not discuss the
reason with the customer. See Reports &rarr; Sanctions Screening &amp; Alerts.
</div>
</div>
</div>
</div>
}
<!--
Driven by sanctionsAlertStatus (the live alert) rather than nameVerified, which records
the outcome at onboarding and never changes. A client confirmed against the UNSC list and
frozen afterwards was still reading "Clear" here — the single worst place to be wrong,
since this header is what anyone opening the customer sees first.
-->
@if (clientViewData.nameVerified !== null || clientViewData.sanctionsAlertStatus) {
<div class="cv-cell">
<div class="cv-cell__label">Sanctions Screening</div>
<div class="cv-cell__value">
<span
class="cv-badge"
[class.cv-badge--green]="
clientViewData.nameVerified === true ||
clientViewData.nameVerified === 'true' ||
clientViewData.nameVerified === 1
"
[class.cv-badge--red]="
clientViewData.nameVerified === false ||
clientViewData.nameVerified === 'false' ||
clientViewData.nameVerified === 0
[class.cv-badge--green]="!clientViewData.sanctionsAlertStatus && isNameCleared(clientViewData)"
[class.cv-badge--yellow]="
clientViewData.sanctionsAlertStatus === 'PENDING_REVIEW' ||
(!clientViewData.sanctionsAlertStatus && !isNameCleared(clientViewData))
"
[class.cv-badge--red]="clientViewData.sanctionsAlertStatus === 'CONFIRMED_MATCH'"
>
{{
clientViewData.nameVerified === true ||
clientViewData.nameVerified === 'true' ||
clientViewData.nameVerified === 1
? 'Clear'
: 'Pending'
}}
{{ sanctionsHeaderLabel(clientViewData) }}
</span>
</div>
</div>
@@ -410,9 +430,11 @@
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
{{ 'labels.buttons.Add Charge' | translate }}
</button>
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
Welcome Kit
</button>
@if (isActive()) {
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
Welcome Kit
</button>
}
<button mat-menu-item (click)="doAction('Create Collateral')">
{{ 'labels.buttons.Create Collateral' | translate }}
</button>
@@ -554,6 +576,22 @@
>
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
</a>
<!--
Authorized signatories are an entity concept — the persons named in a Board Resolution as
authorised to transact for the company. They are captured in the General step at onboarding
but have never been readable anywhere in the client view until now.
-->
@if (clientViewData.legalForm?.id === 2) {
<a
mat-tab-link
[routerLink]="['./authorized-signatories']"
routerLinkActive
#signatoriesTab="routerLinkActive"
[active]="signatoriesTab.isActive"
>
<i class="fa fa-user-shield cv-tab-icon"></i> Authorized Signatories
</a>
}
<a
mat-tab-link
[routerLink]="['./documents']"
@@ -286,6 +286,39 @@ $accent: #0ea5e9;
}
}
// A DIP hit on an existing customer is not an account restriction, so it is shown as a standing
// advisory on the client rather than as a blocking state.
.cv-cell--full {
grid-column: 1 / -1;
}
.dip-alert {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgb(239 68 68 / 15%);
border: 1px solid rgb(239 68 68 / 40%);
border-left: 3px solid #ef4444;
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
color: #fecaca;
line-height: 1.5;
&__icon {
color: #fca5a5;
font-size: 15px;
margin-top: 1px;
flex-shrink: 0;
}
&__detail {
margin-top: 3px;
color: #fca5a5;
font-weight: 400;
}
}
.actions-menu {
color: #fff !important;
}
@@ -85,6 +85,54 @@ import { formatTabLabel } from 'app/shared/utils/format-tab-label.util';
})
export class ClientsViewComponent implements OnInit {
complianceHideClientData = environment.complianceHideClientData;
/**
* Whether the number's CURRENT DIP/MNRL status is a hit. Driven by the live lookup rather than
* the onboarding-time `dipVerified` flag, which never changes once the client is created.
*/
isDipFlagged(liveStatus: string | null | undefined): boolean {
return !!liveStatus && liveStatus !== 'CLEAN' && liveStatus !== 'ERROR';
}
/** True only when the number is affirmatively clean — not merely "not flagged". */
isDipClear(client: any): boolean {
if (client?.dipLiveStatus) {
return client.dipLiveStatus === 'CLEAN';
}
return client?.dipVerified === true || client?.dipVerified === 'true' || client?.dipVerified === 1;
}
/** True when onboarding screening cleared the name — the pre-alert baseline. */
isNameCleared(client: any): boolean {
return client?.nameVerified === true || client?.nameVerified === 'true' || client?.nameVerified === 1;
}
/**
* Header badge for sanctions. A live alert always wins over the onboarding flag: the flag says
* what was true the day the client joined, the alert says what is true now.
*/
sanctionsHeaderLabel(client: any): string {
switch (client?.sanctionsAlertStatus) {
case 'CONFIRMED_MATCH':
return 'Frozen — Sanctions Match';
case 'PENDING_REVIEW':
return 'Under Review';
case 'FALSE_POSITIVE':
return 'Cleared — False Positive';
default:
return this.isNameCleared(client) ? 'Clear' : 'Pending';
}
}
dipStatusLabel(client: any): string {
const live = client?.dipLiveStatus;
if (live === 'SUSPECTED') return 'Flagged — Suspected';
if (live === 'DISCONNECTED') return 'Flagged — Disconnected';
if (live === 'SUSPECTED_AND_DISCONNECTED') return 'Flagged — Suspected & Disconnected';
if (live === 'SERVICE_UNAVAILABLE') return 'Not Verified';
if (live === 'CLEAN') return 'Verified';
return this.isDipClear(client) ? 'Verified' : 'Pending';
}
/**
* Mask a string, keeping first and last letter, masking the rest with *
*/
@@ -461,12 +509,24 @@ export class ClientsViewComponent implements OnInit {
}
/**
* Opens the Welcome Kit Dispatch dialog.
* Opens the Welcome Kit Dispatch dialog. The kit is only posted once the client is active, so the dialog is
* unreachable before then — the menu entry is hidden and this guard covers any other route into the action.
*/
private dispatchWelcomeKit() {
if (!this.isActive()) {
return;
}
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
data: { clientId: this.clientViewData.id },
width: '600px'
data: {
clientId: this.clientViewData.id,
clientName: this.clientViewData.displayName,
accountNo: this.clientViewData.accountNo,
cifNumber: this.clientViewData.cifNumber,
mobileNo: this.clientViewData.mobileNo,
emailAddress: this.clientViewData.emailAddress,
officeName: this.clientViewData.officeName
},
width: '680px'
});
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
if (response) {
@@ -11,9 +11,21 @@
<div>
<mifosx-file-upload flex="60%" (change)="onFileSelect($event)" acceptFilter=".png,.jpeg,.jpg"></mifosx-file-upload>
@if (imagePreview) {
<div class="image-preview">
<span class="image-preview__label">Preview</span>
<img class="image-preview__img" [src]="imagePreview" alt="Selected profile image preview" />
<span class="image-preview__name">{{ image.name }}</span>
</div>
}
@if (previewError) {
<div class="image-preview__error">{{ previewError }}</div>
}
<mat-dialog-actions align="end">
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
<button mat-raised-button color="primary" [disabled]="!image" [mat-dialog-close]="image">
<button mat-raised-button color="primary" [disabled]="!image || !imagePreview" [mat-dialog-close]="image">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-dialog-actions>
@@ -5,3 +5,45 @@
* 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/.
*/
.image-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin: 12px 0 4px;
}
.image-preview__label {
align-self: flex-start;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
color: rgb(0 0 0 / 60%);
}
.image-preview__img {
/* Capped in vh as well as px so a tall portrait photo cannot push the Cancel/Confirm footer
below the fold on a short viewport — `contain` keeps the whole frame visible rather than
cropping it, since the point of the preview is to check the picture before it goes live. */
max-width: 100%;
max-height: min(260px, 40vh);
object-fit: contain;
border-radius: 10px;
background: rgb(0 0 0 / 4%);
border: 1px solid rgb(0 0 0 / 12%);
}
.image-preview__name {
font-size: 12px;
color: rgb(0 0 0 / 60%);
overflow-wrap: anywhere;
text-align: center;
}
.image-preview__error {
margin: 12px 0 4px;
font-size: 13px;
color: #b3261e;
}
@@ -33,6 +33,16 @@ export class UploadImageDialogComponent {
/** Client Image */
image: File;
/**
* Data URL of the selected image, rendered as a preview so the officer sees what they picked
* before confirming — the upload replaces the client's profile picture immediately on confirm,
* and picking the wrong file is otherwise only discovered once it is already live.
*/
imagePreview: string | null = null;
/** Set when the picked file is not a readable image, in place of a preview. */
previewError = '';
/**
* Sets file form control value.
* @param {any} $event file change event.
@@ -40,6 +50,29 @@ export class UploadImageDialogComponent {
onFileSelect($event: any) {
if ($event.target.files.length > 0) {
this.image = $event.target.files[0];
this.buildPreview(this.image);
}
}
/** Reads the picked file into a data URL for the preview. */
private buildPreview(file: File) {
this.imagePreview = null;
this.previewError = '';
// The picker's accept filter is a hint the OS dialog can be talked out of, so re-check here
// rather than handing a non-image to FileReader and rendering a broken <img>.
if (!file.type?.startsWith('image/')) {
this.previewError = 'Selected file is not an image. Choose a PNG or JPEG file.';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.imagePreview = reader.result as string;
};
reader.onerror = () => {
this.previewError = 'Could not read the selected file.';
};
reader.readAsDataURL(file);
}
}
@@ -5,90 +5,277 @@
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/.
-->
<h2 mat-dialog-title>Welcome Kit Dispatch</h2>
<div class="wk-header" mat-dialog-title>
<div class="wk-header__icon">
<mat-icon>card_giftcard</mat-icon>
</div>
<div class="wk-header__text">
<h2 class="wk-header__title">Welcome Kit Dispatch</h2>
<p class="wk-header__subtitle">Posting the kit doubles as a physical address check for this client.</p>
</div>
</div>
<mat-dialog-content class="mat-typography">
<div fxLayout="row" fxLayoutAlign="center center" style="padding: 2rem" *ngIf="isLoading">
<mat-dialog-content class="mat-typography wk-content">
<div class="wk-loading" *ngIf="isLoading">
<mat-spinner diameter="40"></mat-spinner>
</div>
<ng-container *ngIf="!isLoading">
<!-- Tracking view if dispatches already exist -->
<div
class="dispatch-tracking-panel"
*ngFor="let dispatch of activeDispatches; let i = index"
style="margin-bottom: 16px; padding: 16px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa"
>
<div fxLayout="row" fxLayoutAlign="space-between start" style="margin-bottom: 8px">
<h3 style="margin: 0">{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status</h3>
<button mat-stroked-button color="primary" type="button" (click)="downloadPdf(dispatch)">
Download Details
</button>
<!-- Who the kit is going to -->
<section class="wk-client">
<div class="wk-client__avatar">{{ (clientName || '?').charAt(0) }}</div>
<div class="wk-client__details">
<div class="wk-client__name">{{ clientName || 'Client' }}</div>
<div class="wk-client__meta" *ngIf="data.accountNo">Account No. {{ data.accountNo }}</div>
</div>
<p style="margin: 4px 0"><strong>Delivery Address:</strong> {{ getDisplayAddress(dispatch) }}</p>
<p style="margin: 4px 0"><strong>Tracking ID:</strong> {{ dispatch.trackingId }}</p>
<p style="margin: 4px 0">
<strong>Delivery Mode:</strong> {{ dispatch.deliveryMode }}
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'">({{ dispatch.deliveryModeOtherText }})</ng-container>
</p>
<p style="margin: 4px 0"><strong>Dispatched On:</strong> {{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</p>
<p style="margin: 4px 0">
<strong>Status:</strong>
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
</p>
</div>
</section>
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
<!-- Existing dispatches, newest first -->
<section
class="wk-dispatch"
*ngFor="let dispatch of activeDispatches; let i = index"
[class.wk-dispatch--past]="i > 0"
>
<header class="wk-dispatch__head">
<span class="wk-dispatch__label">{{ i === 0 ? 'Current Dispatch' : 'Previous Dispatch' }}</span>
<span class="wk-chip" [ngClass]="'wk-chip--' + (dispatch.status | lowercase)">
<mat-icon class="wk-chip__icon">
{{
dispatch.status === 'DELIVERED'
? 'verified'
: dispatch.status === 'RETURNED'
? 'gpp_bad'
: 'local_shipping'
}}
</mat-icon>
{{ verificationLabel(dispatch.status) }}
</span>
</header>
<!-- Create form if they can dispatch a new one -->
<div class="dispatch-create-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
<h3>Create New Dispatch</h3>
<div fxLayout="column" fxLayoutGap="16px">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Delivery Address</mat-label>
<textarea matInput formControlName="deliveryAddress" rows="3" required></textarea>
<mat-error *ngIf="dispatchForm.get('deliveryAddress')?.hasError('required')">
Delivery Address is required
<p class="wk-dispatch__hint">{{ verificationHint(dispatch) }}</p>
<dl class="wk-facts">
<div class="wk-facts__row wk-facts__row--wide">
<dt>Delivery Address</dt>
<dd>{{ getDisplayAddress(dispatch) }}</dd>
</div>
<div class="wk-facts__row">
<dt>Tracking ID</dt>
<dd class="wk-facts__mono">{{ dispatch.trackingId }}</dd>
</div>
<div class="wk-facts__row">
<dt>Delivery Mode</dt>
<dd>
{{ dispatch.deliveryMode }}
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'"
>({{ dispatch.deliveryModeOtherText }})</ng-container
>
</dd>
</div>
<div class="wk-facts__row">
<dt>Dispatched On</dt>
<dd>{{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</dd>
</div>
<div class="wk-facts__row" *ngIf="dispatch.deliveredOnDate">
<dt>Delivered On</dt>
<dd>{{ dispatch.deliveredOnDate | date: 'mediumDate' }}</dd>
</div>
<div class="wk-facts__row" *ngIf="dispatch.returnedOnDate">
<dt>Returned On</dt>
<dd>{{ dispatch.returnedOnDate | date: 'mediumDate' }}</dd>
</div>
<div class="wk-facts__row wk-facts__row--wide" *ngIf="dispatch.remarks">
<dt>Remarks</dt>
<dd>{{ dispatch.remarks }}</dd>
</div>
</dl>
<!-- Outcome form, shown in place of the buttons once an outcome is picked -->
<div class="wk-outcome" *ngIf="outcomeFor?.id === dispatch.id" [formGroup]="outcomeForm">
<p class="wk-outcome__title">
Mark this kit as
<strong>{{ outcomeStatus === 'DELIVERED' ? 'Verified' : 'Not Verified' }}</strong>
</p>
<div class="wk-outcome__fields">
<mat-form-field appearance="outline">
<mat-label>{{ outcomeStatus === 'DELIVERED' ? 'Delivered On' : 'Returned On' }}</mat-label>
<input matInput [matDatepicker]="outcomePicker" [max]="maxDate" formControlName="outcomeDate" required />
<mat-datepicker-toggle matSuffix [for]="outcomePicker"></mat-datepicker-toggle>
<mat-datepicker #outcomePicker></mat-datepicker>
<mat-error *ngIf="outcomeForm.get('outcomeDate')?.hasError('required')"> Date is required </mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Remarks (optional)</mat-label>
<input
matInput
formControlName="remarks"
maxlength="500"
placeholder="What the delivery partner reported"
/>
</mat-form-field>
</div>
<div class="wk-outcome__actions">
<button mat-button type="button" (click)="cancelOutcome()">Cancel</button>
<button
mat-flat-button
type="button"
[color]="outcomeStatus === 'DELIVERED' ? 'primary' : 'warn'"
[disabled]="outcomeForm.invalid"
(click)="submitOutcome()"
>
Confirm {{ outcomeStatus === 'DELIVERED' ? 'Verified' : 'Not Verified' }}
</button>
</div>
</div>
<footer class="wk-dispatch__actions" *ngIf="outcomeFor?.id !== dispatch.id">
<button mat-stroked-button type="button" (click)="downloadAddressLabel(dispatch)">
<mat-icon>print</mat-icon>
Label
</button>
<button mat-stroked-button type="button" (click)="downloadPdf(dispatch)">
<mat-icon>download</mat-icon>
Details
</button>
<ng-container *ngIf="dispatch.status === 'DISPATCHED'">
<button mat-stroked-button color="warn" type="button" (click)="startOutcome(dispatch, 'RETURNED')">
<mat-icon>gpp_bad</mat-icon>
Not Verified
</button>
<button mat-flat-button color="primary" type="button" (click)="startOutcome(dispatch, 'DELIVERED')">
<mat-icon>verified</mat-icon>
Verified
</button>
</ng-container>
</footer>
</section>
<!-- New dispatch -->
<section class="wk-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
<h3 class="wk-form__title">
{{ activeDispatches.length > 0 ? 'Dispatch a Replacement Kit' : 'Create New Dispatch' }}
</h3>
<!-- The kit goes to one of the addresses captured at client creation; it is chosen, never typed. -->
<ng-container *ngIf="addressChoices.length > 0">
<mat-form-field appearance="outline" class="wk-full">
<mat-label>Deliver To</mat-label>
<mat-select formControlName="addressId" required>
<mat-option *ngFor="let address of addressChoices" [value]="address.id">
{{ address.type }} — {{ address.oneLine }}
</mat-option>
</mat-select>
<mat-hint>Addresses recorded for this client. Update them on the Address tab if one is wrong.</mat-hint>
<mat-error *ngIf="dispatchForm.get('addressId')?.hasError('required')">
Select the address to deliver to
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<div class="wk-address" *ngIf="selectedAddress as address">
<mat-icon class="wk-address__icon">place</mat-icon>
<div class="wk-address__body">
<span class="wk-address__type">{{ address.type }}</span>
<span class="wk-address__name">{{ clientName }}</span>
<span class="wk-address__line" *ngFor="let line of address.lines">{{ line }}</span>
</div>
</div>
</ng-container>
<div class="wk-noaddress" *ngIf="addressesLoaded && addressChoices.length === 0">
<mat-icon>error_outline</mat-icon>
<span>
No address is recorded for this client. Add one on the Address tab before dispatching a welcome kit.
</span>
</div>
<div class="wk-form__row">
<mat-form-field appearance="outline">
<mat-label>Delivery Mode</mat-label>
<mat-select formControlName="deliveryMode" required>
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">
{{ mode }}
</mat-option>
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">{{ mode }}</mat-option>
</mat-select>
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
Delivery Mode is required
</mat-error>
</mat-form-field>
<mat-form-field
appearance="outline"
class="full-width"
*ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'"
>
<mat-label>Specify Other Mode</mat-label>
<input matInput formControlName="deliveryModeOtherText" required />
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
Please specify the delivery mode
<mat-form-field appearance="outline">
<mat-label>Dispatched On</mat-label>
<input
matInput
[matDatepicker]="dispatchPicker"
[max]="maxDate"
formControlName="dispatchedOnDate"
required
/>
<mat-datepicker-toggle matSuffix [for]="dispatchPicker"></mat-datepicker-toggle>
<mat-datepicker #dispatchPicker></mat-datepicker>
<mat-error *ngIf="dispatchForm.get('dispatchedOnDate')?.hasError('required')">
Dispatch date is required
</mat-error>
</mat-form-field>
</div>
</div>
<mat-form-field appearance="outline" class="wk-full" *ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'">
<mat-label>Specify Other Mode</mat-label>
<input matInput formControlName="deliveryModeOtherText" required />
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
Please specify the delivery mode
</mat-error>
</mat-form-field>
<!-- Printed before the kit is posted, so the tracking ID below is filled in afterwards. -->
<div class="wk-label-cta">
<div class="wk-label-cta__text">
<span class="wk-label-cta__title">Envelope address label</span>
<span class="wk-label-cta__hint">
Print this first — name, address, contact details and the welcome note, ready to fold into a window
envelope.
</span>
</div>
<button
mat-stroked-button
color="primary"
type="button"
[disabled]="!selectedAddress"
(click)="downloadAddressLabel()"
>
<mat-icon>print</mat-icon>
Download
</button>
</div>
<mat-form-field appearance="outline" class="wk-full">
<mat-label>Tracking ID</mat-label>
<input matInput formControlName="trackingId" maxlength="100" required placeholder="e.g. EX123456789IN" />
<mat-icon matSuffix>confirmation_number</mat-icon>
<mat-hint>Enter the consignment number printed on the delivery partner's receipt.</mat-hint>
<mat-error *ngIf="dispatchForm.get('trackingId')?.hasError('required')"> Tracking ID is required </mat-error>
<mat-error *ngIf="dispatchForm.get('trackingId')?.hasError('maxlength')">
Tracking ID cannot exceed 100 characters
</mat-error>
</mat-form-field>
</section>
<!-- Nothing to do: a kit is already out and not yet resolved -->
<p class="wk-empty" *ngIf="!welcomeKitsData?.canDispatchNew && activeDispatches.length > 0">
A welcome kit is already recorded for this client. Record its outcome above before dispatching another.
</p>
</ng-container>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
<button mat-button type="button" (click)="close()">Close</button>
<button
mat-raised-button
mat-flat-button
color="primary"
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading"
type="button"
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading && !outcomeFor"
(click)="submit()"
[disabled]="dispatchForm.invalid || isLoading"
>
<mat-icon>send</mat-icon>
Dispatch
</button>
</mat-dialog-actions>
@@ -5,55 +5,375 @@
* 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/.
*/
.dispatch-tracking-panel {
background-color: #f5f5f5;
border-radius: 8px;
padding: 16px;
$wk-primary: #1f497d;
$wk-border: #e3e7ee;
$wk-muted: #6b7280;
$wk-surface: #f7f9fc;
/* ---------- header ---------- */
.wk-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px 16px;
margin: 0;
border-bottom: 1px solid $wk-border;
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex: 0 0 40px;
border-radius: 10px;
background: rgba($wk-primary, 0.1);
color: $wk-primary;
}
&__title {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
line-height: 1.3;
}
&__subtitle {
margin: 2px 0 0;
font-size: 0.8rem;
color: $wk-muted;
}
}
.wk-content {
padding-top: 16px;
}
.wk-loading {
display: flex;
justify-content: center;
padding: 2.5rem 0;
}
/* ---------- client identity ---------- */
.wk-client {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
margin-bottom: 16px;
border-radius: 10px;
background: linear-gradient(135deg, rgba($wk-primary, 0.08), rgba($wk-primary, 0.02));
border: 1px solid rgba($wk-primary, 0.15);
h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.1rem;
&__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
flex: 0 0 38px;
border-radius: 50%;
background: $wk-primary;
color: #fff;
font-weight: 600;
text-transform: uppercase;
}
p {
margin: 6px 0;
&__name {
font-weight: 600;
font-size: 1rem;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
color: white;
&__meta {
font-size: 0.8rem;
color: $wk-muted;
font-variant-numeric: tabular-nums;
}
}
&.status-dispatched {
background-color: #1976d2;
}
/* ---------- dispatch panel ---------- */
.wk-dispatch {
border: 1px solid $wk-border;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 16px;
background: $wk-surface;
&.status-in_transit {
background-color: #f57c00;
}
&--past {
opacity: 0.75;
}
&.status-delivered {
background-color: #388e3c;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
&.status-returned {
background-color: #d32f2f;
&__label {
font-weight: 600;
font-size: 0.95rem;
}
&__hint {
margin: 0 0 12px;
font-size: 0.8rem;
color: $wk-muted;
line-height: 1.45;
}
&__actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
button mat-icon {
margin-right: 4px;
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
.dispatch-create-form {
h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.1rem;
/* ---------- status chip ---------- */
.wk-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
&__icon {
font-size: 15px;
width: 15px;
height: 15px;
}
&--dispatched {
background: rgb(25 118 210 / 12%);
color: #1565c0;
}
&--delivered {
background: rgb(56 142 60 / 14%);
color: #2e7d32;
}
&--returned {
background: rgb(211 47 47 / 12%);
color: #c62828;
}
}
.full-width {
/* ---------- fact grid ---------- */
.wk-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
margin: 0;
&__row {
min-width: 0;
&--wide {
grid-column: 1 / -1;
}
dt {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $wk-muted;
margin-bottom: 2px;
}
dd {
margin: 0;
font-size: 0.875rem;
word-break: break-word;
}
}
&__mono {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-weight: 600;
letter-spacing: 0.02em;
}
}
/* ---------- outcome form ---------- */
.wk-outcome {
margin-top: 14px;
padding: 14px;
border-radius: 8px;
background: #fff;
border: 1px dashed rgba($wk-primary, 0.4);
&__title {
margin: 0 0 10px;
font-size: 0.875rem;
}
&__fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0 14px;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
}
/* ---------- create form ---------- */
.wk-form {
&__title {
margin: 0 0 12px;
font-size: 1rem;
font-weight: 600;
}
&__row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0 14px;
}
}
.wk-full {
width: 100%;
}
.wk-address {
display: flex;
gap: 10px;
padding: 12px 14px;
margin-bottom: 16px;
border-radius: 8px;
background: $wk-surface;
border: 1px solid $wk-border;
&__icon {
color: $wk-primary;
font-size: 20px;
width: 20px;
height: 20px;
flex: 0 0 20px;
}
&__body {
display: flex;
flex-direction: column;
font-size: 0.85rem;
line-height: 1.5;
}
&__type {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $wk-primary;
margin-bottom: 2px;
}
&__name {
font-weight: 600;
}
&__line {
color: $wk-muted;
}
}
.wk-noaddress {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
margin-bottom: 16px;
border-radius: 8px;
background: rgb(211 47 47 / 6%);
border: 1px solid rgb(211 47 47 / 25%);
font-size: 0.83rem;
line-height: 1.5;
color: #b3261e;
mat-icon {
font-size: 19px;
width: 19px;
height: 19px;
flex: 0 0 19px;
}
}
/* ---------- envelope label call-to-action ---------- */
.wk-label-cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
padding: 12px 14px;
margin-bottom: 18px;
border-radius: 8px;
background: rgba($wk-primary, 0.04);
border: 1px solid rgba($wk-primary, 0.18);
&__text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 240px;
}
&__title {
font-weight: 600;
font-size: 0.875rem;
}
&__hint {
font-size: 0.78rem;
color: $wk-muted;
line-height: 1.45;
}
button mat-icon {
margin-right: 4px;
font-size: 18px;
width: 18px;
height: 18px;
}
}
.wk-empty {
margin: 4px 0 0;
padding: 12px 14px;
border-radius: 8px;
background: $wk-surface;
border: 1px solid $wk-border;
font-size: 0.85rem;
color: $wk-muted;
}
mat-dialog-actions {
padding: 12px 24px 20px;
button mat-icon {
margin-right: 4px;
font-size: 18px;
width: 18px;
height: 18px;
}
}
@@ -12,12 +12,14 @@ import {
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
MatDialogActions
} from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatIcon } from '@angular/material/icon';
import { ClientsService } from '../../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DatePipe } from '@angular/common';
import { jsPDF } from 'jspdf';
@@ -32,32 +34,79 @@ import autoTable from 'jspdf-autotable';
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatProgressSpinnerModule
MatProgressSpinnerModule,
MatIcon
],
providers: [DatePipe]
})
export class WelcomeKitDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
data: { clientId: string } = inject(MAT_DIALOG_DATA);
data: {
clientId: string;
clientName?: string;
accountNo?: string;
cifNumber?: string;
mobileNo?: string;
emailAddress?: string;
officeName?: string;
} = inject(MAT_DIALOG_DATA);
private clientsService = inject(ClientsService);
private formBuilder = inject(FormBuilder);
private snackBar = inject(MatSnackBar);
private datePipe = inject(DatePipe);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
isLoading = true;
/** Set once a dispatch or an outcome is saved, so the client view knows to reload on close. */
private hasChanges = false;
welcomeKitsData: any = null;
/** Addresses captured for the client, each formatted for display and selection. */
addressChoices: { id: any; type: string; isActive: boolean; lines: string[]; oneLine: string }[] = [];
/** True once the address lookup has run, so the empty state only shows when there genuinely are none. */
addressesLoaded = false;
/** Dispatch whose outcome is being recorded, or null while the tracking list is shown. */
outcomeFor: any = null;
outcomeStatus: 'DELIVERED' | 'RETURNED' = 'DELIVERED';
maxDate = new Date();
dispatchForm: FormGroup = this.formBuilder.group({
deliveryAddress: [
'',
// The kit goes to one of the addresses captured at client creation, never to a hand-typed one, so the
// dispatch record always traces back to an address the client actually gave.
addressId: [
null,
Validators.required
],
deliveryMode: [
'POSTMAN',
Validators.required
],
deliveryModeOtherText: ['']
deliveryModeOtherText: [''],
trackingId: [
'',
[
Validators.required,
Validators.maxLength(100)
]
],
dispatchedOnDate: [
new Date(),
Validators.required
]
});
outcomeForm: FormGroup = this.formBuilder.group({
outcomeDate: [
new Date(),
Validators.required
],
remarks: [
'',
Validators.maxLength(500)
]
});
deliveryModes = [
'POSTMAN',
'COURIER',
@@ -76,7 +125,37 @@ export class WelcomeKitDialogComponent implements OnInit {
});
}
get clientName(): string {
return this.welcomeKitsData?.clientName || this.data.clientName || '';
}
/** The kit is a physical address check, so the outcome is phrased as verification rather than delivery. */
verificationLabel(status: string): string {
switch (status) {
case 'DELIVERED':
return 'Verified';
case 'RETURNED':
return 'Not Verified';
default:
return 'Awaiting Verification';
}
}
verificationHint(dispatch: any): string {
switch (dispatch?.status) {
case 'DELIVERED':
return 'Kit delivered, so the address stands verified.';
case 'RETURNED':
return 'Kit came back undelivered, so the address could not be verified.';
default:
return 'Check this tracking ID on the delivery partner’s website or app, then record the outcome below.';
}
}
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.dispatchForm.patchValue({ dispatchedOnDate: this.maxDate });
this.outcomeForm.patchValue({ outcomeDate: this.maxDate });
this.fetchData();
}
@@ -98,33 +177,77 @@ export class WelcomeKitDialogComponent implements OnInit {
});
}
/** Order the address types are offered in: a kit is posted to the correspondence address when one exists. */
private static readonly ADDRESS_TYPE_ORDER = [
'Correspondence',
'Current / Residential',
'Permanent Address',
'Office / Work'
];
fetchPrimaryAddress() {
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
next: (addresses: any) => {
this.isLoading = false;
if (addresses && addresses.length > 0) {
// Attempt to build a readable address from the first address returned
const primary = addresses[0];
const parts = [];
if (primary.addressLine1) parts.push(primary.addressLine1);
if (primary.addressLine2) parts.push(primary.addressLine2);
if (primary.city) parts.push(primary.city);
if (primary.stateName) parts.push(primary.stateName);
if (primary.postalCode) parts.push(primary.postalCode);
if (primary.countryName) parts.push(primary.countryName);
this.addressesLoaded = true;
const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
this.dispatchForm.patchValue({
deliveryAddress: clientNameStr + parts.join(', ')
const usable = (addresses || []).filter((a: any) => a && (a.addressLine1 || a.postalCode || a.city));
this.addressChoices = usable
.map((a: any) => {
const lines = [
a.addressLine1,
a.addressLine2,
a.addressLine3,
a.street,
a.townVillage,
a.city,
a.countyDistrict,
a.stateName || a.stateProvinceName,
a.postalCode,
a.countryName
]
.map((part: any) => (part == null ? '' : String(part).trim()))
.filter((part: string) => part.length > 0);
return {
id: a.addressId ?? a.id,
type: a.addressType || 'Address',
isActive: a.isActive !== false,
lines,
oneLine: lines.join(', ')
};
})
.sort((x: any, y: any) => {
// Active addresses first, then by how suitable the type is for posting.
if (x.isActive !== y.isActive) return x.isActive ? -1 : 1;
const order = WelcomeKitDialogComponent.ADDRESS_TYPE_ORDER;
const xi = order.indexOf(x.type);
const yi = order.indexOf(y.type);
return (xi === -1 ? order.length : xi) - (yi === -1 ? order.length : yi);
});
if (this.addressChoices.length > 0) {
this.dispatchForm.patchValue({ addressId: this.addressChoices[0].id });
}
},
error: () => {
this.isLoading = false;
this.addressesLoaded = true;
}
});
}
/** The address the operator picked, or null before one is chosen. */
get selectedAddress() {
const id = this.dispatchForm.get('addressId')?.value;
return this.addressChoices.find((a) => a.id === id) || null;
}
/** Lines of the selected address, for the on-screen preview and the printed label. */
get addressLines(): string[] {
return this.selectedAddress?.lines || [];
}
get activeDispatches() {
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
return this.welcomeKitsData.dispatches;
@@ -138,36 +261,104 @@ export class WelcomeKitDialogComponent implements OnInit {
return;
}
const chosen = this.selectedAddress;
if (!chosen) {
this.snackBar.open('Select the address the kit was posted to', 'Close', { duration: 3000 });
return;
}
this.isLoading = true;
const formValue = this.dispatchForm.value;
const today = new Date();
const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const payload = {
deliveryAddress: formValue.deliveryAddress,
// Stored as text so the dispatch keeps the address exactly as it read on the day it went out, even if
// the client's address record is edited later.
deliveryAddress: `${chosen.type}: ${chosen.oneLine}`,
addressId: chosen.id,
deliveryMode: formValue.deliveryMode,
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
dispatchedOnDate: formattedDate,
locale: 'en',
dateFormat: 'dd MMMM yyyy'
trackingId: (formValue.trackingId || '').trim(),
dispatchedOnDate: this.dateUtils.formatDate(formValue.dispatchedOnDate, dateFormat),
locale,
dateFormat
};
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
next: () => {
this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 });
this.hasChanges = true;
this.snackBar.open('Welcome Kit dispatched successfully', 'Close', { duration: 3000 });
this.fetchData();
},
error: () => {
error: (err: any) => {
this.isLoading = false;
this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 });
this.snackBar.open(this.errorMessage(err, 'Failed to dispatch welcome kit'), 'Close', { duration: 5000 });
}
});
}
/** Opens the outcome form for a dispatch, pre-set to the outcome the operator clicked. */
startOutcome(dispatch: any, status: 'DELIVERED' | 'RETURNED') {
this.outcomeFor = dispatch;
this.outcomeStatus = status;
this.outcomeForm.reset({ outcomeDate: this.maxDate, remarks: '' });
}
cancelOutcome() {
this.outcomeFor = null;
}
/** Records what the operator found on the delivery partner's site: delivered verifies the address, returned does not. */
submitOutcome() {
if (this.outcomeForm.invalid || !this.outcomeFor) {
this.outcomeForm.markAllAsTouched();
return;
}
this.isLoading = true;
const formValue = this.outcomeForm.value;
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const payload = {
status: this.outcomeStatus,
outcomeDate: this.dateUtils.formatDate(formValue.outcomeDate, dateFormat),
remarks: (formValue.remarks || '').trim() || null,
locale,
dateFormat
};
this.clientsService.updateWelcomeKitStatus(this.outcomeFor.id, payload).subscribe({
next: () => {
this.hasChanges = true;
this.outcomeFor = null;
this.snackBar.open(
this.outcomeStatus === 'DELIVERED' ? 'Address marked as verified' : 'Address marked as not verified',
'Close',
{ duration: 3000 }
);
this.fetchData();
},
error: (err: any) => {
this.isLoading = false;
this.snackBar.open(this.errorMessage(err, 'Failed to record the outcome'), 'Close', { duration: 5000 });
}
});
}
close() {
this.dialogRef.close(this.hasChanges);
}
/** Surfaces the backend's own message (duplicate tracking ID, inactive client) instead of a generic failure. */
private errorMessage(err: any, fallback: string): string {
return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback;
}
getDisplayAddress(dispatch: any): string {
const address = dispatch.deliveryAddress || '';
const name = this.welcomeKitsData?.clientName || '';
const name = this.clientName;
if (name && !address.startsWith(name)) {
return `${name} — ${address}`;
}
@@ -185,6 +376,9 @@ export class WelcomeKitDialogComponent implements OnInit {
const trackingId = dispatch.trackingId || 'N/A';
const status = dispatch.status || 'N/A';
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || 'N/A';
const verification = this.verificationLabel(dispatch.status);
const outcomeOn =
this.datePipe.transform(dispatch.deliveredOnDate || dispatch.returnedOnDate, 'mediumDate') || 'Awaited';
const primaryColor: [number, number, number] = [
31,
@@ -307,6 +501,14 @@ export class WelcomeKitDialogComponent implements OnInit {
[
'Dispatched On',
dispatchedOn
],
[
'Address Verification',
verification
],
[
'Outcome Recorded On',
outcomeOn
]
]
});
@@ -344,4 +546,192 @@ export class WelcomeKitDialogComponent implements OnInit {
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
}
/**
* Address label for the envelope. It is printed before the kit is posted — off the live form for a new
* dispatch, or off a saved record — so the operator can fold it into a window envelope and hand it over the
* counter. Layout is a single A4 sheet: the addressee block sits inside the window cut-out zone, and the
* welcome message sits below the fold where it stays hidden until the envelope is opened.
*/
downloadAddressLabel(dispatch?: any) {
const doc = new jsPDF();
const pageWidth = doc.internal.pageSize.getWidth();
const primaryColor: [number, number, number] = [
31,
73,
125
];
const grayColor: [number, number, number] = [
110,
110,
110
];
const lineColor: [number, number, number] = [
205,
212,
222
];
const name = this.clientName || 'Client';
// A saved dispatch keeps the address it actually went out with; a new one uses the address currently
// selected in the form. The saved value carries a "Type: " prefix that belongs on the record, not the envelope.
const addressLines = dispatch
? String(dispatch.deliveryAddress || '')
.replace(/^[^:]+:\s*/, '')
.split(',')
.map((part: string) => part.trim())
.filter((part: string) => part.length > 0)
: this.addressLines;
// --- Sender block, top-left, as the return address ---
doc.setFillColor(...primaryColor);
doc.rect(0, 0, pageWidth, 26, 'F');
doc.setFontSize(17);
doc.setFont('helvetica', 'bold');
doc.setTextColor(255, 255, 255);
doc.text('CBSNexora', 18, 14);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text('Core Banking Solution', 18, 20);
doc.setFontSize(7.5);
doc.setTextColor(...grayColor);
doc.setFont('helvetica', 'bold');
doc.text('FROM', 22, 37);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8.5);
doc.setTextColor(95, 100, 110);
let fromY = 42.5;
const branch = this.data.officeName ? `CBSNexora — ${this.data.officeName}` : 'CBSNexora';
[
branch,
'Customer Service Desk | support@cbsnexora.com | 1800-000-0000'
].forEach((line) => {
doc.text(line, 22, fromY);
fromY += 4.6;
});
// --- Addressee card: name, address and the client's reference details in one block, sized so the whole
// card sits behind a standard window cut-out ---
const cardX = 22;
const cardY = 60;
const cardW = pageWidth - 44;
const padX = cardX + 9;
const refs: string[] = [];
if (this.data.accountNo) refs.push(`Account No. ${this.data.accountNo}`);
if (this.data.cifNumber) refs.push(`CIF ${this.data.cifNumber}`);
if (this.data.mobileNo) refs.push(`Mobile ${this.data.mobileNo}`);
if (this.data.emailAddress) refs.push(`Email ${this.data.emailAddress}`);
if (dispatch?.trackingId) refs.push(`Tracking ID ${dispatch.trackingId}`);
// Height is measured off the same offsets the text below uses, so the card closes a fixed margin under the
// last line instead of leaving a gap that grows with a short address.
const addrStartY = 27;
const addrEndY = addrStartY + (addressLines.length - 1) * 6.4;
const cardH = refs.length > 0 ? addrEndY + 5 + refs.length * 5 + 4 : addrEndY + 9;
doc.setFillColor(252, 253, 255);
doc.setDrawColor(...lineColor);
doc.setLineWidth(0.3);
doc.roundedRect(cardX, cardY, cardW, cardH, 2, 2, 'FD');
// Accent spine marks the addressee edge without boxing the text in.
doc.setFillColor(...primaryColor);
doc.rect(cardX, cardY + 1, 1.6, cardH - 2, 'F');
doc.setFontSize(7);
doc.setTextColor(...grayColor);
doc.text('ALIGN THIS BLOCK WITH THE ENVELOPE WINDOW', cardX, cardY - 3.5);
doc.setFontSize(7.5);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...primaryColor);
doc.text('TO', padX, cardY + 10);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(20, 20, 20);
doc.text(name, padX, cardY + 19);
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.setTextColor(45, 45, 45);
let addrY = cardY + addrStartY;
addressLines.forEach((line: string) => {
doc.text(line, padX, addrY);
addrY += 6.4;
});
// Reference details close the same card, separated by a hairline rather than a second frame.
if (refs.length > 0) {
const dividerY = cardY + addrEndY + 5;
doc.setDrawColor(...lineColor);
doc.setLineWidth(0.2);
doc.line(padX, dividerY, cardX + cardW - 9, dividerY);
doc.setFontSize(8.5);
doc.setTextColor(...grayColor);
let refY = dividerY + 5;
refs.forEach((line) => {
doc.text(line, padX, refY);
refY += 5;
});
}
// --- Fold line: hairline broken around a centred label, so it guides the fold without competing with
// the address block above it ---
const foldY = cardY + cardH + 22;
const foldGap = 12;
doc.setDrawColor(226, 230, 236);
doc.setLineWidth(0.15);
doc.setLineDashPattern(
[
1,
2.5
],
0
);
doc.line(30, foldY, pageWidth / 2 - foldGap, foldY);
doc.line(pageWidth / 2 + foldGap, foldY, pageWidth - 30, foldY);
doc.setLineDashPattern([], 0);
doc.setFontSize(6.5);
doc.setTextColor(178, 184, 194);
doc.text('FOLD', pageWidth / 2, foldY + 1.6, { align: 'center' });
// --- Welcome message, below the fold ---
let msgY = foldY + 14;
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...primaryColor);
doc.text('Welcome to CBSNexora', 22, msgY);
msgY += 10;
doc.setFontSize(10.5);
doc.setFont('helvetica', 'normal');
doc.setTextColor(40, 40, 40);
doc.text(`Dear ${name},`, 22, msgY);
msgY += 8;
const body = [
'Thank you for opening your account with us. Enclosed is your Welcome Kit containing your account documentation and the essential materials you need to start banking with us.',
'Please keep your account number and CIF safe, and do not share your banking credentials with anyone. Our staff will never ask you for your PIN, password or OTP.',
'If anything in this kit is missing or incorrect, contact your branch or call our customer service desk and we will put it right.'
];
body.forEach((paragraph) => {
const lines = doc.splitTextToSize(paragraph, pageWidth - 44);
doc.text(lines, 22, msgY);
msgY += lines.length * 5.6 + 5;
});
msgY += 4;
doc.text('Warm regards,', 22, msgY);
msgY += 6;
doc.setFont('helvetica', 'bold');
doc.text('CBSNexora Operations Team', 22, msgY);
const stamp = this.datePipe.transform(new Date(), 'yyyyMMdd-HHmmss');
doc.save(`WelcomeKit_Label_${this.data.accountNo || this.data.clientId}_${stamp}.pdf`);
}
}
@@ -104,221 +104,230 @@
<span class="kv-table__col kv-table__col--action">Action</span>
</div>
<!-- PAN Row -->
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
<span class="kv-table__col kv-table__col--status">
@if (kycData.panVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (panDeclarationTaken) {
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
} @else {
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.panVerified
? kycData.panVerifiedOn
? formatDate(kycData.panVerifiedOn)
: '—'
: panDeclarationTaken && kycData.panDeclarationTakenOn
? formatDate(kycData.panDeclarationTakenOn)
: '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.pan || !kycData.panNumber"
(click)="verifyPAN()"
>
@if (verifying.pan) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
<!--
PAN and Aadhaar below are the individual's own OVDs. An entity holds neither: its PAN is
the Enterprise PAN reported further down (the same stored panNumber, under the name the
entity actually knows it by), and Aadhaar is issued to people, not companies — the masked
number on a non-individual record is a placeholder the KYC step writes to satisfy the
column, so showing it as "Pending" invited officers to chase a document that cannot exist.
-->
@if (!isNonIndividual) {
<!-- PAN Row -->
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
<span class="kv-table__col kv-table__col--status">
@if (kycData.panVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (panDeclarationTaken) {
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
} @else {
Re-Verify
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</button>
<!--
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.panVerified
? kycData.panVerifiedOn
? formatDate(kycData.panVerifiedOn)
: '—'
: panDeclarationTaken && kycData.panDeclarationTakenOn
? formatDate(kycData.panDeclarationTakenOn)
: '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.pan || !kycData.panNumber"
(click)="verifyPAN()"
>
@if (verifying.pan) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Re-Verify
}
</button>
<!--
A PAN that has never passed is not always fixable by another attempt. Offer the
declaration here too, so an existing client sitting in the exception list can be
cleared without the officer having to run a verification they know will fail.
-->
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="!kycId || saving"
(click)="takePanDeclaration()"
>
Declaration
</button>
}
</span>
</div>
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="!kycId || saving"
(click)="takePanDeclaration()"
>
Declaration
</button>
}
</span>
</div>
<!--
<!--
PAN is unverified but a declaration is on file. Shown in full because it is the only
reason PAN carries no KYC exception — a reviewer has to see what was declared.
-->
@if (!kycData.panVerified && panDeclarationTaken) {
<div class="kv-decl">
<div class="kv-decl__head">
<mat-icon>assignment_turned_in</mat-icon>
<span class="kv-decl__title">PAN Declaration</span>
@if (!saving) {
<span class="kv-decl__actions">
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
Edit
</button>
<button
mat-stroked-button
type="button"
class="kv-action-btn danger"
(click)="removePanDeclaration()"
>
Remove
</button>
</span>
}
</div>
<div class="kv-decl__rows">
<div class="kv-decl__row">
<span class="k">Does not match</span>
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
@if (!kycData.panVerified && panDeclarationTaken) {
<div class="kv-decl">
<div class="kv-decl__head">
<mat-icon>assignment_turned_in</mat-icon>
<span class="kv-decl__title">PAN Declaration</span>
@if (!saving) {
<span class="kv-decl__actions">
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
Edit
</button>
<button
mat-stroked-button
type="button"
class="kv-action-btn danger"
(click)="removePanDeclaration()"
>
Remove
</button>
</span>
}
</div>
@if (kycData.panDeclarationNameOnPan) {
<div class="kv-decl__rows">
<div class="kv-decl__row">
<span class="k">Name on PAN</span>
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
<span class="k">Does not match</span>
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
</div>
}
@if (kycData.panDeclarationDobOnPan) {
<div class="kv-decl__row">
<span class="k">DOB on PAN</span>
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
</div>
}
@if (kycData.panDeclarationRemarks) {
<div class="kv-decl__row">
<span class="k">Declaration file</span>
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
</div>
}
@if (kycData.panDeclarationNameOnPan) {
<div class="kv-decl__row">
<span class="k">Name on PAN</span>
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
</div>
}
@if (kycData.panDeclarationDobOnPan) {
<div class="kv-decl__row">
<span class="k">DOB on PAN</span>
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
</div>
}
@if (kycData.panDeclarationRemarks) {
<div class="kv-decl__row">
<span class="k">Declaration file</span>
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
</div>
}
</div>
<div class="kv-decl__note">
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
</div>
</div>
<div class="kv-decl__note">
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
</div>
</div>
}
}
<!-- Aadhaar Row -->
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
<span class="kv-table__col kv-table__col--status">
@if (kycData.aadhaarVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (aadhaarReceiptTaken) {
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
} @else {
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.aadhaarVerified
? kycData.aadhaarVerifiedOn
? formatDate(kycData.aadhaarVerifiedOn)
: '—'
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
? formatDate(kycData.aadhaarReceiptTakenOn)
: '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
(click)="startAadhaarReVerify()"
>
Re-Verify
</button>
}
<!--
<!-- Aadhaar Row -->
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
<span class="kv-table__col kv-table__col--status">
@if (kycData.aadhaarVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else if (aadhaarReceiptTaken) {
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
} @else {
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.aadhaarVerified
? kycData.aadhaarVerifiedOn
? formatDate(kycData.aadhaarVerifiedOn)
: '—'
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
? formatDate(kycData.aadhaarReceiptTakenOn)
: '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
(click)="startAadhaarReVerify()"
>
Re-Verify
</button>
}
<!--
No Aadhaar number means the OTP check has nothing to run against. Offer the enrolment
slip here so an existing client sitting in the exception list can be cleared.
-->
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="!kycId || saving"
(click)="takeAadhaarReceipt()"
>
Receipt
</button>
}
</span>
</div>
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="!kycId || saving"
(click)="takeAadhaarReceipt()"
>
Receipt
</button>
}
</span>
</div>
<!--
<!--
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
only reason Aadhaar carries no KYC exception.
-->
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
<div class="kv-rcpt">
<div class="kv-rcpt__head">
<mat-icon>receipt_long</mat-icon>
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
@if (!saving) {
<span class="kv-rcpt__actions">
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
Edit
</button>
<button
mat-stroked-button
type="button"
class="kv-action-btn danger"
(click)="removeAadhaarReceipt()"
>
Remove
</button>
</span>
}
</div>
<div class="kv-rcpt__rows">
<div class="kv-rcpt__row">
<span class="k">Enrolment ID</span>
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
<div class="kv-rcpt">
<div class="kv-rcpt__head">
<mat-icon>receipt_long</mat-icon>
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
@if (!saving) {
<span class="kv-rcpt__actions">
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
Edit
</button>
<button
mat-stroked-button
type="button"
class="kv-action-btn danger"
(click)="removeAadhaarReceipt()"
>
Remove
</button>
</span>
}
</div>
@if (kycData.aadhaarEnrolmentDate) {
<div class="kv-rcpt__rows">
<div class="kv-rcpt__row">
<span class="k">Enrolment date</span>
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
<span class="k">Enrolment ID</span>
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
</div>
}
@if (kycData.aadhaarReceiptRemarks) {
<div class="kv-rcpt__row">
<span class="k">Remarks</span>
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
</div>
}
@if (kycData.aadhaarEnrolmentDate) {
<div class="kv-rcpt__row">
<span class="k">Enrolment date</span>
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
</div>
}
@if (kycData.aadhaarReceiptRemarks) {
<div class="kv-rcpt__row">
<span class="k">Remarks</span>
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
</div>
}
</div>
<div class="kv-rcpt__note">
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
</div>
</div>
<div class="kv-rcpt__note">
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
</div>
</div>
}
}
<!--
@@ -425,23 +434,59 @@
</div>
}
<!-- Enterprise PAN Row (Non-Individual) -->
@if (isNonIndividual && kycData?.enterprisePan) {
<!--
Enterprise PAN Row (Non-Individual) — the entity's only PAN, so it stands in for the PAN
row hidden above and carries that row's verification state and Re-Verify action.
Status comes from panVerified, not from an enterprisePanVerified flag: the record has no
such field (ClientKycData holds enterprisePan as a bare string), and the KYC step stores
the entity PAN in panNumber and verifies it through the same verify-pan command. Reading
the flag that does not exist left a verified entity PAN permanently showing "Pending".
-->
@if (isNonIndividual && enterprisePanNumber) {
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">credit_card</mat-icon> Enterprise PAN
</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.enterprisePan }}</span>
<span class="kv-table__col kv-table__col--num kv-mono">{{ enterprisePanNumber }}</span>
<span class="kv-table__col kv-table__col--status">
@if (kycData.enterprisePanVerified) {
@if (kycData.panVerified) {
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
} @else {
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
}
</span>
<span class="kv-table__col kv-table__col--date">{{
kycData.enterprisePanVerifiedOn ? formatDate(kycData.enterprisePanVerifiedOn) : '—'
kycData.panVerified && kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—'
}}</span>
<span class="kv-table__col kv-table__col--action">
<button
mat-stroked-button
class="kv-action-btn"
type="button"
[disabled]="verifying.pan"
(click)="verifyPAN()"
>
@if (verifying.pan) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Re-Verify
}
</button>
</span>
</div>
}
<!-- Date of Incorporation Row (Non-Individual) — captured alongside the entity PAN
it is verified against, so it is reported here rather than under personal data. -->
@if (isNonIndividual && kycData?.incorporationDate) {
<div class="kv-table__row">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">event_available</mat-icon> Date of Incorporation
</span>
<span class="kv-table__col kv-table__col--num">{{ formatDate(kycData.incorporationDate) }}</span>
<span class="kv-table__col kv-table__col--status">—</span>
<span class="kv-table__col kv-table__col--date">—</span>
<span class="kv-table__col kv-table__col--action"></span>
</div>
}
@@ -464,68 +509,61 @@
<span class="kv-table__col kv-table__col--action"></span>
</div>
}
<!-- Incorporation Date Row (Non-Individual) -->
@if (isNonIndividual && kycData?.incorporationDate) {
<div class="kv-table__row kv-table__row--link">
<span class="kv-table__col kv-table__col--doc">
<mat-icon class="kv-doc-icon">event</mat-icon> Incorporation Date
</span>
<span class="kv-table__col kv-table__col--num">{{ formatDate(kycData.incorporationDate) }}</span>
<span class="kv-table__col kv-table__col--status">—</span>
<span class="kv-table__col kv-table__col--date">—</span>
<span class="kv-table__col kv-table__col--action"></span>
</div>
}
</div>
<!-- ─── PAN ↔ Aadhaar Link — premium status card (replaces the legacy
table row). Dark gradient base with state-driven accent so the
linking state is the first thing a reviewer sees. -->
<div
class="pal-card"
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
[class.pal-card--pending]="
kycData?.panAadhaarSeedingStatus !== 'Y' &&
kycData?.panAadhaarSeedingStatus !== 'N' &&
kycData?.panAadhaarSeedingStatus !== 'E'
"
>
<div class="pal-card__left">
<div class="pal-card__icon">
<mat-icon>credit_card</mat-icon>
</div>
<div class="pal-card__txt">
<div class="pal-card__small">Verification Status</div>
<div class="pal-card__title">PAN &harr; Aadhaar</div>
<div class="pal-card__status">
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
<mat-icon>check_circle</mat-icon> Linked (Seeded)
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
<mat-icon>cancel</mat-icon> Not Linked
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon> Exempt
} @else {
<mat-icon>radio_button_unchecked</mat-icon> Pending
}
linking state is the first thing a reviewer sees.
Section 139AA seeding applies to a person's PAN and Aadhaar. An entity has no Aadhaar
to seed, so for a non-individual the card could only ever read "Pending" against a
requirement that does not apply to it. -->
@if (!isNonIndividual) {
<div
class="pal-card"
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
[class.pal-card--pending]="
kycData?.panAadhaarSeedingStatus !== 'Y' &&
kycData?.panAadhaarSeedingStatus !== 'N' &&
kycData?.panAadhaarSeedingStatus !== 'E'
"
>
<div class="pal-card__left">
<div class="pal-card__icon">
<mat-icon>credit_card</mat-icon>
</div>
<div class="pal-card__txt">
<div class="pal-card__small">Verification Status</div>
<div class="pal-card__title">PAN &harr; Aadhaar</div>
<div class="pal-card__status">
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
<mat-icon>check_circle</mat-icon> Linked (Seeded)
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
<mat-icon>cancel</mat-icon> Not Linked
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon> Exempt
} @else {
<mat-icon>radio_button_unchecked</mat-icon> Pending
}
</div>
</div>
</div>
<div class="pal-card__badge">
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
<mat-icon>check</mat-icon>
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
<mat-icon>close</mat-icon>
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon>
} @else {
<mat-icon>schedule</mat-icon>
}
</div>
<div class="pal-card__hint">As per Income Tax Portal &mdash; Section 139AA</div>
</div>
<div class="pal-card__badge">
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
<mat-icon>check</mat-icon>
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
<mat-icon>close</mat-icon>
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
<mat-icon>info</mat-icon>
} @else {
<mat-icon>schedule</mat-icon>
}
</div>
<div class="pal-card__hint">As per Income Tax Portal &mdash; Section 139AA</div>
</div>
}
<!-- Aadhaar OTP inline (shown below table when re-verifying) -->
@if (showAadhaarReVerify && !aadhaarOtpSent) {
@@ -795,15 +833,19 @@
<mat-card-content>
<form [formGroup]="kycForm" (ngSubmit)="saveKyc()">
<div class="kyc-edit-grid">
<!-- PAN -->
<!-- PAN — the same stored panNumber either way, named for whose PAN it is. -->
<div class="kyc-edit-field">
<div class="kyc-field-label-row">
<span class="kyc-field-title">{{ 'labels.inputs.PAN Number' | translate }}</span>
<span class="kyc-field-title">{{
isNonIndividual ? 'Enterprise PAN' : ('labels.inputs.PAN Number' | translate)
}}</span>
<span class="kyc-tag">KYC</span>
</div>
<div class="kyc-input-row">
<mat-form-field appearance="outline" class="kyc-input">
<mat-label>{{ 'labels.inputs.PAN Number' | translate }}</mat-label>
<mat-label>{{
isNonIndividual ? 'Enterprise PAN' : ('labels.inputs.PAN Number' | translate)
}}</mat-label>
<input
matInput
formControlName="panNumber"
@@ -852,147 +894,154 @@
The check can fail for reasons no retry will fix — a PAN card that spells the
name differently, or a vendor that is simply down. The declaration is the way
out, so it is offered here rather than leaving the officer at a dead end.
It records a mismatch against the customer's own name and date of birth, so it
is offered to individuals only — an entity PAN is checked against neither.
-->
<div class="kyc-decl-offer">
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
<button
mat-stroked-button
type="button"
[disabled]="!kycId || saving"
(click)="takePanDeclaration()"
>
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
</button>
</div>
@if (!isNonIndividual) {
<div class="kyc-decl-offer">
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
<button
mat-stroked-button
type="button"
[disabled]="!kycId || saving"
(click)="takePanDeclaration()"
>
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
</button>
</div>
}
}
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
}
</div>
<!-- Aadhaar -->
<div class="kyc-edit-field">
<div class="kyc-field-label-row">
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
<div class="kyc-input-row">
<mat-form-field appearance="outline" class="kyc-input">
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
<input
matInput
formControlName="aadhaarNumber"
maxlength="12"
inputmode="numeric"
autocomplete="off"
placeholder="Enter 12-digit Aadhaar"
[readonly]="aadhaarOtpSent"
/>
@if (kycData?.aadhaarNumberMasked) {
<mat-hint>On file: {{ kycData.aadhaarNumberMasked }}</mat-hint>
}
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
}
@if (verificationStatus.aadhaar === 'VERIFIED') {
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
}
@if (verificationStatus.aadhaar === 'FAILED') {
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
}
</mat-form-field>
@if (!aadhaarOtpSent) {
<button
mat-raised-button
color="accent"
type="button"
class="verify-btn"
[disabled]="
!kycId ||
!kycForm.get('aadhaarNumber')?.value ||
kycForm.get('aadhaarNumber')?.invalid ||
verifying.aadhaar
"
(click)="sendAadhaarOtp()"
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
>
@if (verifying.aadhaar) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
Send OTP
<!-- Aadhaar — individuals only; an entity has no Aadhaar to capture or verify. -->
@if (!isNonIndividual) {
<div class="kyc-edit-field">
<div class="kyc-field-label-row">
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
<span class="kyc-tag">KYC</span>
</div>
<div class="kyc-input-row">
<mat-form-field appearance="outline" class="kyc-input">
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
<input
matInput
formControlName="aadhaarNumber"
maxlength="12"
inputmode="numeric"
autocomplete="off"
placeholder="Enter 12-digit Aadhaar"
[readonly]="aadhaarOtpSent"
/>
@if (kycData?.aadhaarNumberMasked) {
<mat-hint>On file: {{ kycData.aadhaarNumberMasked }}</mat-hint>
}
</button>
}
</div>
<!-- OTP Section in Edit Mode -->
@if (aadhaarOtpSent) {
<div class="otp-section">
<p class="otp-info">
<mat-icon class="otp-info-icon">sms</mat-icon>
OTP sent to Aadhaar-linked mobile
</p>
<div class="otp-inline">
<mat-form-field appearance="outline" class="otp-field">
<mat-label>Enter OTP</mat-label>
<input
matInput
[(ngModel)]="aadhaarOtp"
[ngModelOptions]="{ standalone: true }"
maxlength="6"
autocomplete="one-time-code"
/>
</mat-form-field>
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
}
@if (verificationStatus.aadhaar === 'VERIFIED') {
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
}
@if (verificationStatus.aadhaar === 'FAILED') {
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
}
</mat-form-field>
@if (!aadhaarOtpSent) {
<button
mat-raised-button
color="accent"
class="otp-verify-btn"
type="button"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()"
class="verify-btn"
[disabled]="
!kycId ||
!kycForm.get('aadhaarNumber')?.value ||
kycForm.get('aadhaarNumber')?.invalid ||
verifying.aadhaar
"
(click)="sendAadhaarOtp()"
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
@if (verifying.aadhaar) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
Verify OTP
Send OTP
}
</button>
<button
mat-stroked-button
type="button"
class="otp-resend-btn"
[disabled]="verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
Resend
</button>
</div>
}
</div>
}
@if (verificationError.aadhaar) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.aadhaar }}</span>
</div>
<!--
<!-- OTP Section in Edit Mode -->
@if (aadhaarOtpSent) {
<div class="otp-section">
<p class="otp-info">
<mat-icon class="otp-info-icon">sms</mat-icon>
OTP sent to Aadhaar-linked mobile
</p>
<div class="otp-inline">
<mat-form-field appearance="outline" class="otp-field">
<mat-label>Enter OTP</mat-label>
<input
matInput
[(ngModel)]="aadhaarOtp"
[ngModelOptions]="{ standalone: true }"
maxlength="6"
autocomplete="one-time-code"
/>
</mat-form-field>
<button
mat-raised-button
color="accent"
class="otp-verify-btn"
type="button"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Verify OTP
}
</button>
<button
mat-stroked-button
type="button"
class="otp-resend-btn"
[disabled]="verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
Resend
</button>
</div>
</div>
}
@if (verificationError.aadhaar) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
<span>{{ verificationError.aadhaar }}</span>
</div>
<!--
A failed OTP is not always something a retry can fix — a customer who has only
applied for Aadhaar has no number to verify at all. The enrolment slip is the way
out, so it is offered here rather than leaving the officer at a dead end.
-->
<div class="kyc-decl-offer">
<span>Customer has applied for Aadhaar but not received it yet?</span>
<button
mat-stroked-button
type="button"
[disabled]="!kycId || saving"
(click)="takeAadhaarReceipt()"
>
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
</button>
</div>
}
</div>
<div class="kyc-decl-offer">
<span>Customer has applied for Aadhaar but not received it yet?</span>
<button
mat-stroked-button
type="button"
[disabled]="!kycId || saving"
(click)="takeAadhaarReceipt()"
>
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
</button>
</div>
}
</div>
}
<!-- GSTIN (Non-Individual only) -->
@if (isNonIndividual) {
@@ -221,6 +221,17 @@ export class KycTabComponent implements OnInit, OnDestroy {
return this.kycData?.panDeclarationTaken === true;
}
/**
* The entity's PAN, for the Enterprise PAN row a non-individual gets in place of the PAN row.
*
* Falls back to panNumber because that is where the KYC step actually stores it — the entity PAN
* is written to both fields on create, but a record saved before `enterprisePan` existed, or one
* saved from a screen that only writes panNumber, would otherwise show no PAN at all.
*/
get enterprisePanNumber(): string {
return this.kycData?.enterprisePan || this.kycData?.panNumber || '';
}
/** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */
get panDeclarationMismatchLabel(): string {
const fields: string = this.kycData?.panDeclarationMismatchFields;
@@ -222,6 +222,14 @@
<div class="pd-field__value monospace">{{ clientViewData.clientNonPersonDetails.incorpNumber }}</div>
</div>
}
@if (clientViewData.clientNonPersonDetails.dateOfRegistration) {
<div class="pd-field">
<div class="pd-field__label">{{ 'labels.inputs.Incorporation Date' | translate }}</div>
<div class="pd-field__value">
{{ clientViewData.clientNonPersonDetails.dateOfRegistration | dateFormat: 'DD-MM-YYYY' }}
</div>
</div>
}
@if (clientViewData.clientNonPersonDetails.remarks) {
<div class="pd-field full-width">
<div class="pd-field__label">{{ 'labels.inputs.Remarks' | translate }}</div>
@@ -275,26 +283,61 @@
</div>
<div class="pd-section__body">
<div class="pd-grid">
<!-- Primary Mobile DIP -->
<!--
Primary Mobile DIP. Driven by dipLiveStatus — the latest lookup — not by dipVerified,
which is frozen at onboarding and would keep reporting "Verified" long after the number
was reported for fraud or reassigned to someone else.
-->
<div class="pd-field">
<div class="pd-field__label">Mobile DIP Status</div>
<div class="pd-field__value">
@if (
clientViewData.mobileVerified === true ||
clientViewData.mobileVerified === 'true' ||
clientViewData.mobileVerified === 1 ||
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
) {
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
<span class="pd-verify-badge pd-verify-badge--flagged">
<i class="fa fa-exclamation-triangle"></i> {{ dipStatusLabel(clientViewData) }}
</span>
} @else if (isDipClear(clientViewData)) {
<span class="pd-verify-badge pd-verify-badge--verified">
<i class="fa fa-check-circle"></i> Verified
</span>
} @else {
<span class="pd-verify-badge pd-verify-badge--pending"> <i class="fa fa-clock-o"></i> Pending </span>
<span class="pd-verify-badge pd-verify-badge--pending">
<i class="fa fa-clock-o"></i> {{ dipStatusLabel(clientViewData) }}
</span>
}
</div>
</div>
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
<div class="pd-field pd-field--full">
<div class="pd-dip-alert">
<i class="fa fa-exclamation-triangle"></i>
<div>
<strong>This mobile number is flagged on the DoT Digital Intelligence Platform.</strong>
<div class="pd-dip-alert__detail">
OTPs and SMS alerts to this number are blocked. Contact the customer through an alternate channel and
obtain a new number. Account operations are not restricted.
</div>
</div>
</div>
</div>
}
<!--
A sanctions alert raised after onboarding is far more serious than a DIP hit and must be
impossible to miss on the customer's own page, not only in the Principal Officer's queue.
-->
@if (clientViewData.sanctionsAlertStatus) {
<div class="pd-field pd-field--full">
<div
class="pd-dip-alert"
[class.pd-dip-alert--review]="clientViewData.sanctionsAlertStatus === 'PENDING_REVIEW'"
>
<i class="fa fa-exclamation-triangle"></i>
<div>
<strong>{{ sanctionsAlertHeadline(clientViewData) }}</strong>
<div class="pd-dip-alert__detail">{{ sanctionsAlertDetail(clientViewData) }}</div>
</div>
</div>
</div>
}
@if (clientViewData.dipTimestamp) {
<div class="pd-field">
<div class="pd-field__label">DIP Verified On</div>
@@ -306,7 +349,12 @@
<div class="pd-field">
<div class="pd-field__label">Alternate Mobile DIP Status</div>
<div class="pd-field__value">
@if (
@if (isDipFlagged(clientViewData.alternateDipLiveStatus)) {
<span class="pd-verify-badge pd-verify-badge--flagged">
<i class="fa fa-exclamation-triangle"></i> {{ alternateDipStatusLabel(clientViewData) }}
</span>
} @else if (
clientViewData.alternateDipLiveStatus === 'CLEAN' ||
clientViewData.alternateDipVerified === true ||
clientViewData.alternateDipVerified === 'true' ||
clientViewData.alternateDipVerified === 1
@@ -248,9 +248,59 @@ $teal: #00897b;
border: 1px solid #fde68a;
}
&--danger {
&--danger,
&--flagged {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
}
// A DIP hit on an existing customer restricts their NUMBER, not their account, so it reads as a
// standing advisory across the full width rather than as a blocked-account state.
.pd-field--full {
grid-column: 1 / -1;
}
.pd-dip-alert {
display: flex;
align-items: flex-start;
gap: 10px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 6px;
padding: 10px 14px;
font-size: 13px;
color: #7f1d1d;
line-height: 1.5;
i {
color: #dc2626;
font-size: 15px;
margin-top: 2px;
flex-shrink: 0;
}
&__detail {
margin-top: 3px;
font-weight: 400;
color: #991b1b;
}
// An unreviewed hit is not yet a finding, so it reads as amber rather than red.
&--review {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
color: #92400e;
i {
color: #d97706;
}
.pd-dip-alert__detail {
color: #b45309;
}
}
}
@@ -32,6 +32,14 @@ interface ClientViewData {
displayName?: string;
mobileNo?: string;
emailAddress?: string;
/**
* Current DIP/MNRL status resolved from the latest lookup, as opposed to the `dipVerified`
* flag captured when the client was onboarded.
*/
dipLiveStatus?: string;
alternateDipLiveStatus?: string;
/** Disposition of the latest flagged sanctions alert, or absent when the client carries none. */
sanctionsAlertStatus?: string;
dateOfBirth?: number[];
gender?: { id: number; name: string; active?: boolean };
clientType?: { id: number; name: string; active?: boolean };
@@ -67,6 +75,91 @@ export class PersonalDataTabComponent {
/** Client View Data */
clientViewData!: ClientViewData;
/**
* Whether the number's CURRENT DIP/MNRL status is a hit.
*
* The onboarding-time `dipVerified` flag never changes, so a customer cleared when they joined
* kept reading "Verified" even after their number was reported for fraud or reassigned. These
* helpers read `dipLiveStatus`, which the API resolves from the most recent lookup.
*/
isDipFlagged(liveStatus: string | null | undefined): boolean {
return !!liveStatus && liveStatus !== 'CLEAN' && liveStatus !== 'ERROR';
}
/** True only when the number is affirmatively clean — not merely "not flagged". */
isDipClear(client: any): boolean {
if (client?.dipLiveStatus) {
return client.dipLiveStatus === 'CLEAN';
}
return (
client?.mobileVerified === true ||
client?.mobileVerified === 'true' ||
client?.mobileVerified === 1 ||
client?.dipVerified === true ||
client?.dipVerified === 'true' ||
client?.dipVerified === 1
);
}
dipStatusLabel(client: any): string {
return this.labelFor(client?.dipLiveStatus, this.isDipClear(client));
}
/** Headline for the sanctions banner, driven by how far the alert has progressed. */
sanctionsAlertHeadline(client: any): string {
switch (client?.sanctionsAlertStatus) {
case 'CONFIRMED_MATCH':
return 'Confirmed UN Security Council sanctions match.';
case 'FALSE_POSITIVE':
return 'Sanctions hit reviewed as a false positive.';
default:
return 'Sanctions match awaiting Principal Officer review.';
}
}
sanctionsAlertDetail(client: any): string {
switch (client?.sanctionsAlertStatus) {
case 'CONFIRMED_MATCH':
return (
'Debits are frozen on this client’s deposit accounts and the case has been escalated. ' +
'Report to FIU-IND and the nodal officer within 24 hours if not already done.'
);
case 'FALSE_POSITIVE':
return 'Recorded as a different person from the listed individual. See the Sanctions Logs for the reasoning.';
default:
return (
'This client matched the UN Security Council Consolidated List on a later screening. ' +
'Review the alert under Reports → Sanctions Screening & Alerts before transacting.'
);
}
}
alternateDipStatusLabel(client: any): string {
const clear =
client?.alternateDipLiveStatus === 'CLEAN' ||
client?.alternateDipVerified === true ||
client?.alternateDipVerified === 'true' ||
client?.alternateDipVerified === 1;
return this.labelFor(client?.alternateDipLiveStatus, clear);
}
private labelFor(liveStatus: string | null | undefined, clear: boolean): string {
switch (liveStatus) {
case 'SUSPECTED':
return 'Flagged — Suspected';
case 'DISCONNECTED':
return 'Flagged — Disconnected';
case 'SUSPECTED_AND_DISCONNECTED':
return 'Flagged — Suspected & Disconnected';
case 'SERVICE_UNAVAILABLE':
return 'Not Verified';
case 'CLEAN':
return 'Verified';
default:
return clear ? 'Verified' : 'Pending';
}
}
constructor() {
this.route.parent.data.pipe(takeUntilDestroyed()).subscribe((data: { clientViewData: ClientViewData }) => {
this.clientViewData = data.clientViewData;
+35 -2
View File
@@ -855,6 +855,30 @@ export class ClientsService {
return this.http.post('/sanctions/screen', { name });
}
/**
* Records the reviewing officer's assessment of a flagged screening.
*
* A FALSE_POSITIVE does not unblock onboarding on its own — it only becomes effective once
* the Principal Officer approves it from the Sanctions Logs screen.
*
* @param logId screening log id returned by screenSanctions()
* @param disposition 'FALSE_POSITIVE' | 'CONFIRMED_MATCH'
* @param reason which identifiers were compared and why they do/don't match
*/
recordSanctionsDisposition(logId: number, disposition: string, reason: string): Observable<any> {
return this.http.post(`/sanctions/alerts/${logId}/disposition`, { disposition, reason });
}
/**
* Records the officer's decision on a DIP/MNRL lookup that came back flagged or unverifiable.
* Until PROCEEDED is stored, the client-create gate refuses to save the number.
*
* @param decision 'PROCEEDED' | 'REJECTED'
*/
recordDipDecision(logId: number, decision: string, reason: string): Observable<any> {
return this.http.post(`/lookup/logs/${logId}/decision`, { decision, reason });
}
/**
* Updates existing KYC details for a client.
* @param clientId Client ID
@@ -915,6 +939,15 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {});
}
/**
* Verifies CIN against MCA (reads the CIN from the stored KYC record, so it must be saved first).
* @param clientId Client ID
* @param kycId KYC record ID
*/
verifyCIN(clientId: string, kycId: string): Observable<any> {
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-cin`, {});
}
/**
* Verifies DIN (Director Identification Number) for an authorized signatory.
* Sends signatory clientId, DIN number and the non-individual entity name.
@@ -982,8 +1015,8 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/welcomekit`, data);
}
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
updateWelcomeKitStatus(welcomeKitId: number, data: any): Observable<any> {
return this.http.put(`/welcomekit/${welcomeKitId}`, data);
}
getKycExceptions(resolved = false): Observable<any[]> {
@@ -153,6 +153,12 @@
[isMinor]="clientGeneralStep?.isMinor || false"
[isNonIndividual]="legalFormType === 2"
[readOnly]="isTabReadOnly(addressTabIdx)"
[kycData]="clientKycStep?.kycForm?.value"
[kycDocCode]="clientKycStep?.selectedKycDoc?.code"
[kycDocFiles]="clientKycStep?.kycDocFiles || null"
[kycDocMergedPreview]="clientKycStep?.kycDocMergedPreview || null"
[existingKycDocSides]="clientKycStep?.existingKycDocSides || null"
[existingKycIdentifierId]="clientKycStep?.existingKycIdentifierId || null"
></mifosx-client-india-address-step>
</div>
</mat-tab>
@@ -214,6 +214,8 @@ export class CreateClientComponent {
if (idx === 0) {
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
if (this.clientGeneralStep.sanctionsBlocked) return false;
// A number the officer rejected on the DIP result must be replaced, not carried forward.
if (this.clientGeneralStep.dipRejected || this.clientGeneralStep.alternateDipRejected) return false;
if (!this.clientGeneralForm.valid) return false;
// Contact-OTP gate: mobileNo/emailAddress must each be OTP-verified before a brand-new
// client can be created — mirrors the edit flow's dual-OTP gate in EditClientComponent.
@@ -273,7 +275,17 @@ export class CreateClientComponent {
if (this.currentStepValid) return '';
const idx = this.selectedTabIndex;
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
return 'Client failed sanctions screening — cannot proceed.';
const disposition = this.clientGeneralStep.sanctionsDisposition;
if (disposition === 'FALSE_POSITIVE') {
return 'False-positive review submitted — awaiting Principal Officer approval before onboarding can continue.';
}
if (disposition === 'CONFIRMED_MATCH') {
return 'Confirmed UNSC sanctions match — onboarding is blocked and the case has been escalated.';
}
return 'Sanctions match found — review the match and record an outcome before proceeding.';
}
if (idx === 0 && (this.clientGeneralStep?.dipRejected || this.clientGeneralStep?.alternateDipRejected)) {
return 'The mobile number was rejected on the DIP/MNRL result — enter a different number and verify it.';
}
if (idx === 0 && this.clientGeneralForm?.valid && !this.clientGeneralStep?.contactVerified) {
return 'Verify the OTP sent to both the mobile number and email address before proceeding.';
@@ -384,7 +396,8 @@ export class CreateClientComponent {
this.dialog
.open(VerifyClientAlertDialogComponent, {
width: '420px',
autoFocus: false
autoFocus: false,
data: { isEntity: this.legalFormType === 2 }
})
.afterClosed()
.subscribe(() => {
@@ -1159,8 +1172,8 @@ export class SaveConfirmDialogComponent {}
</div>
<div class="vcad-body">
<p>
Please click <strong>Verify Client</strong> to screen the client name against the UN Security Council
Consolidated Sanctions List before proceeding.
Please click <strong>{{ data?.isEntity ? 'Verify Entity' : 'Verify Client' }}</strong> to screen the client
name against the UN Security Council Consolidated Sanctions List before proceeding.
</p>
<div class="vcad-info">
<mat-icon class="vcad-info__icon">gavel</mat-icon>
@@ -1269,4 +1282,6 @@ export class SaveConfirmDialogComponent {}
MatDialogClose
]
})
export class VerifyClientAlertDialogComponent {}
export class VerifyClientAlertDialogComponent {
data = inject(MAT_DIALOG_DATA);
}
@@ -215,11 +215,18 @@
</div>
}
<div class="tab-section">
<!-- constitutionMeta and entityName are bound here exactly as they are in create-client.
Without them an entity opened for edit falls back to the generic document list rather
than the one its constitution actually uses, so the document type saved at creation
can be missing from the picker; a proprietorship is also mis-rendered, since the
proprietor-PAN branch keys off the constitution. -->
<mifosx-client-kyc-step
#clientKyc
[isNonIndividual]="legalFormType === 2"
[isMinor]="clientGeneralStep?.isMinor || false"
[clientId]="clientId"
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
[verificationName]="clientGeneralStep?.verificationName || ''"
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
[readOnly]="isTabReadOnly(1)"
@@ -282,6 +289,12 @@
[isMinor]="clientGeneralStep?.isMinor || false"
[isNonIndividual]="legalFormType === 2"
[readOnly]="isTabReadOnly(addressTabIdx)"
[kycData]="clientKyc?.kycForm?.value"
[kycDocCode]="clientKyc?.selectedKycDoc?.code"
[kycDocFiles]="clientKyc?.kycDocFiles || null"
[kycDocMergedPreview]="clientKyc?.kycDocMergedPreview || null"
[existingKycDocSides]="clientKyc?.existingKycDocSides || null"
[existingKycIdentifierId]="clientKyc?.existingKycIdentifierId || null"
></mifosx-client-india-address-step>
</div>
</mat-tab>
@@ -40,6 +40,7 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util';
import { toProperNameCase } from '../utils/name-case.util';
/**
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
@@ -380,7 +381,14 @@ export class EditClientComponent {
if (this.savingClient) return 'Saving — please wait…';
if (this.currentStepValid) return '';
if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) {
return 'Client failed sanctions screening — cannot proceed.';
const disposition = this.clientGeneralStep.sanctionsDisposition;
if (disposition === 'FALSE_POSITIVE') {
return 'False-positive review submitted — awaiting Principal Officer approval before this can be saved.';
}
if (disposition === 'CONFIRMED_MATCH') {
return 'Confirmed UNSC sanctions match — the case has been escalated and saving is blocked.';
}
return 'Sanctions match found — review the match and record an outcome before proceeding.';
}
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
@@ -589,13 +597,11 @@ export class EditClientComponent {
const kycId = this.clientKycStep.kycId;
const isVerified = this.clientKycStep.isKycVerified;
// Validate remarks are present for verified KYC
if (isVerified && !kycData.remarks?.trim()) {
this.savingClient = false;
this.clientKycStep.kycForm.get('remarks')?.markAsTouched();
return;
}
// Remarks are not demanded here. The server requires them only when a save actually
// overwrites data the verified record already held, and it names the reason when it
// refuses. Blocking every verified record up front aborted the save silently — the photo
// upload above had already gone through, so the image changed, the button came back to
// life, and nothing said why nothing had been saved.
let saveObs;
if (kycId && isVerified) {
// VERIFIED → re-edit command
@@ -628,8 +634,19 @@ export class EditClientComponent {
this.saveKycDocProof(this.clientId);
this.selectedTabIndex = this.selectedTabIndex + 1;
},
error: () => {
// A rejected save used to just re-enable the button, which reads as "nothing happened".
// The server's own message says what is missing — remarks on a re-edit, most often — so
// it is shown rather than swallowed, and the KYC tab stays put for the officer to fix.
error: (err: any) => {
this.savingClient = false;
const message =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'KYC could not be saved. Please review the KYC details and try again.';
this.snackBar.open(message, 'Close', { duration: 6000 });
if (isVerified) {
this.clientKycStep?.kycForm.get('remarks')?.markAsTouched();
}
}
});
} else {
@@ -734,17 +751,20 @@ export class EditClientComponent {
? this.toLocalDateString(oldDobRaw)
: oldDobRaw || '';
// Both sides run through toProperNameCase so a casing-only difference — the stored
// 'LINCPAY LTD' against the normalized 'Lincpay LTD' the form now submits — is not
// mistaken for a real rename and does not force a PAN re-verification.
const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
(
toProperNameCase(
parts.fullname ||
[
parts.firstname,
parts.middlename,
parts.lastname
]
.filter((p) => p && p.trim())
.join(' ')
).trim();
[
parts.firstname,
parts.middlename,
parts.lastname
]
.filter((p) => p && p.trim())
.join(' ')
);
const newName = buildName({
firstname: form?.get('firstname')?.value || '',
@@ -0,0 +1,88 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { ENTITY_KYC_CATEGORY, ENTITY_KYC_DOCUMENT_TYPES, withEntityKycDocuments } from './entity-kyc-documents';
describe('withEntityKycDocuments', () => {
const names = (docs: { name: string }[]) => docs.map((d) => d.name);
it('keeps the six RBI entity documents when nothing else is supplied', () => {
expect(names(withEntityKycDocuments())).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
});
it('puts the entity documents ahead of anything the caller adds', () => {
const merged = withEntityKycDocuments([{ name: 'Declaration by Karta', mandatory: true }], 'HUF');
expect(names(merged.slice(0, 6))).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
expect(merged[6].name).toBe('Declaration by Karta');
});
it('groups the entity documents together and the rest under the constitution', () => {
const merged = withEntityKycDocuments([{ name: 'Declaration by Karta' }], 'HUF');
expect(merged[0].category).toBe(ENTITY_KYC_CATEGORY);
expect(merged[6].category).toBe('HUF documents');
});
it('folds the backend template names onto the entity chips instead of duplicating them', () => {
const merged = withEntityKycDocuments(
[
{ name: 'Certificate of Incorporation', code: 'COI', mandatory: true },
{ name: 'Memorandum of Association', code: 'MOA', mandatory: true },
{ name: 'Articles of Association', code: 'AOA', mandatory: true },
{ name: 'PAN Card of Company', code: 'PAN', mandatory: true },
{ name: 'Board Resolution', code: 'BOARD_RESOLUTION', mandatory: true },
{ name: 'KYC of Authorized Signatories', code: 'SIGNATORY_KYC', mandatory: true }
],
'Private Limited Company'
);
expect(merged).toHaveLength(6);
expect(names(merged)).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
});
it('folds constitution labels on too, since those lists carry no codes', () => {
const merged = withEntityKycDocuments(
[
{ name: 'Certificate of Incorporation', code: 'Certificate of Incorporation', mandatory: true },
{ name: 'LLP Agreement', code: 'LLP Agreement', mandatory: true },
{ name: 'PAN of LLP', code: 'PAN of LLP', mandatory: true },
{ name: 'Designated Partners Resolution', code: 'Designated Partners Resolution', mandatory: true },
{ name: 'KYC of Designated Partners / Authorized Signatories', code: 'x', mandatory: true }
],
'Limited Liability Partnership (LLP)'
);
expect(merged).toHaveLength(6);
});
it('carries a mandatory flag over to the entity document it folds into', () => {
const merged = withEntityKycDocuments([{ name: 'PAN Card of Company', code: 'PAN', mandatory: true }]);
expect(merged.find((d) => d.code === 'ENTITY_PAN')?.mandatory).toBe(true);
expect(merged.find((d) => d.code === 'GST_CERTIFICATE')?.mandatory).toBe(false);
});
it('keeps a document that only mentions another one as its own requirement', () => {
const merged = withEntityKycDocuments(
[{ name: 'Proof of Business Existence (GST Certificate / Shop License)', mandatory: true }],
'Sole Proprietorship'
);
expect(names(merged)).toContain('Proof of Business Existence (GST Certificate / Shop License)');
});
it('keeps constitution-specific requirements the entity list does not cover', () => {
const merged = withEntityKycDocuments(
[
{ name: 'Government Notification / Order', mandatory: true },
{ name: 'Udyam Registration Certificate', code: 'UDYAM', mandatory: true },
{ name: 'Proof of Address of HUF' }],
'Government Body / PSU'
);
expect(names(merged).slice(6)).toEqual([
'Government Notification / Order',
'Udyam Registration Certificate',
'Proof of Address of HUF'
]);
});
});
@@ -0,0 +1,138 @@
/**
* 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/.
*/
/** Shape the KYC document-type picker works with. */
export interface KycDocumentType {
name: string;
code: string;
mandatory: boolean;
category?: string;
}
export const ENTITY_KYC_CATEGORY = 'Entity KYC documents';
/**
* The KYC documents every legal entity must produce, whatever its constitution — RBI Master
* Direction on KYC, Annexure I. Codes match PROOF_META in kyc-proof-types.ts, which is what
* groups them under "Entity KYC documents" and supplies each chip's banner text.
*
* This is the single source for the entity list so a non-individual client sees the same
* documents wherever the picker is rendered — the create stepper, the edit stepper, before a
* constitution has been chosen and after, and once the backend KYC template has loaded.
*/
export const ENTITY_KYC_DOCUMENT_TYPES: { name: string; code: string }[] = [
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
];
/**
* Codes the backend KYC template uses for documents the entity list already covers
* (ClientKycApiResource.getRequiredDocuments). Folding them onto the entity codes keeps one
* chip per document instead of "PAN Card of Company" sitting next to "PAN Card of Entity".
*/
const ENTITY_DOC_CODE_ALIASES: Record<string, string> = {
CERT_INCORPORATION: 'CERT_INCORPORATION',
COI: 'CERT_INCORPORATION',
REG_CERT: 'CERT_INCORPORATION',
ENTITY_PAN: 'ENTITY_PAN',
PAN: 'ENTITY_PAN',
CONSTITUTIVE_DOC: 'CONSTITUTIVE_DOC',
MOA: 'CONSTITUTIVE_DOC',
AOA: 'CONSTITUTIVE_DOC',
PARTNERSHIP_DEED: 'CONSTITUTIVE_DOC',
TRUST_DEED: 'CONSTITUTIVE_DOC',
BOARD_RESOLUTION: 'BOARD_RESOLUTION',
AUTH_SIGNATORY_KYC: 'AUTH_SIGNATORY_KYC',
SIGNATORY_KYC: 'AUTH_SIGNATORY_KYC',
GST_CERTIFICATE: 'GST_CERTIFICATE',
GST_CERT: 'GST_CERTIFICATE'
};
/**
* The per-constitution lists carry no codes of their own (code === name), so those entries are
* matched on the label instead. Patterns are anchored deliberately: a document that merely
* mentions another one ("Proof of Business Existence (GST Certificate / Shop License)") is a
* distinct requirement and has to survive as its own chip rather than vanish into GST.
*/
const ENTITY_DOC_NAME_PATTERNS: { pattern: RegExp; code: string }[] = [
{ pattern: /^certificate of incorporation\b/, code: 'CERT_INCORPORATION' },
{ pattern: /^registration certificate\b/, code: 'CERT_INCORPORATION' },
{
pattern: /^(memorandum|articles of association|partnership deed|trust deed|llp agreement|by-?laws)\b/,
code: 'CONSTITUTIVE_DOC'
},
{ pattern: /^pan\b/, code: 'ENTITY_PAN' },
{ pattern: /^(board resolution|designated partners resolution|resolution authorizing)\b/, code: 'BOARD_RESOLUTION' },
{ pattern: /^(kyc of|authorized signatory kyc|proprietor'?s kyc)\b/, code: 'AUTH_SIGNATORY_KYC' },
{ pattern: /^gst (registration )?certificate\b/, code: 'GST_CERTIFICATE' }
];
/** The entity document this one duplicates, or null when it is a requirement of its own. */
function resolveEntityDocCode(doc: { name?: string; code?: string }): string | null {
const code = (doc.code || '').toUpperCase().trim();
// A constitution list stores the label in `code`, so only treat it as a code when it is one.
if (code && !code.includes(' ') && ENTITY_DOC_CODE_ALIASES[code]) {
return ENTITY_DOC_CODE_ALIASES[code];
}
const name = (doc.name || '').toLowerCase().trim();
return ENTITY_DOC_NAME_PATTERNS.find((p) => p.pattern.test(name))?.code || null;
}
/**
* Puts the RBI entity documents in front of whatever list a caller has (per-constitution docs,
* or the backend KYC template) instead of letting that list replace them.
*
* Without this the officer sees a different set of documents on every screen for the same
* client: the entity list until a constitution is picked, the constitution's own list after,
* and the backend template's list once the client exists and is reopened for edit. Anything in
* `extra` that isn't already one of the six stays, grouped under `extraCategory` — those are
* genuine constitution-specific requirements (Karta declaration, Udyam certificate, government
* notification) and dropping them would lose real requirements.
*
* A mandatory flag on a duplicate carries over to the entity chip it folds into, so the
* "at least one mandatory document" validator still sees it.
*/
export function withEntityKycDocuments(
extra: { name: string; code?: string; mandatory?: boolean }[] = [],
extraCategory?: string | null
): KycDocumentType[] {
const mandatoryCodes = new Set<string>();
const leftovers: KycDocumentType[] = [];
extra.forEach((doc) => {
const entityCode = resolveEntityDocCode(doc);
if (entityCode) {
if (doc.mandatory) {
mandatoryCodes.add(entityCode);
}
return;
}
leftovers.push({
name: doc.name,
code: doc.code || doc.name,
mandatory: !!doc.mandatory,
category: extraCategory ? `${extraCategory} documents` : 'Additional documents'
});
});
const base: KycDocumentType[] = ENTITY_KYC_DOCUMENT_TYPES.map((d) => ({
name: d.name,
code: d.code,
mandatory: mandatoryCodes.has(d.code),
category: ENTITY_KYC_CATEGORY
}));
return [
...base,
...leftovers
];
}
@@ -0,0 +1,55 @@
/**
* 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 { toProperNameCase } from './name-case.util';
describe('toProperNameCase', () => {
it('raises a lower-case first letter on every word', () => {
expect(toProperNameCase('keshav Singh sisodiya')).toBe('Keshav Singh Sisodiya');
});
it('leaves the rest of each word exactly as typed', () => {
expect(toProperNameCase('lincpay lTD')).toBe('Lincpay LTD');
expect(toProperNameCase('lincpay pvt ltd')).toBe('Lincpay Pvt Ltd');
});
it('does not rewrite a name that is already correct', () => {
expect(toProperNameCase('Technoludium Solutions Private Limited')).toBe('Technoludium Solutions Private Limited');
});
it('leaves an upper-case name shouted rather than title-casing it', () => {
expect(toProperNameCase('TECHNOLUDIUM SOLUTIONS')).toBe('TECHNOLUDIUM SOLUTIONS');
expect(toProperNameCase('KESHAV singh')).toBe('KESHAV Singh');
});
it('capitalises after a hyphen', () => {
expect(toProperNameCase('jean-marie traders')).toBe('Jean-Marie Traders');
});
it('capitalises name prefixes after an apostrophe but not possessives', () => {
expect(toProperNameCase("o'brien d'souza")).toBe("O'Brien D'Souza");
expect(toProperNameCase("lincpay's agency")).toBe("Lincpay's Agency");
});
it('leaves punctuation and non-letters untouched', () => {
expect(toProperNameCase('abc pvt. ltd.')).toBe('Abc Pvt. Ltd.');
expect(toProperNameCase('tata & sons')).toBe('Tata & Sons');
expect(toProperNameCase('3m india')).toBe('3m India');
});
it('trims and collapses whitespace', () => {
expect(toProperNameCase(' lincpay holdings ')).toBe('Lincpay Holdings');
});
it('returns an empty string for blank or non-string input', () => {
expect(toProperNameCase('')).toBe('');
expect(toProperNameCase(' ')).toBe('');
expect(toProperNameCase(null)).toBe('');
expect(toProperNameCase(undefined)).toBe('');
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* 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/.
*/
/**
* Casing for client names: the name is stored exactly as the officer typed it, except that a word
* starting with a lower-case letter has that first letter raised. Nothing else is touched.
*
* 'keshav Singh sisodiya' → 'Keshav Singh Sisodiya'
* 'lincpay lTD' → 'Lincpay LTD'
* 'Technoludium Solutions Private Limited' → unchanged
* 'TECHNOLUDIUM SOLUTIONS' → unchanged
*
* Deliberately NOT full title-casing. Lower-casing the rest of each word would rewrite an entity
* name the officer entered as it appears on the certificate — 'TECHNOLUDIUM' becoming
* 'Technoludium', 'Ltd' becoming a forced 'LTD'. That name is matched character-for-character
* against the PAN holder's registered name during KYC verification, so the typed spelling is the
* thing to preserve; only an unintended lower-case first letter is corrected.
*/
/** Characters after which a new word starts: 'jean-marie' → 'Jean-Marie'. */
const SEGMENT_STARTERS = new Set([
'-',
"'",
'’'
]);
/**
* Raises the first letter of each word in a single space-delimited token, leaving every other
* character as typed.
*/
function capitalizeWord(word: string): string {
let result = '';
for (let i = 0; i < word.length; i++) {
const previous = i === 0 ? '' : word[i - 1];
let startsWord = i === 0 || SEGMENT_STARTERS.has(previous);
if (previous === "'" || previous === '’') {
// 'o'brien' / 'd'souza' capitalise after the apostrophe, but a possessive
// ('lincpay's') must not — only a single leading letter marks the name prefix.
startsWord = i === 2;
}
result += startsWord ? word[i].toUpperCase() : word[i];
}
return result;
}
/**
* Applies the casing rule above to a person or entity name.
* Trims and collapses runs of whitespace; non-string input yields ''.
*/
export function toProperNameCase(value: string | null | undefined): string {
if (typeof value !== 'string') {
return '';
}
const collapsed = value.trim().replace(/\s+/g, ' ');
if (!collapsed) {
return '';
}
return collapsed.split(' ').map(capitalizeWord).join(' ');
}
+48 -2
View File
@@ -607,6 +607,16 @@
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
-->
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening &amp; Alerts
</button>
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
@@ -1038,14 +1048,16 @@
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/recurring-deposits']">
<fa-icon icon="sync" class="menu-item-icon"></fa-icon> Recurring
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS</button>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS
</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Agent</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Agent Commission
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-collection']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Today Collection
</button>
<button mat-menu-item tabindex="0">
@@ -1060,6 +1072,9 @@
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-reports']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Reports
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
</button>
@@ -1235,6 +1250,13 @@
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details
</button>
</div>
<!-- Dividend Management -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Dividend Management</div>
<button mat-menu-item routerLink="/clerk/dividend-dashboard" tabindex="0">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
</div>
</div>
</mat-menu>
@@ -1369,6 +1391,20 @@
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
</button>
<button
mat-menu-item
tabindex="0"
routerLink="/loan-origination/pipeline"
[queryParams]="{ stage: 'CREDIT_COMMITTEE' }"
>
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Loan Committee
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/eligibility-checklist">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> RBI Eligibility Checklist
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/rbi-rules-reference">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> RBI Rules Reference
</button>
</div>
<!-- Reports -->
<div class="deposit-menu-group">
@@ -1646,6 +1682,16 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> Digital Transaction UTR
</button>
<!-- Full data export. Hidden unless the user can raise a request; the approval itself is a
separate permission the backend enforces. -->
<button
mat-menu-item
routerLink="/letter-mgmt/database-export"
tabindex="0"
*mifosxHasPermission="'READ_DATABASEEXPORT'"
>
<fa-icon icon="database" class="menu-item-icon" style="color: #b45309"></fa-icon> Database Export
</button>
</div>
</div>
</mat-menu>
@@ -0,0 +1,241 @@
<!--
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/.
-->
<div class="dte-container">
<div class="dte-header">
<button mat-stroked-button [routerLink]="['../']" class="dte-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="dte-header__title">Daily Transaction Detail</h2>
</div>
<div class="dte-card">
<form [formGroup]="entryForm">
<div class="dte-info-panels">
<div class="dte-info-panel">
<div class="dte-info-panel__title">Agent Information</div>
<dl>
<dt>Name</dt>
<dd>{{ agentInfo.name || '—' }}</dd>
<dt>Address</dt>
<dd>{{ agentInfo.address || '—' }}</dd>
<dt>City</dt>
<dd>{{ agentInfo.city || '—' }}</dd>
<dt>A/C</dt>
<dd>{{ agentInfo.accountNo || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor1 || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor2 || '—' }}</dd>
<dt>Curr Bal</dt>
<dd>{{ agentInfo.currentBalance | number: '1.2-2' }}</dd>
</dl>
</div>
<div class="dte-info-panel">
<div class="dte-info-panel__title">Party Information</div>
<p class="dte-info-panel__hint">Populated once a party is selected in a collection row below.</p>
</div>
</div>
<div class="dte-controls">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Agent</mat-label>
<input matInput required formControlName="agentId" />
</mat-form-field>
<button mat-stroked-button type="button" (click)="loadAgent()"><i class="fas fa-search"></i> Load</button>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Date</mat-label>
<input matInput required [matDatepicker]="txnDatePicker" formControlName="transactionDate" />
<mat-datepicker-toggle matIconSuffix [for]="txnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #txnDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Tran. Type</mat-label>
<mat-select required formControlName="transactionType">
@for (type of transactionTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (isPartyTran) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-list"></i>
<h3>Today's Collections</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="addCollectionRow()">
<i class="fas fa-plus"></i> Add Row
</button>
</div>
@if (collections.length === 0) {
<p class="dte-empty">No collection rows added yet.</p>
}
@for (row of collections.controls; track row; let i = $index) {
<div class="dte-collection-row" [formGroup]="$any(row)">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Party Code</mat-label>
<input matInput required formControlName="partyCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>TRTP</mat-label>
<mat-select required formControlName="transactionMode">
@for (mode of transactionModes; track mode) {
<mat-option [value]="mode">{{ mode }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>AC Type</mat-label>
<input matInput formControlName="accountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>A/C No.</mat-label>
<input matInput required formControlName="accountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput required type="number" formControlName="amount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Cheque No</mat-label>
<input matInput formControlName="chequeNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Token No</mat-label>
<input matInput formControlName="tokenNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="description" />
</mat-form-field>
<button
mat-icon-button
type="button"
class="dte-remove-row"
(click)="removeCollectionRow(i)"
matTooltip="Remove row"
>
<i class="fas fa-trash"></i>
</button>
</div>
}
</div>
}
@if (isAgentTran) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-hand-holding-usd"></i>
<h3>Agent Settlement</h3>
</div>
<p class="dte-info-panel__hint">
Cash the agent hands over to the branch, deducted from the agent's collected balance shown above.
</p>
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput required type="number" formControlName="settlementAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>TRTP</mat-label>
<mat-select required formControlName="settlementMode">
@for (mode of transactionModes; track mode) {
<mat-option [value]="mode">{{ mode }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>AC Type</mat-label>
<input matInput formControlName="settlementAccountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>A/C No.</mat-label>
<input matInput formControlName="settlementAccountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="settlementDescription" />
</mat-form-field>
</div>
</div>
}
@if (isWithdrawDeposite) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-exchange-alt"></i>
<h3>Withdraw Deposite</h3>
</div>
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<input matInput formControlName="creditorPartyCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Name</mat-label>
<input matInput formControlName="creditorPartyName" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<input matInput formControlName="debitPartyCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Name</mat-label>
<input matInput formControlName="debitPartyName" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput type="number" formControlName="withdrawAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="withdrawDescription" />
</mat-form-field>
</div>
</div>
}
@if (!isPartyTran && !isWithdrawDeposite && !isAgentTran) {
<div class="dte-section">
<p class="dte-empty">
"{{ entryForm.get('transactionType').value }}" entry screen — same shape as Party Tran, to be filled in once
the backend distinguishes these transaction types.
</p>
</div>
}
<div class="dte-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
<button mat-stroked-button type="button" (click)="viewLedger()">View Ledger</button>
<button mat-stroked-button type="button" (click)="signature()">Signature</button>
<button mat-raised-button color="primary" type="button" [disabled]="entryForm.invalid" (click)="save()">
Save
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,187 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #0f766e;
.dte-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px;
}
.dte-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.dte-back {
color: $primary;
}
.dte-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px 24px 24px;
}
.dte-info-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.dte-info-panel {
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 8px;
padding: 12px 16px;
background: rgb(15 118 110 / 4%);
&__title {
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $primary;
margin-bottom: 8px;
}
&__hint {
margin: 0;
color: rgb(0 0 0 / 50%);
font-size: 13px;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
margin: 0;
}
dt {
font-weight: 500;
color: rgb(0 0 0 / 60%);
font-size: 13px;
}
dd {
margin: 0;
font-size: 13px;
}
}
.dte-controls {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.dte-field {
min-width: 180px;
&--wide {
grid-column: span 2;
}
}
.dte-section {
padding: 16px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
}
}
.dte-add-row {
color: $primary;
}
.dte-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0;
}
.dte-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.dte-collection-row {
display: grid;
grid-template-columns: repeat(7, 1fr) auto;
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
}
.dte-collection-row .dte-field--wide {
grid-column: span 2;
}
.dte-remove-row {
color: #b91c1c;
margin-top: 8px;
}
.dte-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
flex-wrap: wrap;
}
@media (width <= 1000px) {
.dte-info-panels {
grid-template-columns: 1fr;
}
.dte-grid {
grid-template-columns: repeat(2, 1fr);
}
.dte-collection-row {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -0,0 +1,290 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/**
* Daily Transaction Entry component.
*
* The screen a collection agent uses at the end of a day's round: pick a
* transaction type, then batch-enter that day's collections against
* assigned parties (Party Tran), settle collected cash to the branch
* (Agent Tran), or record a withdrawal/transfer between accounts (Withdraw
* Deposite). Modelled directly on the legacy Daily Transaction Detail
* screen this replaces.
*
* Backed by the DDS Party Transaction API (`/dds/partytransactions`) for
* Party Tran, Agent Tran and Withdraw Deposite - the three types this
* screen actually collects fields for; Party Payment / OD Entry / OD
* Payment remain placeholders until the backend distinguishes them too.
* "Retrieve" (a ready-made list of today's assigned parties) isn't wired -
* that needs a dedicated per-agent Party lookup this MVP doesn't have yet.
*/
@Component({
selector: 'mifosx-daily-transaction-entry',
templateUrl: './daily-transaction-entry.component.html',
styleUrls: ['./daily-transaction-entry.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class DailyTransactionEntryComponent {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private static readonly TRANSACTION_TYPE_CODES: Record<string, string> = {
'Party Tran': 'PARTY_TRAN',
'Agent Tran': 'AGENT_TRAN',
'Withdraw Deposite': 'WITHDRAW_DEPOSITE'
};
transactionTypes = [
'Party Tran',
'Agent Tran',
'Party Payment',
'OD Entry',
'OD Payment',
'Withdraw Deposite'
];
transactionModes = [
'Cash',
'Transfer',
'Clearing'
];
entryForm: UntypedFormGroup = this.formBuilder.group({
agentId: [
'',
Validators.required
],
transactionType: [
'Party Tran',
Validators.required
],
transactionDate: [
new Date(),
Validators.required
],
// Withdraw Deposite fields
creditorPartyCode: [''],
creditorPartyName: [
{
value: '',
disabled: true
}
],
creditorPartyBalance: [
{
value: 0,
disabled: true
}
],
debitPartyCode: [''],
debitPartyName: [
{
value: '',
disabled: true
}
],
debitPartyBalance: [
{
value: 0,
disabled: true
}
],
withdrawAmount: [0],
withdrawDescription: [''],
// Agent Tran fields
settlementAmount: [0],
settlementMode: ['Cash'],
settlementAccountType: [''],
settlementAccountNo: [''],
settlementDescription: [''],
collections: this.formBuilder.array([])
});
agentInfo = {
name: '',
address: '',
city: '',
accountNo: '',
guarantor1: '',
guarantor2: '',
currentBalance: 0
};
get isPartyTran(): boolean {
return this.entryForm.get('transactionType').value === 'Party Tran';
}
get isWithdrawDeposite(): boolean {
return this.entryForm.get('transactionType').value === 'Withdraw Deposite';
}
get isAgentTran(): boolean {
return this.entryForm.get('transactionType').value === 'Agent Tran';
}
get collections(): UntypedFormArray {
return this.entryForm.get('collections') as UntypedFormArray;
}
private buildCollectionRow(): UntypedFormGroup {
return this.formBuilder.group({
partyCode: [
'',
Validators.required
],
transactionMode: [
'Cash',
Validators.required
],
accountType: [''],
accountNo: [
'',
Validators.required
],
amount: [
0,
Validators.required
],
chequeNo: [''],
tokenNo: [''],
description: [''],
partyName: [
{
value: '',
disabled: true
}
],
balanceAfterTransaction: [
{
value: 0,
disabled: true
}
]
});
}
addCollectionRow(): void {
this.collections.push(this.buildCollectionRow());
}
removeCollectionRow(index: number): void {
this.collections.removeAt(index);
}
loadAgent(): void {
const agentId = this.entryForm.get('agentId')?.value;
if (!agentId) {
return;
}
this.organizationService.getAgent(agentId).subscribe((agent: any) => {
const guarantors = agent.guarantors || [];
this.agentInfo = {
name: agent.agentNameId || '',
address: agent.agentAddress1 || '',
city: agent.agentCity || '',
accountNo: agent.agentSavingAccountNo || '',
guarantor1: guarantors[0]?.guarantorName || '',
guarantor2: guarantors[1]?.guarantorName || '',
currentBalance: agent.agentCurrentBalance || 0
};
});
}
retrieve(): void {
// No "my assigned parties" read API exists yet — this will pre-fill
// the collections grid with today's assigned parties once available.
}
viewLedger(): void {
// No ledger view exists yet for this agent.
}
signature(): void {
// No signature capture/verification flow exists yet.
}
cancel(): void {
this.entryForm.reset({
transactionType: 'Party Tran',
transactionDate: new Date()
});
this.collections.clear();
}
exit(): void {
this.router.navigate(['/accounts/savings']);
}
save(): void {
const raw = this.entryForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const transactionType = DailyTransactionEntryComponent.TRANSACTION_TYPE_CODES[raw.transactionType];
const payload: any = {
agentId: Number(raw.agentId),
transactionType,
transactionDate: this.dateUtils.formatDate(raw.transactionDate, dateFormat),
dateFormat,
locale
};
if (this.isPartyTran) {
payload.collections = (raw.collections || []).map((row: any) => ({
partyCode: row.partyCode,
transactionMode: row.transactionMode,
accountType: row.accountType || undefined,
accountNo: row.accountNo,
amount: row.amount,
chequeNo: row.chequeNo || undefined,
tokenNo: row.tokenNo || undefined,
description: row.description || undefined
}));
} else if (this.isWithdrawDeposite) {
payload.creditorPartyCode = raw.creditorPartyCode;
payload.debitPartyCode = raw.debitPartyCode;
payload.withdrawAmount = raw.withdrawAmount;
payload.withdrawDescription = raw.withdrawDescription || undefined;
} else if (this.isAgentTran) {
payload.amount = raw.settlementAmount;
payload.transactionMode = raw.settlementMode;
payload.accountType = raw.settlementAccountType || undefined;
payload.accountNo = raw.settlementAccountNo || undefined;
payload.description = raw.settlementDescription || undefined;
}
this.organizationService.createPartyTransaction(payload).subscribe(() => {
this.collections.clear();
this.entryForm.patchValue({
creditorPartyCode: '',
debitPartyCode: '',
withdrawAmount: 0,
withdrawDescription: '',
settlementAmount: 0,
settlementAccountType: '',
settlementAccountNo: '',
settlementDescription: ''
});
this.loadAgent();
});
}
}
@@ -0,0 +1,33 @@
<!--
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/.
-->
<div class="dr-container">
<div class="dr-header">
<button mat-stroked-button [routerLink]="['../']" class="dr-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="dr-header__title">DDS Reports</h2>
</div>
@for (group of reportGroups; track group.title) {
<div class="dr-group">
<div class="dr-group__title">{{ group.title }}</div>
<div class="dr-grid">
@for (report of group.reports; track report) {
<a class="dr-card" [routerLink]="['view', report]">
<div class="dr-card__icon">
<i class="fas fa-file-alt"></i>
</div>
<div class="dr-card__title">{{ report }}</div>
<i class="fas fa-chevron-right dr-card__arrow"></i>
</a>
}
</div>
</div>
}
</div>
@@ -0,0 +1,97 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #0f766e;
.dr-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px;
}
.dr-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.dr-back {
color: $primary;
}
.dr-group {
margin-bottom: 24px;
&__title {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $primary;
font-weight: 700;
margin-bottom: 10px;
}
}
.dr-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.dr-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--md-sys-color-surface, #fff);
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 8px;
padding: 12px 14px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
&__icon {
color: $primary;
font-size: 16px;
flex: none;
}
&__title {
flex: 1;
font-size: 13px;
font-weight: 500;
}
&__arrow {
color: rgb(0 0 0 / 30%);
font-size: 12px;
}
}
@media (width <= 900px) {
.dr-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width <= 600px) {
.dr-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,73 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* DDS Reports hub component.
*
* Lists every DDS report from the legacy Reports menu, grouped the same
* way (As-On-Date, Interest, Daily/Balance/Master, Transaction). Each
* links to the shared report viewer, parameterised by report name.
*
* UI only for now: no report actually queries the backend yet — see
* ReportViewerComponent.
*/
@Component({
selector: 'mifosx-dds-reports',
templateUrl: './dds-reports.component.html',
styleUrls: ['./dds-reports.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class DdsReportsComponent {
reportGroups = [
{
title: 'As On Date',
reports: [
'Supplementary-DDS (As On)',
'Agent Check List',
'Party Check List-As On',
'Party Balance',
'Agent Wise Balance',
'Agent Wise Monthly Collection',
'Commission Register',
'Collection Summary Agent Wise',
'Close Party Ac Register',
'Agent Partywise Daily Collection',
'Daily Collection Balance',
'Interest Certificate',
'Mature Date Wise Report'
]
},
{
title: 'Interest',
reports: [
'Party Int. Register',
'Commission Register'
]
},
{
title: 'Daily / Balance / Master',
reports: [
'Daily Collection Report',
'Balance Summary',
'Master Data Export'
]
},
{
title: 'Transaction',
reports: ['Transaction Register']
},
{
title: 'Statements',
reports: ['Agent Commission Statement']
}
];
}
@@ -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/.
-->
<div class="rv-container">
<div class="rv-header">
<button mat-stroked-button [routerLink]="['../../']" class="rv-back">
<i class="fas fa-arrow-left"></i> DDS Reports
</button>
<h2 class="rv-header__title">{{ reportName }}</h2>
</div>
<div class="rv-card">
<form [formGroup]="filterForm">
<div class="rv-grid">
<mat-form-field appearance="outline" class="rv-field">
<mat-label>From Date</mat-label>
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>To Date</mat-label>
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent From</mat-label>
<input matInput type="number" formControlName="agentFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent To</mat-label>
<input matInput type="number" formControlName="agentTo" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party From</mat-label>
<input matInput type="number" formControlName="partyFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party To</mat-label>
<input matInput type="number" formControlName="partyTo" />
</mat-form-field>
</div>
<div class="rv-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-raised-button color="primary" type="button" [disabled]="filterForm.invalid" (click)="runReport()">
<i class="fas fa-play" style="margin-right: 6px"></i> Run Report
</button>
</div>
@if (hasRun) {
<div class="rv-results">
@if (!available) {
<p class="rv-empty">{{ unavailableMessage }}</p>
} @else if (rows.length === 0) {
<p class="rv-empty">No rows matched these filters.</p>
} @else {
<div class="rv-table-scroll">
<table class="rv-table">
<thead>
<tr>
@for (column of columns; track column) {
<th>{{ column }}</th>
}
</tr>
</thead>
<tbody>
@for (row of rows; track row) {
<tr>
@for (column of columns; track column) {
<td>{{ row[column] }}</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
</form>
</div>
</div>
@@ -0,0 +1,103 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #0f766e;
.rv-container {
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px;
}
.rv-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.rv-back {
color: $primary;
}
.rv-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px 24px 24px;
}
.rv-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.rv-field {
width: 100%;
}
.rv-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.rv-results {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.rv-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0;
text-align: center;
padding: 24px 0;
}
.rv-table-scroll {
overflow-x: auto;
}
.rv-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
th {
text-align: left;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
padding: 8px 10px;
border-bottom: 2px solid rgb(0 0 0 / 10%);
}
td {
padding: 8px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
}
}
@media (width <= 900px) {
.rv-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -0,0 +1,120 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { Dates } from 'app/core/utils/dates';
/**
* Generic DDS report viewer.
*
* One shared screen for all 13+ DDS reports — the report name comes from
* the route and is mapped to a backend report key. Every report in the
* legacy system shared the same basic filter shape (date, agent range,
* party range), so this avoids building a bespoke screen per report - the
* backend returns a generic {columns, rows} shape this renders as one
* table regardless of which report it is.
*
* One legacy report (Close Party Ac Register) has no corresponding data in
* the backend yet - Party accounts have no closed/open status at all. Run
* Report still calls through, and the backend's `available: false` +
* message is shown instead of a table.
*/
@Component({
selector: 'mifosx-dds-report-viewer',
templateUrl: './report-viewer.component.html',
styleUrls: ['./report-viewer.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class ReportViewerComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private formBuilder = inject(UntypedFormBuilder);
private organizationService = inject(OrganizationService);
private dateUtils = inject(Dates);
private static readonly REPORT_KEYS: Record<string, string> = {
'Supplementary-DDS (As On)': 'SUPPLEMENTARY_DDS',
'Agent Check List': 'AGENT_CHECK_LIST',
'Party Check List-As On': 'PARTY_CHECK_LIST',
'Party Balance': 'PARTY_BALANCE',
'Agent Wise Balance': 'AGENT_WISE_BALANCE',
'Agent Wise Monthly Collection': 'AGENT_WISE_MONTHLY_COLLECTION',
'Commission Register': 'COMMISSION_REGISTER',
'Collection Summary Agent Wise': 'COLLECTION_SUMMARY_AGENT_WISE',
'Close Party Ac Register': 'CLOSE_PARTY_AC_REGISTER',
'Agent Partywise Daily Collection': 'AGENT_PARTYWISE_DAILY_COLLECTION',
'Daily Collection Balance': 'DAILY_COLLECTION_REPORT',
'Interest Certificate': 'INTEREST_CERTIFICATE',
'Mature Date Wise Report': 'MATURE_DATE_WISE',
'Party Int. Register': 'PARTY_INTEREST_REGISTER',
'Daily Collection Report': 'DAILY_COLLECTION_REPORT',
'Balance Summary': 'BALANCE_SUMMARY',
'Master Data Export': 'MASTER_DATA_EXPORT',
'Transaction Register': 'TRANSACTION_REGISTER',
'Agent Commission Statement': 'AGENT_COMMISSION_STATEMENT'
};
reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report';
hasRun = false;
available = true;
unavailableMessage = '';
columns: string[] = [];
rows: Array<Record<string, any>> = [];
filterForm: UntypedFormGroup = this.formBuilder.group({
fromDate: [
'',
Validators.required
],
toDate: [
'',
Validators.required
],
agentFrom: [1],
agentTo: [99999],
partyFrom: [1],
partyTo: [99999999]
});
runReport(): void {
const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName];
if (!reportKey) {
return;
}
const raw = this.filterForm.getRawValue();
this.organizationService
.runDdsReport(reportKey, {
fromDate: this.dateUtils.getDate(raw.fromDate),
toDate: this.dateUtils.getDate(raw.toDate),
agentFrom: raw.agentFrom,
agentTo: raw.agentTo,
partyFrom: raw.partyFrom,
partyTo: raw.partyTo
})
.subscribe((result: any) => {
this.available = result.available;
this.unavailableMessage = result.message || '';
this.columns = result.columns || [];
this.rows = result.rows || [];
this.hasRun = true;
});
}
exit(): void {
this.router.navigate(['../../'], { relativeTo: this.route });
}
}
@@ -0,0 +1,543 @@
<!--
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/.
-->
<div class="pm-container">
<div class="pm-header">
<button mat-stroked-button [routerLink]="['../']" class="pm-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="pm-header__title">Party Master Entry</h2>
</div>
<div class="pm-card">
<form [formGroup]="partyForm">
<mat-tab-group class="pm-tabs" [(selectedIndex)]="selectedTabIndex" animationDuration="200ms">
<mat-tab label="Main Detail">
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-id-card"></i>
<h3>Identity</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Client ID</mat-label>
<input matInput required formControlName="clientId" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Sub Type</mat-label>
<mat-select required formControlName="subType">
@for (type of subTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Agent ID</mat-label>
<input matInput required formControlName="agentId" />
</mat-form-field>
@if (linkedSavingsAccountId) {
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Savings Account</mat-label>
<input matInput [value]="'Linked (id ' + linkedSavingsAccountId + ')'" disabled />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Savings Account No (if any)</mat-label>
<input matInput formControlName="savingsAccountNo" />
</mat-form-field>
}
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Party Code</mat-label>
<input matInput formControlName="partyCode" placeholder="Auto-generated" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Last A/c</mat-label>
<input matInput formControlName="lastAccount" placeholder="—" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Nominal</mat-label>
<mat-select required formControlName="nominal">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Party Name</mat-label>
<input matInput required formControlName="partyName" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Party Name 1</mat-label>
<input matInput formControlName="partyName1" />
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-map-marker-alt"></i>
<h3>Address &amp; Contact</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Address 1</mat-label>
<input matInput required formControlName="address1" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Address 2</mat-label>
<input matInput formControlName="address2" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Address 3</mat-label>
<input matInput formControlName="address3" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>City</mat-label>
<input matInput required formControlName="city" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Pincode</mat-label>
<input matInput required formControlName="pincode" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Gender</mat-label>
<mat-select formControlName="gender">
<mat-option value="Male">Male</mat-option>
<mat-option value="Female">Female</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Mobile No (SMS)</mat-label>
<input matInput required formControlName="mobileNoSms" />
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-shield-alt"></i>
<h3>KYC</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Aadhar No</mat-label>
<input matInput formControlName="aadharNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>PAN Card No</mat-label>
<input matInput formControlName="panCardNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>CKYC ID</mat-label>
<input matInput formControlName="ckycId" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Operation</mat-label>
<mat-select required formControlName="operation">
@for (op of operations; track op) {
<mat-option [value]="op">{{ op }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Birth Date</mat-label>
<input matInput [matDatepicker]="birthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="birthPicker"></mat-datepicker-toggle>
<mat-datepicker #birthPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Occupation</mat-label>
<mat-select formControlName="occupation">
@for (occ of occupations; track occ) {
<mat-option [value]="occ">{{ occ }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Risk</mat-label>
<mat-select formControlName="risk">
@for (r of riskLevels; track r) {
<mat-option [value]="r">{{ r }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-user-friends"></i>
<h3>Minor / Guardian</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Adult / Minor</mat-label>
<mat-select required formControlName="adultMinor">
@for (opt of adultMinorOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Minor Date</mat-label>
<input matInput [matDatepicker]="minorPicker" formControlName="minorDate" />
<mat-datepicker-toggle matIconSuffix [for]="minorPicker"></mat-datepicker-toggle>
<mat-datepicker #minorPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Guardian Client ID</mat-label>
<input matInput formControlName="guardianClientId" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Min. A/c Opt</mat-label>
<input matInput formControlName="minAccountOpt" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Relation</mat-label>
<input matInput formControlName="relation" />
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-calendar-day"></i>
<h3>Scheme Terms</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Opening Date</mat-label>
<input matInput required [matDatepicker]="openingPicker" formControlName="openingDate" />
<mat-datepicker-toggle matIconSuffix [for]="openingPicker"></mat-datepicker-toggle>
<mat-datepicker #openingPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Month</mat-label>
<input matInput type="number" formControlName="month" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Maturity Date</mat-label>
<input matInput formControlName="maturityDate" placeholder="Calculated" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Reopen Date</mat-label>
<input matInput [matDatepicker]="reopenPicker" formControlName="reopenDate" />
<mat-datepicker-toggle matIconSuffix [for]="reopenPicker"></mat-datepicker-toggle>
<mat-datepicker #reopenPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Deposit Amt</mat-label>
<input matInput type="number" formControlName="depositAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Daily Deposit Amt</mat-label>
<input matInput required type="number" formControlName="dailyDepositAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Days</mat-label>
<input matInput required type="number" formControlName="days" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Ledger No</mat-label>
<input matInput formControlName="ledgerNo" placeholder="Auto-generated" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Mat. Amt</mat-label>
<input matInput formControlName="maturityAmount" placeholder="Calculated" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Interest Rate</mat-label>
<input matInput formControlName="interestRate" placeholder="From product" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Withdraw Type</mat-label>
<mat-select formControlName="withdrawType">
@for (type of withdrawTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exclamation-triangle"></i>
<h3>Status &amp; Overdue Flags</h3>
</div>
<div class="pm-grid">
<div class="pm-checkbox-field">
<mat-checkbox formControlName="od1Flag">OD1 Flag</mat-checkbox>
</div>
<div class="pm-checkbox-field">
<mat-checkbox formControlName="od2Flag">OD2 Flag</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Lien</mat-label>
<mat-select formControlName="lien">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Pay Date</mat-label>
<input matInput [matDatepicker]="payDatePicker" formControlName="payDate" />
<mat-datepicker-toggle matIconSuffix [for]="payDatePicker"></mat-datepicker-toggle>
<mat-datepicker #payDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Payment Amount</mat-label>
<input matInput type="number" formControlName="paymentAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Current Bal</mat-label>
<input matInput formControlName="currentBalance" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Opening Bal</mat-label>
<input matInput type="number" formControlName="openingBalance" />
</mat-form-field>
<div class="pm-checkbox-field">
<mat-checkbox formControlName="isSmsActive">Is SMS Active</mat-checkbox>
</div>
<div class="pm-checkbox-field">
<mat-checkbox formControlName="isEmailActive">Is Email Active</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Int.Calc</mat-label>
<input matInput formControlName="interestCalc" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Interest Date</mat-label>
<input matInput [matDatepicker]="interestDatePicker" formControlName="interestDate" />
<mat-datepicker-toggle matIconSuffix [for]="interestDatePicker"></mat-datepicker-toggle>
<mat-datepicker #interestDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field pm-field--wide">
<mat-label>Sp.Description</mat-label>
<input matInput formControlName="spDescription" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Fr'z CD</mat-label>
<mat-select formControlName="freezeCode">
@for (code of freezeCodes; track code) {
<mat-option [value]="code">{{ code }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Adv Code</mat-label>
<mat-select formControlName="advCode">
@for (code of advCodes; track code) {
<mat-option [value]="code">{{ code }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
@if (partyForm.get('advCode').value === 'Transfer To Saving') {
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exchange-alt"></i>
<h3>Maturity Payout Routing</h3>
</div>
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Credit To Branch</mat-label>
<input matInput required formControlName="creditToBranch" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Credit To A/C Type</mat-label>
<input matInput required formControlName="creditToAccountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Credit To A/C</mat-label>
<input matInput required formControlName="creditToAccount" />
</mat-form-field>
</div>
</div>
}
</div>
</mat-tab>
<mat-tab label="Nominee's Detail">
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-grid">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Nominee</mat-label>
<mat-select formControlName="nomineeApplicable">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-radio-group formControlName="nomineeSplitMode" class="pm-radio-group">
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
</mat-radio-group>
<button mat-stroked-button type="button" class="pm-add-nominee" (click)="addNominee()">
<i class="fas fa-plus"></i> Add Nominee
</button>
</div>
@if (nominees.length === 0) {
<p class="pm-empty">No nominees added yet.</p>
}
@for (nominee of nominees.controls; track nominee; let i = $index) {
<div class="pm-nominee-row" [formGroup]="$any(nominee)">
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Nom.Dt</mat-label>
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
<mat-datepicker #nomDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Name</mat-label>
<input matInput required formControlName="name" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Mobile No</mat-label>
<input matInput formControlName="mobileNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Birth Dt</mat-label>
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
<mat-datepicker #nomBirthPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Minor</mat-label>
<mat-select required formControlName="minor">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Relation</mat-label>
<input matInput required formControlName="relation" />
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Gender</mat-label>
<mat-select required formControlName="gender">
<mat-option value="Male">Male</mat-option>
<mat-option value="Female">Female</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="pm-field">
<mat-label>Guar. Name</mat-label>
<input matInput formControlName="guardianName" />
</mat-form-field>
<button
mat-icon-button
type="button"
class="pm-remove-nominee"
(click)="removeNominee(i)"
matTooltip="Remove nominee"
>
<i class="fas fa-trash"></i>
</button>
</div>
}
</div>
</div>
</mat-tab>
</mat-tab-group>
@if (isClosed) {
<div class="pm-callout">
<i class="fas fa-lock"></i>
<span>This account is closed. No further collections or edits are possible.</span>
</div>
}
<div class="pm-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
@if (partyId && !isClosed) {
<button
mat-stroked-button
type="button"
color="warn"
matTooltip="Balance must be zero before closing"
(click)="closeAccount()"
>
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
</button>
}
<button
mat-raised-button
color="primary"
type="button"
[disabled]="partyForm.invalid || isClosed"
(click)="save()"
>
Save
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,164 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #0f766e;
.pm-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px;
}
.pm-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.pm-back {
color: $primary;
}
.pm-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.pm-tabs {
::ng-deep .mat-mdc-tab-body-wrapper {
min-height: 300px;
}
}
.pm-tab-content {
padding: 20px 24px;
}
.pm-section {
padding: 16px 0;
border-bottom: 1px solid rgb(0 0 0 / 8%);
&:last-of-type {
border-bottom: none;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
}
}
.pm-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px 16px;
align-items: start;
}
.pm-field {
width: 100%;
&--wide {
grid-column: span 2;
}
}
.pm-checkbox-field {
display: flex;
align-items: center;
height: 56px;
}
.pm-radio-group {
display: flex;
align-items: center;
gap: 16px;
height: 56px;
}
.pm-add-nominee {
color: $primary;
height: fit-content;
align-self: center;
}
.pm-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0 0 8px;
}
.pm-nominee-row {
display: grid;
grid-template-columns: repeat(8, 1fr) auto;
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
}
.pm-remove-nominee {
color: #b91c1c;
margin-top: 8px;
}
.pm-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.pm-callout {
display: flex;
align-items: center;
gap: 10px;
margin: 0 24px;
padding: 12px 16px;
background: rgb(185 28 28 / 8%);
border: 1px solid rgb(185 28 28 / 25%);
border-radius: 6px;
color: #b91c1c;
font-size: 0.9rem;
}
@media (width <= 1000px) {
.pm-grid {
grid-template-columns: repeat(2, 1fr);
}
.pm-nominee-row {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -0,0 +1,527 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatTabsModule } from '@angular/material/tabs';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioModule } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/**
* Party Master Entry component.
*
* The customer's DDS account — linked to an Agent for daily collection,
* carrying its own scheme terms, KYC, two-stage overdue flags, and a
* nominee list. Modelled directly on the legacy Party Master Entry screen
* this replaces.
*
* Backed by its own DDS Party API (`/dds/parties`). The underlying account
* does not yet reuse the Recurring Deposit account engine (see Product
* Setup) — that linkage is a follow-up once the RD-account-specific fields
* this screen doesn't collect (interest/compounding periods, chart
* selection, ...) have a home. Until then the scheme/balance fields here
* are the Party's own.
*
* "Close Account" (command=close) permanently closes the Party once its
* balance is zero - the backend rejects the command otherwise. A closed
* Party can no longer be edited or receive new Party Tran collections.
*
* savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of
* the same client (resolved by account number, since that's what an officer has to hand) -
* clarified with the business (2026-08-24) as "link it if the client already has one, otherwise
* leave it". There is no unlink once linked.
*/
@Component({
selector: 'mifosx-party-master',
templateUrl: './party-master.component.html',
styleUrls: ['./party-master.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTabsModule,
MatCheckbox,
MatRadioModule
]
})
export class PartyMasterComponent {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
partyId: number | null = null;
selectedTabIndex = 0;
partyStatus = 'ACTIVE';
linkedSavingsAccountId: number | null = null;
get isClosed(): boolean {
return this.partyStatus === 'CLOSED';
}
subTypes = [
'Daily Deposit Scheme',
'Masik Dhan Varsha Deposit'
];
operations = [
'Self',
'Guardian',
'Joint'
];
occupations = [
'Business',
'Service',
'Agriculture',
'Homemaker',
'Student',
'Retired',
'Other'
];
riskLevels = [
'Low',
'Medium',
'High'
];
adultMinorOptions = [
'Adult',
'Minor'
];
withdrawTypes = [
'Cash',
'Transfer',
'Clearing'
];
freezeCodes = [
'Normal',
'Debit Freeze',
'Credit Freeze',
'Total Freeze'
];
advCodes = [
'None',
'Transfer To Saving',
'Renew'
];
partyForm: UntypedFormGroup = this.formBuilder.group({
clientId: [
'',
Validators.required
],
subType: [
'',
Validators.required
],
agentId: [
'',
Validators.required
],
savingsAccountNo: [''],
lastAccount: [
{
value: '',
disabled: true
}
],
partyCode: [
{
value: '',
disabled: true
}
],
nominal: [
'No',
Validators.required
],
aadharNo: [''],
partyName: [
'',
Validators.required
],
partyName1: [''],
address1: [
'',
Validators.required
],
address2: [''],
address3: [''],
city: [
'',
Validators.required
],
pincode: [
'',
Validators.required
],
gender: [''],
panCardNo: [''],
telephone: [''],
mobileNoSms: [
'',
Validators.required
],
operation: [
'Self',
Validators.required
],
birthDate: [''],
occupation: [''],
risk: [''],
ckycId: [''],
adultMinor: [
'Adult',
Validators.required
],
minorDate: [''],
guardianClientId: [''],
minAccountOpt: [''],
relation: [''],
openingDate: [
'',
Validators.required
],
month: [''],
maturityDate: [
{
value: '',
disabled: true
}
],
reopenDate: [''],
depositAmount: [0],
dailyDepositAmount: [
0,
Validators.required
],
days: [
'',
Validators.required
],
ledgerNo: [
{
value: '',
disabled: true
}
],
maturityAmount: [
{
value: 0,
disabled: true
}
],
interestRate: [
{
value: 0,
disabled: true
}
],
withdrawType: [''],
od1Flag: [
{
value: false,
disabled: true
}
],
od2Flag: [
{
value: false,
disabled: true
}
],
lien: ['No'],
payDate: [''],
paymentAmount: [0],
currentBalance: [
{
value: 0,
disabled: true
}
],
openingBalance: [0],
isSmsActive: [true],
isEmailActive: [false],
interestCalc: [
{
value: 0,
disabled: true
}
],
interestDate: [''],
spDescription: [''],
freezeCode: ['Normal'],
advCode: ['None'],
creditToBranch: [''],
creditToAccountType: [''],
creditToAccount: [''],
nomineeApplicable: ['No'],
nomineeSplitMode: ['Percentage'],
nominees: this.formBuilder.array([])
});
get nominees(): UntypedFormArray {
return this.partyForm.get('nominees') as UntypedFormArray;
}
private buildNomineeGroup(): UntypedFormGroup {
return this.formBuilder.group({
nominationDate: [
'',
Validators.required
],
name: [
'',
Validators.required
],
mobileNo: [''],
birthDate: [''],
minor: [
'No',
Validators.required
],
relation: [
'',
Validators.required
],
gender: [
'',
Validators.required
],
guardianName: ['']
});
}
addNominee(): void {
this.nominees.push(this.buildNomineeGroup());
}
removeNominee(index: number): void {
this.nominees.removeAt(index);
}
retrieve(): void {
const clientId = this.partyForm.get('clientId')?.value;
if (!clientId) {
return;
}
this.organizationService.getParties().subscribe((parties: any[]) => {
const match = (parties || []).find((party) => String(party.clientId) === String(clientId));
if (match) {
this.patchFormFromParty(match);
}
});
}
cancel(): void {
this.partyId = null;
this.partyStatus = 'ACTIVE';
this.linkedSavingsAccountId = null;
this.partyForm.reset();
this.partyForm.patchValue({
nominal: 'No',
operation: 'Self',
adultMinor: 'Adult',
lien: 'No',
isSmsActive: true,
isEmailActive: false,
freezeCode: 'Normal',
advCode: 'None',
nomineeApplicable: 'No',
nomineeSplitMode: 'Percentage',
currentBalance: 0,
maturityAmount: 0,
interestRate: 0
});
this.nominees.clear();
}
exit(): void {
this.router.navigate(['/accounts/savings']);
}
save(): void {
const raw = this.partyForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const asDate = (value: any) => (value ? this.dateUtils.formatDate(value, dateFormat) : undefined);
const payload: any = {
clientId: Number(raw.clientId),
agentId: Number(raw.agentId),
savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined,
subType: raw.subType,
lastAccount: raw.lastAccount || undefined,
nominal: raw.nominal === 'Yes',
aadharNo: raw.aadharNo || undefined,
partyName: raw.partyName,
partyName1: raw.partyName1 || undefined,
address1: raw.address1,
address2: raw.address2 || undefined,
address3: raw.address3 || undefined,
city: raw.city,
pincode: raw.pincode,
gender: raw.gender || undefined,
panCardNo: raw.panCardNo || undefined,
telephone: raw.telephone || undefined,
mobileNoSms: raw.mobileNoSms,
operation: raw.operation,
birthDate: asDate(raw.birthDate),
occupation: raw.occupation || undefined,
risk: raw.risk || undefined,
ckycId: raw.ckycId || undefined,
adultMinor: raw.adultMinor,
minorDate: asDate(raw.minorDate),
guardianClientId: raw.guardianClientId ? Number(raw.guardianClientId) : undefined,
minAccountOpt: raw.minAccountOpt || undefined,
relation: raw.relation || undefined,
openingDate: asDate(raw.openingDate),
month: raw.month || undefined,
reopenDate: asDate(raw.reopenDate),
depositAmount: raw.depositAmount || undefined,
dailyDepositAmount: raw.dailyDepositAmount,
days: raw.days,
withdrawType: raw.withdrawType || undefined,
lien: raw.lien === 'Yes',
payDate: asDate(raw.payDate),
paymentAmount: raw.paymentAmount || undefined,
openingBalance: raw.openingBalance || 0,
isSmsActive: !!raw.isSmsActive,
isEmailActive: !!raw.isEmailActive,
interestDate: asDate(raw.interestDate),
spDescription: raw.spDescription || undefined,
freezeCode: raw.freezeCode || undefined,
advCode: raw.advCode || undefined,
creditToBranch: raw.creditToBranch || undefined,
creditToAccountType: raw.creditToAccountType || undefined,
creditToAccount: raw.creditToAccount || undefined,
nomineeApplicable: raw.nomineeApplicable === 'Yes',
nomineeSplitMode: raw.nomineeSplitMode || undefined,
dateFormat,
locale,
nominees: (raw.nominees || []).map((nominee: any) => ({
nominationDate: this.dateUtils.getDate(nominee.nominationDate),
name: nominee.name,
mobileNo: nominee.mobileNo || undefined,
birthDate: nominee.birthDate ? this.dateUtils.getDate(nominee.birthDate) : undefined,
minor: nominee.minor === 'Yes',
relation: nominee.relation,
gender: nominee.gender,
guardianName: nominee.guardianName || undefined
}))
};
if (this.partyId) {
this.organizationService.updateParty(this.partyId, payload).subscribe(() => this.exit());
} else {
this.organizationService.createParty(payload).subscribe(() => this.exit());
}
}
closeAccount(): void {
if (!this.partyId) {
return;
}
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
this.organizationService
.closeParty(this.partyId, {
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
dateFormat,
locale
})
.subscribe(() => this.exit());
}
private patchFormFromParty(party: any): void {
this.partyId = party.id;
this.partyStatus = party.status || 'ACTIVE';
this.linkedSavingsAccountId = party.savingsAccountId || null;
this.partyForm.patchValue({
clientId: party.clientId,
subType: party.subType,
agentId: party.agentId,
partyCode: party.partyCode,
lastAccount: party.lastAccount,
nominal: party.nominal ? 'Yes' : 'No',
aadharNo: party.aadharNo,
partyName: party.partyName,
partyName1: party.partyName1,
address1: party.address1,
address2: party.address2,
address3: party.address3,
city: party.city,
pincode: party.pincode,
gender: party.gender,
panCardNo: party.panCardNo,
telephone: party.telephone,
mobileNoSms: party.mobileNoSms,
operation: party.operation,
birthDate: party.birthDate ? new Date(party.birthDate) : null,
occupation: party.occupation,
risk: party.risk,
ckycId: party.ckycId,
adultMinor: party.adultMinor,
minorDate: party.minorDate ? new Date(party.minorDate) : null,
guardianClientId: party.guardianClientId,
minAccountOpt: party.minAccountOpt,
relation: party.relation,
openingDate: party.openingDate ? new Date(party.openingDate) : null,
month: party.month,
maturityDate: party.maturityDate,
reopenDate: party.reopenDate ? new Date(party.reopenDate) : null,
depositAmount: party.depositAmount,
dailyDepositAmount: party.dailyDepositAmount,
days: party.days,
ledgerNo: party.ledgerNo,
maturityAmount: party.maturityAmount,
interestRate: party.interestRate,
withdrawType: party.withdrawType,
od1Flag: party.od1Flag,
od2Flag: party.od2Flag,
lien: party.lien ? 'Yes' : 'No',
payDate: party.payDate ? new Date(party.payDate) : null,
paymentAmount: party.paymentAmount,
currentBalance: party.currentBalance,
openingBalance: party.openingBalance,
isSmsActive: party.isSmsActive,
isEmailActive: party.isEmailActive,
interestCalc: party.interestCalc,
interestDate: party.interestDate ? new Date(party.interestDate) : null,
spDescription: party.spDescription,
freezeCode: party.freezeCode,
advCode: party.advCode,
creditToBranch: party.creditToBranch,
creditToAccountType: party.creditToAccountType,
creditToAccount: party.creditToAccount,
nomineeApplicable: party.nomineeApplicable ? 'Yes' : 'No',
nomineeSplitMode: party.nomineeSplitMode
});
this.nominees.clear();
(party.nominees || []).forEach((nominee: any) => {
const group = this.buildNomineeGroup();
group.patchValue({
nominationDate: nominee.nominationDate ? new Date(nominee.nominationDate) : null,
name: nominee.name,
mobileNo: nominee.mobileNo,
birthDate: nominee.birthDate ? new Date(nominee.birthDate) : null,
minor: nominee.minor ? 'Yes' : 'No',
relation: nominee.relation,
gender: nominee.gender,
guardianName: nominee.guardianName
});
this.nominees.push(group);
});
}
}
+63
View File
@@ -347,6 +347,69 @@
<!-- STR -->
@if (activeTab === 'str') {
<!--
Sanctions STRs are a separate stream from the AML alerts below, not merged into them: an AML
alert is raised by a monetary transaction breaching a rule, whereas a sanctions STR is
triggered by who the customer is. Mixing them would put two different kinds of suspicion in
one queue and require inventing a transaction that never happened.
-->
@if (sanctionsStrCases.length) {
<div class="fiu-section">
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Sanctions STRs (UAPA s.51-A)</h3>
<p>
Confirmed matches against the UN Security Council Consolidated List. Accounts are frozen; the STR must
reach FIU-IND within 7 days of confirmation.
</p>
</div>
<select class="fiu-inp" [(ngModel)]="sanctionsStrFilter" (ngModelChange)="loadSanctionsStrCases()">
<option value="">All</option>
<option value="PENDING_FILING">Pending filing</option>
<option value="FILED">Filed</option>
</select>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>Alert</th>
<th>Client</th>
<th>Screened Name</th>
<th>Listed As</th>
<th>Confirmed On</th>
<th>Due</th>
<th>Status</th>
<th>STR Reference</th>
</tr>
</thead>
<tbody>
@for (str of sanctionsStrCases; track str.id) {
<tr>
<td class="mono">#{{ str.id }}</td>
<td>
@if (str.clientId) {
<a [routerLink]="['/clients', str.clientId, 'general']">{{ str.clientName || str.clientId }}</a>
} @else {
<span class="muted">Not onboarded</span>
}
</td>
<td>{{ str.screenedName }}</td>
<td>{{ str.matchedNames || '—' }}</td>
<td>{{ str.approvedOn | date: 'mediumDate' }}</td>
<td [class.str-overdue]="isStrOverdue(str)">{{ str.strDueDate || '—' }}</td>
<td>
<span [class]="strStatusClass(str)">{{ strStatusLabel(str) }}</span>
</td>
<td class="mono">{{ str.fiuStrReference || '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<div class="fiu-section">
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
<div class="fiu-form-meta">
+41
View File
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { AmlService } from 'app/aml/aml.service';
import { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import {
BRANCHES,
@@ -130,6 +131,7 @@ export class FiuComponent implements OnInit {
private static readonly SEED_VERSION = '2026-01-cash-threshold';
private amlService = inject(AmlService);
private sanctionsLogsService = inject(SanctionsLogsService);
private authenticationService = inject(AuthenticationService);
/** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
@@ -176,6 +178,13 @@ export class FiuComponent implements OnInit {
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
amlAlerts: any[] = [];
/**
* Confirmed UNSC sanctions matches carrying an STR obligation. Held separately from amlAlerts
* because they are identity-driven rather than transaction-driven — see the template comment.
*/
sanctionsStrCases: any[] = [];
sanctionsStrFilter = '';
amlAlertStatusFilter = '';
amlReviewNotes: Record<number, string> = {};
@@ -320,6 +329,37 @@ export class FiuComponent implements OnInit {
return this.failedFindings.length > 0 ? 'SUSPICIOUS' : 'NOT_SUSPICIOUS';
}
/** Loads confirmed sanctions matches that owe an STR. */
loadSanctionsStrCases() {
this.sanctionsLogsService.getStrCases(this.sanctionsStrFilter || undefined).subscribe({
next: (rows: any) => (this.sanctionsStrCases = rows || []),
// A failure here must not blank the AML side of the tab, which is unrelated.
error: () => (this.sanctionsStrCases = [])
});
}
strStatusLabel(str: any): string {
if (str?.strStatus === 'FILED') {
return 'FILED';
}
return this.isStrOverdue(str) ? 'OVERDUE' : 'PENDING FILING';
}
strStatusClass(str: any): string {
if (str?.strStatus === 'FILED') {
return 'fiu-pill fiu-pill--ok';
}
return this.isStrOverdue(str) ? 'fiu-pill fiu-pill--danger' : 'fiu-pill fiu-pill--warn';
}
/** Past its 7-day PMLA deadline and still unfiled — the state that actually needs chasing. */
isStrOverdue(str: any): boolean {
if (!str?.strDueDate || str.strStatus === 'FILED') {
return false;
}
return new Date(str.strDueDate) < new Date(new Date().toDateString());
}
get isPrincipalOfficer(): boolean {
return this.authenticationService.isPrincipalOfficerUser();
}
@@ -547,6 +587,7 @@ export class FiuComponent implements OnInit {
*/
loadAmlAlerts(): void {
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
this.loadSanctionsStrCases();
}
loadAmlCtrReports(): void {
@@ -7,556 +7,121 @@
-->
<div class="alm-dlg">
<!-- HEADER -->
<div class="alm-head">
<!-- Window chrome. Screen only — the print takes the statement's own heading, not this bar. -->
<div class="alm-head no-print">
<div class="alm-head-left">
<div class="alm-head-ic"><fa-icon icon="balance-scale"></fa-icon></div>
<div>
<h2 class="alm-title">Asset Liability Management — Structural Liquidity</h2>
<h2 class="alm-title">Average Liabilities and Assets</h2>
<p class="alm-sub">
{{ bank.name }} &middot; <strong>{{ bank.formCode }}</strong> &middot; {{ bank.quarter }} &middot; As on
<strong>{{ bank.asOnDate }}</strong>
{{ bank.name }} &middot; {{ branchName }} &middot; FY {{ bank.financialYear }} &middot; Month-wise during the
year
</p>
</div>
</div>
<div class="alm-head-actions">
<span class="alm-overall" [ngClass]="gap14dWithinTolerance && gap28dWithinTolerance ? 'on-target' : 'below'">
<span class="alm-overall-l">14d Tolerance</span>
<span class="alm-overall-v">{{ gap14dPct.toFixed(2) }}%</span>
<span class="alm-overall-t">/ -{{ bank.tolerance14d }}%</span>
</span>
<button class="alm-close" (click)="close()" matTooltip="Close" aria-label="Close">&#10005;</button>
<button class="alm-close" (click)="close()" matTooltip="Close" aria-label="Close">&#10005;</button>
</div>
<!-- Report parameters and actions, the way a report is run in the core system -->
<div class="alm-params no-print">
<label class="alm-param">
<span>Financial year</span>
<output class="alm-param-static">{{ bank.financialYear }}</output>
</label>
<label class="alm-param">
<span>Up to month</span>
<select [(ngModel)]="upToIndex" (ngModelChange)="runReport()">
<option *ngFor="let m of monthOptions" [ngValue]="m.index">{{ m.label }}</option>
</select>
</label>
<label class="alm-param">
<span>Months included</span>
<output class="alm-param-static">{{ statementRows.length }}</output>
</label>
<span class="alm-params-sp"></span>
<button type="button" class="alm-btn" (click)="print()"><fa-icon icon="print"></fa-icon>&nbsp; Print</button>
<button type="button" class="alm-btn" (click)="exportXls()">
<fa-icon icon="file-excel"></fa-icon>&nbsp; Export
</button>
</div>
<p class="alm-note no-print">
<fa-icon icon="info-circle"></fa-icon>&nbsp; <strong>{{ unmappedColumns }}</strong> are not mapped in the
core-system extract and print as 0.00. Total and Average cover the {{ statementRows.length }} month(s) shown.
</p>
<!-- ═══ THE STATEMENT — what goes on paper ═══ -->
<div class="alm-body">
<div class="stmt" #statement>
<div class="stmt-bank">{{ statementHeading }}</div>
<div class="stmt-title">Average Liabilities and Assets Month-wise during the year</div>
<div class="stmt-tbl-wrap">
<table class="stmt-tbl">
<thead>
<tr>
<th rowspan="2" class="col-month">Month</th>
<th rowspan="2">Capital</th>
<th rowspan="2">Reserve/Prov<br />Exclude OIR</th>
<th rowspan="2">Deposits</th>
<th rowspan="2">Borrowings</th>
<th rowspan="2">Other Liabilities</th>
<th rowspan="2">Cash on Hand</th>
<th colspan="2" class="col-grp">Bank Balances</th>
<th rowspan="2">Investments<br />including</th>
<th rowspan="2">Advances</th>
<th rowspan="2">Other Assets<br />Exclude IRA</th>
</tr>
<tr>
<th>Current</th>
<th>FDs</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of statementRows">
<td class="col-month">
<strong>{{ r.label }}</strong
><br /><span class="stmt-yr">{{ r.sub }}</span>
</td>
<td>{{ r.capital | number: '1.2-2' }}</td>
<td>{{ r.reserveProv | number: '1.2-2' }}</td>
<td>{{ r.deposits | number: '1.2-2' }}</td>
<td>{{ r.borrowings | number: '1.2-2' }}</td>
<td>{{ r.otherLiabilities | number: '1.2-2' }}</td>
<td>{{ r.cashOnHand | number: '1.2-2' }}</td>
<td>{{ r.bankCurrent | number: '1.2-2' }}</td>
<td>{{ r.bankFds | number: '1.2-2' }}</td>
<td>{{ r.investments | number: '1.2-2' }}</td>
<td>{{ r.advances | number: '1.2-2' }}</td>
<td>{{ r.otherAssets | number: '1.2-2' }}</td>
</tr>
<tr class="stmt-sum" *ngFor="let r of [totalRow, averageRow]">
<td class="col-month">{{ r.label }}</td>
<td>{{ r.capital | number: '1.2-2' }}</td>
<td>{{ r.reserveProv | number: '1.2-2' }}</td>
<td>{{ r.deposits | number: '1.2-2' }}</td>
<td>{{ r.borrowings | number: '1.2-2' }}</td>
<td>{{ r.otherLiabilities | number: '1.2-2' }}</td>
<td>{{ r.cashOnHand | number: '1.2-2' }}</td>
<td>{{ r.bankCurrent | number: '1.2-2' }}</td>
<td>{{ r.bankFds | number: '1.2-2' }}</td>
<td>{{ r.investments | number: '1.2-2' }}</td>
<td>{{ r.advances | number: '1.2-2' }}</td>
<td>{{ r.otherAssets | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="stmt-foot">
<div>Print Date : {{ generatedAt }}</div>
<div>Report Ref No : {{ bank.reportRefNo }}&nbsp;&nbsp;User Code:{{ statementUserCode }}</div>
</div>
</div>
</div>
<!-- TABS -->
<div class="alm-tabs">
<button class="alm-tab" [class.active]="activeTab === 'dashboard'" (click)="setTab('dashboard')">
<fa-icon icon="chart-pie"></fa-icon>&nbsp; Dashboard
</button>
<button class="alm-tab" [class.active]="activeTab === 'ladder'" (click)="setTab('ladder')">
<fa-icon icon="layer-group"></fa-icon>&nbsp; Maturity Ladder
</button>
<button class="alm-tab" [class.active]="activeTab === 'monthly'" (click)="setTab('monthly')">
<fa-icon icon="calendar-alt"></fa-icon>&nbsp; Monthly Snapshot
</button>
<button class="alm-tab" [class.active]="activeTab === 'avgbal'" (click)="setTab('avgbal')">
<fa-icon icon="table"></fa-icon>&nbsp; Avg. Liabilities &amp; Assets
</button>
<button class="alm-tab" [class.active]="activeTab === 'returns'" (click)="setTab('returns')">
<fa-icon icon="file-invoice"></fa-icon>&nbsp; Form ALM-1
</button>
</div>
<div class="alm-body">
<!-- ════════════ DASHBOARD ════════════ -->
<ng-container *ngIf="activeTab === 'dashboard'">
<div class="alm-stats">
<div class="alm-stat-card alm-stat-gauge">
<div
class="gauge"
[style.--pct.%]="totalGapPct > 0 ? (totalGapPct > 100 ? 100 : totalGapPct) : 0"
[ngClass]="totalGap >= 0 ? 'on-target' : 'below'"
>
<div class="gauge-inner">
<div class="gauge-pct">{{ totalGapPct.toFixed(1) }}%</div>
<div class="gauge-label">Net Gap / Outflow</div>
</div>
</div>
</div>
<div class="alm-stat-card">
<div class="alm-stat-l">NDTL</div>
<div class="alm-stat-v">&#8377; {{ bank.ndtl / 100 | number: '1.2-2' }} Cr</div>
<div class="alm-stat-meta">Net Demand &amp; Time Liabilities</div>
</div>
<div class="alm-stat-card">
<div class="alm-stat-l">Total Outflows</div>
<div class="alm-stat-v">&#8377; {{ totalOutflow / 100 | number: '1.2-2' }} Cr</div>
<div class="alm-stat-meta">All maturity buckets</div>
</div>
<div class="alm-stat-card">
<div class="alm-stat-l">Total Inflows</div>
<div class="alm-stat-v">&#8377; {{ totalInflow / 100 | number: '1.2-2' }} Cr</div>
<div class="alm-stat-meta" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
Net: {{ totalGap >= 0 ? '+' : '' }}{{ totalGap / 100 | number: '1.2-2' }} Cr
</div>
</div>
</div>
<!-- TOLERANCE BARS -->
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">RBI Tolerance Limits — Cumulative Gap</h3>
<span class="alm-circular">RBI MD: {{ bank.rbiCircular }}</span>
</div>
<div class="alm-tolerance">
<div class="alm-tol-row" [ngClass]="gap14dWithinTolerance ? 'good' : 'bad'">
<div class="alm-tol-h">
<span class="alm-tol-l"
><fa-icon icon="hourglass-half"></fa-icon>&nbsp; Cumulative Gap up to 14 days</span
>
<span class="alm-tol-p">
<strong>{{ gap14dPct.toFixed(2) }}%</strong>
<span class="alm-tol-lim">limit -{{ bank.tolerance14d }}%</span>
<span class="alm-tol-st">{{ gap14dWithinTolerance ? 'WITHIN' : 'BREACH' }}</span>
</span>
</div>
<div class="alm-tol-track">
<div
class="alm-tol-fill"
[style.width.%]="gap14dPct >= 0 ? 100 : 100 + (gap14dPct / bank.tolerance14d) * 100"
></div>
<div class="alm-tol-marker"></div>
</div>
</div>
<div class="alm-tol-row" [ngClass]="gap28dWithinTolerance ? 'good' : 'bad'">
<div class="alm-tol-h">
<span class="alm-tol-l"><fa-icon icon="clock"></fa-icon>&nbsp; Cumulative Gap up to 30 days</span>
<span class="alm-tol-p">
<strong>{{ gap28dPct.toFixed(2) }}%</strong>
<span class="alm-tol-lim">limit -{{ bank.tolerance28d }}%</span>
<span class="alm-tol-st">{{ gap28dWithinTolerance ? 'WITHIN' : 'BREACH' }}</span>
</span>
</div>
<div class="alm-tol-track">
<div
class="alm-tol-fill"
[style.width.%]="gap28dPct >= 0 ? 100 : 100 + (gap28dPct / bank.tolerance28d) * 100"
></div>
<div class="alm-tol-marker"></div>
</div>
</div>
</div>
</div>
<!-- BUCKET MINI HEATMAP -->
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">Bucket-wise Mismatch Heatmap</h3>
</div>
<div class="alm-heatmap">
<div
*ngFor="let b of buckets"
class="heat-cell"
[ngClass]="bucketStatus(b)"
[matTooltip]="b.label + ' — Gap: ' + bucketGapPct(b).toFixed(2) + '%'"
>
<div class="heat-code">{{ b.code }}</div>
<div class="heat-pct">{{ bucketGapPct(b).toFixed(1) }}%</div>
<div class="heat-lbl">{{ b.label }}</div>
</div>
</div>
</div>
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">Reporting Snapshot</h3>
</div>
<div class="alm-info-grid">
<div class="alm-info">
<div class="alm-info-l">Reporting Bank</div>
<div class="alm-info-v">{{ bank.name }}</div>
</div>
<div class="alm-info">
<div class="alm-info-l">Branch</div>
<div class="alm-info-v">{{ bank.branch }}</div>
</div>
<div class="alm-info">
<div class="alm-info-l">Principal Officer</div>
<div class="alm-info-v">{{ bank.principalOfficer }}</div>
</div>
<div class="alm-info">
<div class="alm-info-l">Form Code</div>
<div class="alm-info-v">{{ bank.formCode }}</div>
</div>
<div class="alm-info">
<div class="alm-info-l">Report Ref No</div>
<div class="alm-info-v mono">{{ bank.reportRefNo }}</div>
</div>
<div class="alm-info">
<div class="alm-info-l">User Code</div>
<div class="alm-info-v mono">{{ bank.userCode }}</div>
</div>
</div>
</div>
</ng-container>
<!-- ════════════ MATURITY LADDER ════════════ -->
<ng-container *ngIf="activeTab === 'ladder'">
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">Statement of Structural Liquidity — Maturity Ladder</h3>
<div class="alm-sec-actions">
<select class="alm-sel" [(ngModel)]="filterBucket">
<option value="">All Buckets</option>
<option value="short">Short Term (≤ 30 days)</option>
<option value="medium">Medium Term (1–12 months)</option>
<option value="long">Long Term (&gt; 1 year)</option>
</select>
<button mat-stroked-button class="alm-btn-xls" (click)="exportLadderCsv()">
<fa-icon icon="file-csv"></fa-icon>&nbsp; Export CSV
</button>
</div>
</div>
<div class="alm-tbl-wrap">
<table class="alm-tbl">
<thead>
<tr>
<th>Bucket</th>
<th>Time Period</th>
<th class="ta-r">Outflow (Liab)</th>
<th class="ta-r">Inflow (Assets)</th>
<th class="ta-r">Gap</th>
<th class="ta-r">Gap %</th>
<th class="ta-c">Status</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let b of filteredBuckets">
<td class="mono">{{ b.code }}</td>
<td>{{ b.label }}</td>
<td class="ta-r">{{ b.outflow | number: '1.2-2' }}</td>
<td class="ta-r">{{ b.inflow | number: '1.2-2' }}</td>
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
<strong>{{ bucketGap(b) >= 0 ? '+' : '' }}{{ bucketGap(b) | number: '1.2-2' }}</strong>
</td>
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
{{ bucketGapPct(b) >= 0 ? '+' : '' }}{{ bucketGapPct(b).toFixed(2) }}%
</td>
<td class="ta-c">
<span class="status-pill" [ngClass]="bucketStatus(b)">
<ng-container *ngIf="bucketStatus(b) === 'surplus'">SURPLUS</ng-container>
<ng-container *ngIf="bucketStatus(b) === 'deficit'">DEFICIT</ng-container>
<ng-container *ngIf="bucketStatus(b) === 'balanced'">BALANCED</ng-container>
</span>
</td>
</tr>
<tr class="row-total">
<td colspan="2"><strong>TOTAL</strong></td>
<td class="ta-r">
<strong>{{ totalOutflow | number: '1.2-2' }}</strong>
</td>
<td class="ta-r">
<strong>{{ totalInflow | number: '1.2-2' }}</strong>
</td>
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
<strong>{{ totalGap >= 0 ? '+' : '' }}{{ totalGap | number: '1.2-2' }}</strong>
</td>
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
<strong>{{ totalGapPct >= 0 ? '+' : '' }}{{ totalGapPct.toFixed(2) }}%</strong>
</td>
<td></td>
</tr>
</tbody>
</table>
</div>
<!-- Cumulative table -->
<h4 class="form-iii-t">Cumulative Position</h4>
<div class="alm-tbl-wrap">
<table class="alm-tbl">
<thead>
<tr>
<th>Through Bucket</th>
<th class="ta-r">Cum. Outflow</th>
<th class="ta-r">Cum. Inflow</th>
<th class="ta-r">Cum. Gap</th>
<th class="ta-r">Cum. Gap %</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let b of buckets; let i = index">
<td>{{ b.code }} — {{ b.label }}</td>
<td class="ta-r">{{ cumulativeOutflow(i) | number: '1.2-2' }}</td>
<td class="ta-r">{{ cumulativeInflow(i) | number: '1.2-2' }}</td>
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
{{ cumulativeGap(i) >= 0 ? '+' : '' }}{{ cumulativeGap(i) | number: '1.2-2' }}
</td>
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
<strong>{{ cumulativeGapPct(i) >= 0 ? '+' : '' }}{{ cumulativeGapPct(i).toFixed(2) }}%</strong>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</ng-container>
<!-- ════════════ MONTHLY ════════════ -->
<ng-container *ngIf="activeTab === 'monthly'">
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">Monthly Asset / Liability Snapshot — FY {{ bank.fiscalYear }}</h3>
<button mat-stroked-button class="alm-btn-xls" (click)="exportMonthlyXls()">
<fa-icon icon="file-excel"></fa-icon>&nbsp; Export Excel
</button>
</div>
<div class="alm-tbl-wrap">
<table class="alm-tbl">
<thead>
<tr>
<th rowspan="2">Month</th>
<th colspan="4" class="ta-c grp-liab">Liabilities (₹ Lakh)</th>
<th colspan="4" class="ta-c grp-asset">Assets (₹ Lakh)</th>
</tr>
<tr>
<th class="ta-r">Reserve/Prov</th>
<th class="ta-r">Borrowings</th>
<th class="ta-r">FDs</th>
<th class="ta-r">Total Liab</th>
<th class="ta-r">Cash</th>
<th class="ta-r">Investments</th>
<th class="ta-r">Other Assets</th>
<th class="ta-r">Total Assets</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let m of monthly">
<td class="mono">{{ m.month }}</td>
<td class="ta-r">{{ m.reserveProv | number: '1.2-2' }}</td>
<td class="ta-r">{{ m.borrowings | number: '1.2-2' }}</td>
<td class="ta-r">{{ m.fds | number: '1.2-2' }}</td>
<td class="ta-r">
<strong>{{ monthTotalLiab(m) | number: '1.2-2' }}</strong>
</td>
<td class="ta-r">{{ m.cashOnHand | number: '1.2-2' }}</td>
<td class="ta-r">{{ m.investments | number: '1.2-2' }}</td>
<td class="ta-r">{{ m.otherAssets | number: '1.2-2' }}</td>
<td class="ta-r">
<strong>{{ monthTotalAssets(m) | number: '1.2-2' }}</strong>
</td>
</tr>
<tr class="row-total">
<td><strong>AVERAGE</strong></td>
<td class="ta-r">{{ avg('reserveProv') | number: '1.2-2' }}</td>
<td class="ta-r">{{ avg('borrowings') | number: '1.2-2' }}</td>
<td class="ta-r">{{ avg('fds') | number: '1.2-2' }}</td>
<td class="ta-r">
<strong>{{ avg('reserveProv') + avg('borrowings') + avg('fds') | number: '1.2-2' }}</strong>
</td>
<td class="ta-r">{{ avg('cashOnHand') | number: '1.2-2' }}</td>
<td class="ta-r">{{ avg('investments') | number: '1.2-2' }}</td>
<td class="ta-r">{{ avg('otherAssets') | number: '1.2-2' }}</td>
<td class="ta-r">
<strong>{{ avg('cashOnHand') + avg('investments') + avg('otherAssets') | number: '1.2-2' }}</strong>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</ng-container>
<!-- ════════════ AVERAGE LIABILITIES AND ASSETS ════════════ -->
<ng-container *ngIf="activeTab === 'avgbal'">
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">Average Liabilities and Assets Month-wise during the year</h3>
<button mat-stroked-button class="alm-btn-xls" (click)="exportAvgBalancesXls()">
<fa-icon icon="file-excel"></fa-icon>&nbsp; Export Excel
</button>
</div>
<!-- Printed-statement banner: institution and office come off the session, not the report. -->
<div class="alm-stmt">
<div class="alm-stmt-bank">{{ statementHeading }}</div>
<div class="alm-stmt-title">Average Liabilities and Assets Month-wise during the year</div>
<div class="alm-tbl-wrap">
<table class="alm-tbl alm-stmt-tbl">
<thead>
<tr>
<th rowspan="2">Month</th>
<th rowspan="2" class="ta-r">Capital</th>
<th rowspan="2" class="ta-r">Reserve/Prov<br />Exclude OIR</th>
<th rowspan="2" class="ta-r">Deposits</th>
<th rowspan="2" class="ta-r">Borrowings</th>
<th rowspan="2" class="ta-r">Other Liabilities</th>
<th rowspan="2" class="ta-r">Cash on Hand</th>
<th colspan="2" class="ta-c">Bank Balances</th>
<th rowspan="2" class="ta-r">Investments<br />including</th>
<th rowspan="2" class="ta-r">Advances</th>
<th rowspan="2" class="ta-r">Other Assets<br />Exclude IRA</th>
</tr>
<tr>
<th class="ta-r">Current</th>
<th class="ta-r">FDs</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of avgBalanceRows">
<td class="mono">
<strong>{{ r.label }}</strong
><br /><span class="alm-stmt-sub">{{ r.sub }}</span>
</td>
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
</tr>
<tr class="row-total" *ngFor="let r of [avgBalanceTotal, avgBalanceAverage]">
<td>{{ r.label }}</td>
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="alm-stmt-foot">
<div>Print Date : {{ statementPrintedAt }}</div>
<div>Report Ref No : {{ bank.reportRefNo }}&nbsp;&nbsp;User Code:{{ statementUserCode }}</div>
</div>
</div>
</div>
</ng-container>
<!-- ════════════ RETURNS / FORM ALM-1 ════════════ -->
<ng-container *ngIf="activeTab === 'returns'">
<div class="alm-section">
<div class="alm-sec-h">
<h3 class="alm-sec-t">{{ bank.formCode }} — Statement of Structural Liquidity</h3>
<button mat-stroked-button class="alm-btn-xls" (click)="exportFormAlm()">
<fa-icon icon="file-excel"></fa-icon>&nbsp; Generate {{ bank.formCode }}
</button>
</div>
<div class="form-iii">
<div class="form-iii-h">
<div class="form-iii-bank">
<strong>{{ bank.name }}</strong> &mdash; {{ bank.branch }}
</div>
<div class="form-iii-meta">
<span><fa-icon icon="calendar"></fa-icon>&nbsp; As On {{ bank.asOnDate }}</span>
<span><fa-icon icon="university"></fa-icon>&nbsp; {{ bank.quarter }}</span>
<span class="mono"><fa-icon icon="hashtag"></fa-icon>&nbsp; Ref: {{ bank.reportRefNo }}</span>
</div>
</div>
<h4 class="form-iii-t">A. NDTL &amp; Reference</h4>
<table class="alm-tbl form-tbl">
<tbody>
<tr>
<td>Net Demand &amp; Time Liabilities (NDTL) (₹ Lakhs)</td>
<td class="ta-r">
<strong>{{ bank.ndtl | number: '1.2-2' }}</strong>
</td>
</tr>
<tr>
<td>RBI Master Direction</td>
<td class="ta-r mono">{{ bank.rbiCircular }}</td>
</tr>
</tbody>
</table>
<h4 class="form-iii-t">B. Maturity Ladder (₹ in Lakhs)</h4>
<table class="alm-tbl form-tbl">
<thead>
<tr>
<th>Bucket</th>
<th>Time Period</th>
<th class="ta-r">Outflows</th>
<th class="ta-r">Inflows</th>
<th class="ta-r">Mismatch</th>
<th class="ta-r">Cum. Mismatch</th>
<th class="ta-r">Cum. % of Outflow</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let b of buckets; let i = index">
<td class="mono">{{ b.code }}</td>
<td>{{ b.label }}</td>
<td class="ta-r">{{ b.outflow | number: '1.2-2' }}</td>
<td class="ta-r">{{ b.inflow | number: '1.2-2' }}</td>
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
{{ bucketGap(b) >= 0 ? '+' : '' }}{{ bucketGap(b) | number: '1.2-2' }}
</td>
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
{{ cumulativeGap(i) >= 0 ? '+' : '' }}{{ cumulativeGap(i) | number: '1.2-2' }}
</td>
<td class="ta-r">
<strong [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'"
>{{ cumulativeGapPct(i) >= 0 ? '+' : '' }}{{ cumulativeGapPct(i).toFixed(2) }}%</strong
>
</td>
</tr>
<tr class="form-total">
<td colspan="2"><strong>TOTAL</strong></td>
<td class="ta-r">
<strong>{{ totalOutflow | number: '1.2-2' }}</strong>
</td>
<td class="ta-r">
<strong>{{ totalInflow | number: '1.2-2' }}</strong>
</td>
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
<strong>{{ totalGap >= 0 ? '+' : '' }}{{ totalGap | number: '1.2-2' }}</strong>
</td>
<td colspan="2"></td>
</tr>
</tbody>
</table>
<h4 class="form-iii-t">C. RBI Tolerance Compliance</h4>
<table class="alm-tbl form-tbl">
<tbody>
<tr>
<td>Cumulative Negative Gap up to 14 days</td>
<td class="ta-r">
<strong>{{ gap14dPct.toFixed(2) }}%</strong>
</td>
<td class="ta-r">Limit: -{{ bank.tolerance14d }}%</td>
<td class="ta-c">
<span class="status-pill" [ngClass]="gap14dWithinTolerance ? 'on-target' : 'below'">
{{ gap14dWithinTolerance ? 'WITHIN' : 'BREACH' }}
</span>
</td>
</tr>
<tr>
<td>Cumulative Negative Gap up to 30 days</td>
<td class="ta-r">
<strong>{{ gap28dPct.toFixed(2) }}%</strong>
</td>
<td class="ta-r">Limit: -{{ bank.tolerance28d }}%</td>
<td class="ta-c">
<span class="status-pill" [ngClass]="gap28dWithinTolerance ? 'on-target' : 'below'">
{{ gap28dWithinTolerance ? 'WITHIN' : 'BREACH' }}
</span>
</td>
</tr>
</tbody>
</table>
<div class="form-iii-foot">
<div class="cert">
<div class="cert-l">Certified by</div>
<div class="cert-v">{{ bank.principalOfficer }}</div>
<div class="cert-r">Principal Officer — {{ bank.name }}</div>
</div>
<div class="cert">
<div class="cert-l">Submitted under</div>
<div class="cert-v mono">{{ bank.rbiCircular }}</div>
<div class="cert-r">RBI Master Direction on Asset Liability Management</div>
</div>
</div>
</div>
</div>
</ng-container>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { Component, ElementRef, ViewChild, inject } from '@angular/core';
import { CommonModule, DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
@@ -16,42 +16,34 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { BranchScopeService } from 'app/core/branch-scope.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
interface MaturityBucket {
code: string;
label: string;
/** Outflows / Liabilities (₹ Lakhs) */
outflow: number;
/** Inflows / Assets (₹ Lakhs) */
inflow: number;
}
interface MonthSnapshot {
/**
* One month of the average liabilities and assets extract, column for column as the core system
* prints it.
*/
interface MonthlyAlmRow {
/** e.g. 'APR-2025' */
month: string;
reserveProv: number;
borrowings: number;
fds: number;
cashOnHand: number;
investments: number;
otherAssets: number;
// ── Columns the month-wise average statement adds to the snapshot above ──
// The statement is a full balance sheet averaged over each month, so it needs the own-funds
// and deposit sides the liquidity tabs do not use, and it splits bank balances from cash.
/** Paid-up share capital. */
capital: number;
/** Total deposits — the FD component above is part of this figure. */
reserveProv: number;
/** Total deposits — demand and time. */
deposits: number;
borrowings: number;
otherLiabilities: number;
cashOnHand: number;
/** Balances with other banks, current accounts. */
bankCurrent: number;
/** Balances with other banks, held as fixed deposits. */
bankFds: number;
investments: number;
advances: number;
otherAssets: number;
}
/** One row of the month-wise average statement — a month, or the Total / Average line. */
interface AvgBalanceRow {
/** One printed line — a month, or the Total / Average line the statement closes with. */
interface StatementRow {
label: string;
/** Sub-label under the month, e.g. the year, as the printed statement prints it. */
/** Sub-label under the month, the year, as the printed statement sets it. */
sub: string;
capital: number;
reserveProv: number;
@@ -66,6 +58,18 @@ interface AvgBalanceRow {
otherAssets: number;
}
/**
* Average Liabilities and Assets Month-wise during the year.
*
* One report, one statement, the way the core system prints it — a parameter line, the statement,
* and the print footer. It is deliberately not a dashboard: an ALM screen in a bank is a report
* you run and print, not a set of tabs, and figures that go out over the bank's name have to look
* the same on screen as they do on paper.
*
* Month rows are the figures as extracted. Investments, Advances, Bank-FD balances and Other
* Assets are not mapped in the extract and print as 0.00 — stated on screen rather than filled in,
* because a nil that is really "not captured" must not read as a real balance.
*/
@Component({
selector: 'mifosx-alm-report-dialog',
standalone: true,
@@ -86,310 +90,226 @@ export class AlmReportDialogComponent {
private branchScope = inject(BranchScopeService);
private auth = inject(AuthenticationService);
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns' = 'dashboard';
/** The rendered statement, handed to the print window as-is. */
@ViewChild('statement') private statementRef?: ElementRef<HTMLElement>;
bank = {
readonly bank = {
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
asOnDate: '31-03-2026',
quarter: 'Q4 FY 2025-26',
fiscalYear: '2025-26',
rbiCircular: 'DCBR.BPD.(PCB).Cir.No.4/12.05.001/2018-19',
principalOfficer: 'Karthik Kamath',
formCode: 'Form ALM-1',
financialYear: '2025-26',
reportRefNo: '1036',
userCode: 'CBSN01',
/** Net Demand & Time Liabilities — denominator for ratios */
ndtl: 740000, // ₹7,400 Cr in Lakhs
/** RBI tolerance: cumulative negative gap up to 14 days ≤ 5%, 28 days ≤ 10% */
tolerance14d: 5,
tolerance28d: 10
userCode: 'CBSN01'
};
/** Standard RBI ALM time buckets per Master Direction */
buckets: MaturityBucket[] = [
{ code: 'B01', label: 'Day 1', outflow: 1840, inflow: 2120 },
{ code: 'B02', label: '2 to 7 days', outflow: 6420, inflow: 5880 },
{ code: 'B03', label: '8 to 14 days', outflow: 4180, inflow: 4360 },
{ code: 'B04', label: '15 to 30 days', outflow: 8240, inflow: 7920 },
{ code: 'B05', label: '31 days to 2 months', outflow: 12480, inflow: 13160 },
{ code: 'B06', label: '> 2 months to 3 months', outflow: 9620, inflow: 10240 },
{ code: 'B07', label: '> 3 months to 6 months', outflow: 28640, inflow: 32180 },
{ code: 'B08', label: '> 6 months to 1 year', outflow: 64280, inflow: 68420 },
{ code: 'B09', label: '> 1 year to 3 years', outflow: 142800, inflow: 156400 },
{ code: 'B10', label: '> 3 years to 5 years', outflow: 96400, inflow: 108200 },
{ code: 'B11', label: 'Over 5 years', outflow: 124800, inflow: 138400 }
];
/** Monthly snapshot — mirrors the format of the legacy ALM Excel */
monthly: MonthSnapshot[] = [
/**
* The financial year, April to March. A part-year print simply stops at the month asked for, which
* is why `upToIndex` exists rather than the row list being cut to size here.
*/
monthly: MonthlyAlmRow[] = [
{
month: 'APR-2025',
reserveProv: 18420,
borrowings: 24180,
fds: 462800,
cashOnHand: 8420,
investments: 184600,
otherAssets: 12480,
capital: 24200,
deposits: 602800,
otherLiabilities: 28400,
bankCurrent: 18900,
bankFds: 46200,
advances: 372400
capital: 264.66,
reserveProv: 720.83,
deposits: 6108.63,
borrowings: 24.47,
otherLiabilities: 280.91,
cashOnHand: 172.44,
bankCurrent: 190.09,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'MAY-2025',
reserveProv: 18640,
borrowings: 23980,
fds: 468200,
cashOnHand: 8680,
investments: 186400,
otherAssets: 12620,
capital: 24280,
deposits: 608600,
otherLiabilities: 28560,
bankCurrent: 19240,
bankFds: 46600,
advances: 376800
capital: 270.73,
reserveProv: 722.11,
deposits: 6181.15,
borrowings: 26.36,
otherLiabilities: 280.91,
cashOnHand: 129.0,
bankCurrent: 218.27,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'JUN-2025',
reserveProv: 18820,
borrowings: 23640,
fds: 472400,
cashOnHand: 9120,
investments: 188200,
otherAssets: 12840,
capital: 24360,
deposits: 613400,
otherLiabilities: 28720,
bankCurrent: 19580,
bankFds: 47000,
advances: 381200
capital: 272.61,
reserveProv: 722.11,
deposits: 6291.01,
borrowings: 33.0,
otherLiabilities: 280.91,
cashOnHand: 115.01,
bankCurrent: 322.77,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'JUL-2025',
reserveProv: 19080,
borrowings: 23420,
fds: 478600,
cashOnHand: 9280,
investments: 190400,
otherAssets: 13020,
capital: 24460,
deposits: 619800,
otherLiabilities: 28880,
bankCurrent: 19860,
bankFds: 47400,
advances: 385600
capital: 274.06,
reserveProv: 755.72,
deposits: 6299.45,
borrowings: 50.07,
otherLiabilities: 280.91,
cashOnHand: 84.82,
bankCurrent: 267.13,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'AUG-2025',
reserveProv: 19280,
borrowings: 23180,
fds: 482400,
cashOnHand: 9420,
investments: 192800,
otherAssets: 13280,
capital: 24540,
deposits: 624200,
otherLiabilities: 29040,
bankCurrent: 20180,
bankFds: 47800,
advances: 390200
capital: 273.22,
reserveProv: 755.72,
deposits: 6404.97,
borrowings: 26.53,
otherLiabilities: 280.91,
cashOnHand: 111.77,
bankCurrent: 253.24,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'SEP-2025',
reserveProv: 19460,
borrowings: 22940,
fds: 488200,
cashOnHand: 9640,
investments: 195200,
otherAssets: 13420,
capital: 24620,
deposits: 630400,
otherLiabilities: 29200,
bankCurrent: 20460,
bankFds: 48200,
advances: 394800
capital: 275.34,
reserveProv: 755.72,
deposits: 6361.83,
borrowings: 30.3,
otherLiabilities: 280.91,
cashOnHand: 136.45,
bankCurrent: 190.83,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'OCT-2025',
reserveProv: 19680,
borrowings: 22680,
fds: 492600,
cashOnHand: 9820,
investments: 197600,
otherAssets: 13620,
capital: 24720,
deposits: 635200,
otherLiabilities: 29380,
bankCurrent: 20740,
bankFds: 48600,
advances: 399400
capital: 274.82,
reserveProv: 755.72,
deposits: 6304.25,
borrowings: 38.0,
otherLiabilities: 280.91,
cashOnHand: 93.77,
bankCurrent: 239.01,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'NOV-2025',
reserveProv: 19880,
borrowings: 22420,
fds: 498400,
cashOnHand: 9980,
investments: 200200,
otherAssets: 13820,
capital: 24800,
deposits: 641000,
otherLiabilities: 29540,
bankCurrent: 21020,
bankFds: 49000,
advances: 404200
capital: 278.29,
reserveProv: 755.72,
deposits: 6269.1,
borrowings: 52.64,
otherLiabilities: 280.91,
cashOnHand: 92.03,
bankCurrent: 215.31,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'DEC-2025',
reserveProv: 20120,
borrowings: 22180,
fds: 504200,
cashOnHand: 10240,
investments: 202800,
otherAssets: 14040,
capital: 24900,
deposits: 646800,
otherLiabilities: 29720,
bankCurrent: 21340,
bankFds: 49400,
advances: 409000
capital: 278.97,
reserveProv: 755.72,
deposits: 6310.57,
borrowings: 50.01,
otherLiabilities: 280.91,
cashOnHand: 98.58,
bankCurrent: 221.93,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'JAN-2026',
reserveProv: 20340,
borrowings: 21940,
fds: 508800,
cashOnHand: 10420,
investments: 205200,
otherAssets: 14240,
capital: 24980,
deposits: 651600,
otherLiabilities: 29880,
bankCurrent: 21620,
bankFds: 49800,
advances: 413800
capital: 280.14,
reserveProv: 755.72,
deposits: 6358.2,
borrowings: 47.85,
otherLiabilities: 280.91,
cashOnHand: 101.36,
bankCurrent: 228.47,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'FEB-2026',
reserveProv: 20580,
borrowings: 21680,
fds: 514600,
cashOnHand: 10620,
investments: 207800,
otherAssets: 14460,
capital: 25060,
deposits: 657400,
otherLiabilities: 30040,
bankCurrent: 21900,
bankFds: 50200,
advances: 418600
capital: 281.42,
reserveProv: 768.4,
deposits: 6402.75,
borrowings: 44.62,
otherLiabilities: 280.91,
cashOnHand: 104.19,
bankCurrent: 233.08,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
},
{
month: 'MAR-2026',
reserveProv: 20840,
borrowings: 21420,
fds: 520400,
cashOnHand: 10820,
investments: 210400,
otherAssets: 14680,
capital: 25160,
deposits: 663200,
otherLiabilities: 30220,
bankCurrent: 22240,
bankFds: 50600,
advances: 423600
capital: 283.05,
reserveProv: 768.4,
deposits: 6471.38,
borrowings: 41.3,
otherLiabilities: 280.91,
cashOnHand: 110.62,
bankCurrent: 241.66,
bankFds: 0,
investments: 0,
advances: 0,
otherAssets: 0
}
];
filterBucket = '';
/**
* Last month included in the print. Defaults to the ninth month, DEC-2025, because that is where a
* statement taken part-way through the year stops — the months after it have not closed.
*/
upToIndex = 8;
// -------------------- Computed --------------------
bucketGap(b: MaturityBucket): number {
return b.inflow - b.outflow;
}
bucketGapPct(b: MaturityBucket): number {
return b.outflow === 0 ? 0 : +(((b.inflow - b.outflow) / b.outflow) * 100).toFixed(2);
}
cumulativeOutflow(idx: number): number {
return this.buckets.slice(0, idx + 1).reduce((s, b) => s + b.outflow, 0);
}
cumulativeInflow(idx: number): number {
return this.buckets.slice(0, idx + 1).reduce((s, b) => s + b.inflow, 0);
}
cumulativeGap(idx: number): number {
return this.cumulativeInflow(idx) - this.cumulativeOutflow(idx);
}
cumulativeGapPct(idx: number): number {
const out = this.cumulativeOutflow(idx);
return out === 0 ? 0 : +((this.cumulativeGap(idx) / out) * 100).toFixed(2);
}
/** Columns the core-system extract does not map. Named on screen, not quietly filled in. */
readonly unmappedColumns = 'Investments, Advances, Bank-FD balances and Other Assets';
get totalOutflow(): number {
return this.buckets.reduce((s, b) => s + b.outflow, 0);
}
get totalInflow(): number {
return this.buckets.reduce((s, b) => s + b.inflow, 0);
}
get totalGap(): number {
return this.totalInflow - this.totalOutflow;
}
get totalGapPct(): number {
return this.totalOutflow === 0 ? 0 : +((this.totalGap / this.totalOutflow) * 100).toFixed(2);
}
/** RBI tolerance test: cumulative negative gap up to bucket 3 (14 days) ≤ 5% of cumulative outflow */
get gap14dPct(): number {
return this.cumulativeGapPct(2);
}
/** Up to bucket 4 (30 days) ≤ 10% */
get gap28dPct(): number {
return this.cumulativeGapPct(3);
}
get gap14dWithinTolerance(): boolean {
return this.gap14dPct >= -this.bank.tolerance14d;
}
get gap28dWithinTolerance(): boolean {
return this.gap28dPct >= -this.bank.tolerance28d;
}
bucketStatus(b: MaturityBucket): 'surplus' | 'deficit' | 'balanced' {
const gap = this.bucketGap(b);
if (gap > 0) return 'surplus';
if (gap < 0) return 'deficit';
return 'balanced';
}
// ── Average Liabilities and Assets, month-wise ────────────────────────────
//
// The printed statement carries the institution and office it was taken for, and who took it.
// Those come off the session rather than being typed into the report, so a branch login prints
// its own office and its own user code instead of whatever the last person hard-coded.
/** Stamped when the statement is run, and again whenever the parameters change it. */
generatedAt = this.formatPrintDate(new Date());
/** "<BANK> , <OFFICE>" — the banner line the printed statement opens with. */
get statementHeading(): string {
return `${this.bank.name.toUpperCase()} , ${this.branchScope.officeName.toUpperCase()}`;
}
/** Office the statement was taken for, off the session rather than typed into the report. */
get branchName(): string {
return this.branchScope.officeName;
}
/** Who ran the report — the login's username, which is what the printed footer calls a code. */
get statementUserCode(): string {
return (this.auth.getCredentials()?.username || this.bank.userCode).toUpperCase();
}
/** Stamped when the tab is opened, so the footer records when the figures were taken off. */
readonly statementPrintedAt = this.formatPrintDate(new Date());
/** Month choices for the "up to" parameter, e.g. 'DEC-2025'. */
get monthOptions(): { index: number; label: string }[] {
return this.monthly.map((m, index) => ({ index, label: m.month }));
}
/**
* Month rows, then the Total and Average lines the statement closes with.
*
* Average is the mean of the months shown, not Total ÷ 12 — a statement printed part-way
* through the year would otherwise divide by months that have not happened yet.
*/
get avgBalanceRows(): AvgBalanceRow[] {
return this.monthly.map((m) => {
/** The months the statement prints. */
get statementRows(): StatementRow[] {
return this.monthly.slice(0, this.upToIndex + 1).map((m) => {
const [
name,
year
@@ -412,7 +332,7 @@ export class AlmReportDialogComponent {
});
}
private readonly avgBalanceColumns: (keyof AvgBalanceRow)[] = [
private readonly amountColumns: (keyof StatementRow)[] = [
'capital',
'reserveProv',
'deposits',
@@ -426,190 +346,92 @@ export class AlmReportDialogComponent {
'otherAssets'
];
get avgBalanceTotal(): AvgBalanceRow {
const rows = this.avgBalanceRows;
const out = { label: 'Total', sub: '' } as AvgBalanceRow;
for (const key of this.avgBalanceColumns) {
out[key] = rows.reduce((s, r) => s + (r[key] as number), 0) as never;
get totalRow(): StatementRow {
const rows = this.statementRows;
const out = { label: 'Total', sub: '' } as StatementRow;
for (const key of this.amountColumns) {
out[key] = +rows.reduce((s, r) => s + (r[key] as number), 0).toFixed(2) as never;
}
return out;
}
get avgBalanceAverage(): AvgBalanceRow {
const rows = this.avgBalanceRows;
const total = this.avgBalanceTotal;
const out = { label: 'Average', sub: '' } as AvgBalanceRow;
for (const key of this.avgBalanceColumns) {
/**
* Mean of the months printed, not Total ÷ 12 — a statement that stops at December must divide by
* nine, or every average on it is understated.
*/
get averageRow(): StatementRow {
const rows = this.statementRows;
const total = this.totalRow;
const out = { label: 'Average', sub: '' } as StatementRow;
for (const key of this.amountColumns) {
out[key] = (rows.length ? +((total[key] as number) / rows.length).toFixed(2) : 0) as never;
}
return out;
}
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
private formatPrintDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
const hours24 = d.getHours();
const meridiem = hours24 < 12 ? 'AM' : 'PM';
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
return (
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
);
/** Re-running the statement re-stamps the footer, since the figures on it have changed. */
runReport(): void {
this.generatedAt = this.formatPrintDate(new Date());
}
/** Monthly totals helpers */
monthTotalLiab(m: MonthSnapshot): number {
return m.reserveProv + m.borrowings + m.fds;
}
monthTotalAssets(m: MonthSnapshot): number {
return m.cashOnHand + m.investments + m.otherAssets;
}
/** Average across months */
avg<K extends keyof MonthSnapshot>(key: K): number {
if (typeof this.monthly[0][key] !== 'number') return 0;
const total = this.monthly.reduce((s, m) => s + (m[key] as number), 0);
return +(total / this.monthly.length).toFixed(2);
}
// -------------------- Filter --------------------
get filteredBuckets(): MaturityBucket[] {
if (!this.filterBucket) return this.buckets;
if (this.filterBucket === 'short') return this.buckets.slice(0, 4);
if (this.filterBucket === 'medium') return this.buckets.slice(4, 8);
if (this.filterBucket === 'long') return this.buckets.slice(8);
return this.buckets;
}
// -------------------- Excel Exports --------------------
/** Form ALM-1 — Statement of Structural Liquidity */
exportFormAlm(): void {
const rows: string[][] = [];
rows.push([
this.bank.name,
this.bank.branch
]);
rows.push([
`STATEMENT OF STRUCTURAL LIQUIDITY (${this.bank.formCode})`,
`As On ${this.bank.asOnDate}`
]);
rows.push([
`RBI Circular: ${this.bank.rbiCircular}`,
`Quarter: ${this.bank.quarter}`
]);
rows.push([
`Report Ref: ${this.bank.reportRefNo}`,
`User Code: ${this.bank.userCode}`
]);
rows.push([]);
rows.push([
'NDTL (₹ Lakhs)',
this.bank.ndtl.toString()
]);
rows.push([]);
rows.push(['Maturity Ladder (₹ in Lakhs)']);
rows.push([
'Bucket',
'Time Period',
'Outflows (Liabilities)',
'Inflows (Assets)',
'Mismatch (Gap)',
'Cum. Outflows',
'Cum. Inflows',
'Cum. Gap',
'Cum. Gap % of Outflow'
]);
this.buckets.forEach((b, i) => {
rows.push([
b.code,
b.label,
b.outflow.toFixed(2),
b.inflow.toFixed(2),
this.bucketGap(b).toFixed(2),
this.cumulativeOutflow(i).toFixed(2),
this.cumulativeInflow(i).toFixed(2),
this.cumulativeGap(i).toFixed(2),
this.cumulativeGapPct(i).toFixed(2) + '%'
]);
});
rows.push([]);
rows.push([
'TOTAL',
'',
this.totalOutflow.toFixed(2),
this.totalInflow.toFixed(2),
this.totalGap.toFixed(2)
]);
rows.push([]);
rows.push(['RBI Tolerance Compliance']);
rows.push([
'Cumulative Gap up to 14 days',
this.gap14dPct.toFixed(2) + '%',
`Limit: -${this.bank.tolerance14d}%`,
this.gap14dWithinTolerance ? 'WITHIN' : 'BREACH'
]);
rows.push([
'Cumulative Gap up to 30 days',
this.gap28dPct.toFixed(2) + '%',
`Limit: -${this.bank.tolerance28d}%`,
this.gap28dWithinTolerance ? 'WITHIN' : 'BREACH'
]);
rows.push([]);
rows.push([
'Certified by',
this.bank.principalOfficer + ' — Principal Officer'
]);
this.saveTsv(rows, `ALM-StructuralLiquidity-${this.dateTag()}.xls`);
}
/** Monthly snapshot export */
exportMonthlyXls(): void {
const rows: string[][] = [];
rows.push([
`${this.bank.name} — ${this.bank.branch}`,
`Monthly ALM Snapshot — FY ${this.bank.fiscalYear}`
]);
rows.push([]);
rows.push([
'Month',
'Reserve/Prov (Excl. OIR)',
'Borrowings',
'FDs',
'Total Liabilities',
'Cash on Hand',
'Investments incl. Bal/Repo',
'Other Assets (Excl. IRA)',
'Total Assets'
]);
for (const m of this.monthly) {
rows.push([
m.month,
m.reserveProv.toFixed(2),
m.borrowings.toFixed(2),
m.fds.toFixed(2),
this.monthTotalLiab(m).toFixed(2),
m.cashOnHand.toFixed(2),
m.investments.toFixed(2),
m.otherAssets.toFixed(2),
this.monthTotalAssets(m).toFixed(2)
]);
/**
* Prints the statement on its own.
*
* The dialog sits in an overlay on top of the application, so `window.print()` would put the whole
* screen behind it on the paper. The rendered statement is handed to a window of its own instead,
* which needs no application-wide print stylesheet and cannot pick up anything else on the page.
*/
print(): void {
const node = this.statementRef?.nativeElement;
if (!node) {
return;
}
rows.push([
'AVERAGE',
this.avg('reserveProv').toFixed(2),
this.avg('borrowings').toFixed(2),
this.avg('fds').toFixed(2),
(this.avg('reserveProv') + this.avg('borrowings') + this.avg('fds')).toFixed(2),
this.avg('cashOnHand').toFixed(2),
this.avg('investments').toFixed(2),
this.avg('otherAssets').toFixed(2),
(this.avg('cashOnHand') + this.avg('investments') + this.avg('otherAssets')).toFixed(2)
]);
this.saveTsv(rows, `ALM-Monthly-${this.dateTag()}.xls`);
const win = window.open('', '_blank', 'width=1100,height=760');
if (!win) {
// Blocked by the browser — fall back to the page print, which the print stylesheet handles.
window.print();
return;
}
win.document.write(
`<!doctype html><html><head><title>Average Liabilities and Assets — ${this.bank.financialYear}</title>` +
`<style>${AlmReportDialogComponent.PRINT_CSS}</style></head><body>${node.outerHTML}</body></html>`
);
win.document.close();
win.focus();
// Let the document lay out before the print dialog measures it.
win.setTimeout(() => {
win.print();
win.close();
}, 150);
}
/** Average Liabilities and Assets — the printed statement, column for column. */
exportAvgBalancesXls(): void {
/** Stylesheet for the print window — the statement as the core system lays it out on paper. */
private static readonly PRINT_CSS = `
@page { size: A4 landscape; margin: 12mm; }
* { box-sizing: border-box; }
body { margin: 0; font-family: Arial, Helvetica, sans-serif; color: #000; }
.stmt { padding: 0; }
.stmt-bank { font-size: 8.5pt; font-weight: 700; }
.stmt-title { margin: 2pt 0 14pt; font-size: 7.5pt; }
.stmt-tbl { width: 100%; border-collapse: collapse; font-size: 8pt; }
.stmt-tbl th { padding: 0 6pt 7pt; text-align: right; vertical-align: bottom;
font-weight: 400; font-size: 7pt; line-height: 1.3; white-space: nowrap; }
.stmt-tbl td { padding: 5pt 6pt; text-align: right; font-weight: 700; white-space: nowrap; }
.stmt-tbl .col-month { text-align: left; }
.stmt-tbl .col-grp { text-align: center; padding-bottom: 2pt; }
.stmt-tbl tr { page-break-inside: avoid; }
.stmt-sum td { padding-top: 10pt; }
.stmt-sum:first-of-type td { border-top: 0.5pt solid #000; }
.stmt-sum .col-month { font-weight: 400; }
.stmt-foot { margin-top: 24pt; font-size: 7pt; line-height: 1.8; }
`;
close(): void {
this.dialogRef.close();
}
/** Excel copy of the statement, column for column and in the same order. */
exportXls(): void {
const rows: string[][] = [];
rows.push([this.statementHeading]);
rows.push(['Average Liabilities and Assets Month-wise during the year']);
@@ -628,7 +450,7 @@ export class AlmReportDialogComponent {
'Advances',
'Other Assets Exclude IRA'
]);
const line = (r: AvgBalanceRow): string[] => [
const line = (r: StatementRow): string[] => [
`${r.label} ${r.sub}`.trim(),
r.capital.toFixed(2),
r.reserveProv.toFixed(2),
@@ -642,71 +464,38 @@ export class AlmReportDialogComponent {
r.advances.toFixed(2),
r.otherAssets.toFixed(2)
];
this.avgBalanceRows.forEach((r) => rows.push(line(r)));
rows.push(line(this.avgBalanceTotal));
rows.push(line(this.avgBalanceAverage));
this.statementRows.forEach((r) => rows.push(line(r)));
rows.push(line(this.totalRow));
rows.push(line(this.averageRow));
rows.push([]);
rows.push([`Print Date : ${this.statementPrintedAt}`]);
rows.push([`Not mapped in this extract: ${this.unmappedColumns}`]);
rows.push([`Print Date : ${this.generatedAt}`]);
rows.push([`Report Ref No : ${this.bank.reportRefNo} User Code:${this.statementUserCode}`]);
this.saveTsv(rows, `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`);
const tsv = rows.map((r) => r.map((v) => (v || '').replace(/\t/g, ' ')).join('\t')).join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`;
a.click();
URL.revokeObjectURL(url);
}
/** Maturity ladder CSV */
exportLadderCsv(): void {
const headers = [
'Bucket',
'Time Period',
'Outflow (₹ Lakh)',
'Inflow (₹ Lakh)',
'Gap',
'Gap %'
];
const rows = [headers];
for (const b of this.buckets) {
rows.push([
b.code,
b.label,
b.outflow.toString(),
b.inflow.toString(),
this.bucketGap(b).toString(),
this.bucketGapPct(b).toFixed(2) + '%'
]);
}
this.saveCsv(rows, `ALM-Ladder-${this.dateTag()}.csv`);
}
setTab(tab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns'): void {
this.activeTab = tab;
}
close(): void {
this.dialogRef.close();
}
// ---- helpers ----
private dateTag(): string {
const d = new Date();
return `${String(d.getDate()).padStart(2, '0')}${String(d.getMonth() + 1).padStart(2, '0')}${d.getFullYear()}`;
}
private saveTsv(rows: string[][], filename: string): void {
const tsv = rows.map((r) => r.map((v) => (v || '').replace(/\t/g, ' ')).join('\t')).join('\n');
this.saveBlob(tsv, filename, 'application/vnd.ms-excel');
}
private saveCsv(rows: string[][], filename: string): void {
const escape = (v: string): string => {
const s = (v || '').replace(/"/g, '""');
if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s}"`;
return s;
};
const csv = rows.map((r) => r.map(escape).join(',')).join('\n') + '\n';
this.saveBlob(csv, filename, 'text/csv');
}
private saveBlob(content: string, filename: string, mime: string): void {
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
private formatPrintDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
const hours24 = d.getHours();
const meridiem = hours24 < 12 ? 'AM' : 'PM';
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
return (
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
);
}
}
@@ -0,0 +1,219 @@
<!--
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/.
-->
<div class="container">
<div class="page-header">
<fa-icon icon="database" class="header-icon"></fa-icon>
<h2>Database Export</h2>
</div>
<!-- The export carries every client's data, so the consequence is stated before the form, not after. -->
<div class="warning-banner">
<fa-icon icon="exclamation-triangle" class="warning-banner__icon"></fa-icon>
<div>
<strong>This export contains the complete database</strong>
— every client's personal details, KYC documents, accounts and transactions. A request must be approved by an
administrator before the archive can be downloaded, and the approver cannot be the person who raised it. The
archive can only be downloaded by the user who raised the request, not by the approver, and an approval is valid
for a single download — taking another copy means raising a new request. Every request, approval and download is
recorded.
</div>
</div>
<!-- Raise a request -->
<div class="request-card" [formGroup]="requestForm">
<h3 class="request-card__title">Request a Full Export</h3>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Reason for this export</mat-label>
<textarea
matInput
formControlName="reason"
rows="3"
maxlength="1000"
placeholder="e.g. Statutory audit for FY 2025-26 requested by the RBI inspection team"
required
></textarea>
<mat-hint>Recorded against the request and written into the archive. Minimum 10 characters.</mat-hint>
<mat-error *ngIf="requestForm.get('reason')?.hasError('required')">A reason is required</mat-error>
<mat-error *ngIf="requestForm.get('reason')?.hasError('minlength')">
Please give at least 10 characters of context
</mat-error>
</mat-form-field>
<div class="request-card__actions">
<button
mat-flat-button
color="primary"
type="button"
[disabled]="requestForm.invalid || submitting"
(click)="submitRequest()"
>
<fa-icon icon="paper-plane" class="chip-icon"></fa-icon>
{{ submitting ? 'Submitting…' : 'Submit for Approval' }}
</button>
</div>
</div>
<!-- Request history -->
<div class="filters-row">
<div class="filter-chips">
<button mat-stroked-button [class.active-filter]="!activeStatus" (click)="filterByStatus(null)">
<fa-icon icon="list" class="chip-icon"></fa-icon> All
</button>
<button
mat-stroked-button
class="chip-pending"
[class.active-filter]="activeStatus === 'PENDING_APPROVAL'"
(click)="filterByStatus('PENDING_APPROVAL')"
>
<fa-icon icon="clock" class="chip-icon"></fa-icon> Awaiting Approval
</button>
<button
mat-stroked-button
class="chip-approved"
[class.active-filter]="activeStatus === 'APPROVED'"
(click)="filterByStatus('APPROVED')"
>
<fa-icon icon="check-circle" class="chip-icon"></fa-icon> Approved
</button>
<button
mat-stroked-button
class="chip-rejected"
[class.active-filter]="activeStatus === 'REJECTED'"
(click)="filterByStatus('REJECTED')"
>
<fa-icon icon="times" class="chip-icon"></fa-icon> Rejected
</button>
</div>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate" color="primary"></mat-progress-bar>
}
<div class="mat-elevation-z8">
<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Request</th>
<td mat-cell *matCellDef="let row">#{{ row.id }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Status</th>
<td mat-cell *matCellDef="let row">
<span class="result-badge" [ngClass]="statusClass(row)">{{ statusLabel(row) }}</span>
</td>
</ng-container>
<ng-container matColumnDef="reason">
<th mat-header-cell *matHeaderCellDef>Reason</th>
<td mat-cell *matCellDef="let row" class="reason-cell">
<span [matTooltip]="row.reason">{{ row.reason }}</span>
@if (row.rejectionReason) {
<span class="rejection-note">Rejected: {{ row.rejectionReason }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="requestedBy">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Requested By</th>
<td mat-cell *matCellDef="let row">
<div class="stacked">
<span>{{ row.requestedByUsername || '—' }}</span>
<span class="muted">{{ row.requestedOn | date: 'medium' }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="approvedBy">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Approved By</th>
<td mat-cell *matCellDef="let row">
@if (row.approvedByUsername) {
<div class="stacked">
<span>{{ row.approvedByUsername }}</span>
<span class="muted">{{ row.approvedOn | date: 'medium' }}</span>
</div>
} @else {
<span class="muted">—</span>
}
</td>
</ng-container>
<ng-container matColumnDef="generatedOn">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Downloaded</th>
<td mat-cell *matCellDef="let row">
@if (row.generatedOn) {
<div class="stacked">
<span>{{ row.generatedOn | date: 'medium' }}</span>
<span class="muted">{{ formatSize(row.fileSizeBytes) }} · {{ row.rowCount || 0 }} rows</span>
</div>
} @else {
<span class="muted">Not yet</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Action</th>
<td mat-cell *matCellDef="let row">
<div class="row-actions">
@if (isPending(row)) {
<!-- Only shown to users holding the approval permission; the backend enforces it regardless. -->
<ng-container *mifosxHasPermission="'APPROVE_DATABASEEXPORT'">
<button mat-stroked-button color="primary" (click)="approve(row)">
<fa-icon icon="check"></fa-icon> Approve
</button>
<button mat-stroked-button color="warn" (click)="reject(row)">
<fa-icon icon="times"></fa-icon> Reject
</button>
</ng-container>
}
@if (isDownloadable(row)) {
<button mat-flat-button color="primary" [disabled]="downloadingId === row.id" (click)="download(row)">
<fa-icon icon="download"></fa-icon>
{{ downloadingId === row.id ? 'Preparing…' : 'Download' }}
</button>
}
@if (isWithheld(row)) {
<span
class="muted withheld-note"
matTooltip="Approving an export authorises it. Only {{
row.requestedByUsername || 'the requester'
}} can download the archive."
>
<fa-icon icon="lock"></fa-icon>
Requester only
</span>
}
@if (isSpent(row)) {
<span
class="muted withheld-note"
matTooltip="An approval is valid for a single download. Raise a new request to take another copy."
>
<fa-icon icon="check"></fa-icon>
Download used
</span>
}
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
@if (!loading && dataSource.data.length === 0) {
<div class="no-data">
<fa-icon icon="folder-open" class="no-data-icon"></fa-icon>
<p>No export requests yet.</p>
</div>
}
<mat-paginator [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons></mat-paginator>
</div>
</div>
@@ -0,0 +1,213 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.header-icon {
color: #1074b9;
font-size: 20px;
}
}
/* The consequence of this screen is stated up front, not buried in a tooltip. */
.warning-banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
margin-bottom: 20px;
border-radius: 8px;
background-color: rgb(180 83 9 / 7%);
border: 1px solid rgb(180 83 9 / 30%);
font-size: 13px;
line-height: 1.55;
color: #7c2d12;
&__icon {
color: #b45309;
font-size: 18px;
margin-top: 2px;
}
}
.request-card {
padding: 18px 20px;
margin-bottom: 22px;
border-radius: 10px;
background-color: #f7f9fc;
border: 1px solid #e3e7ee;
&__title {
margin: 0 0 14px;
font-size: 15px;
font-weight: 600;
}
&__actions {
display: flex;
justify-content: flex-end;
.chip-icon {
margin-right: 6px;
}
}
}
.full-width {
width: 100%;
}
.filters-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.filter-chips {
display: flex;
align-items: center;
gap: 8px;
button {
font-size: 13px;
border-radius: 20px;
}
.chip-icon {
margin-right: 6px;
}
.active-filter {
background-color: #1074b9;
color: #fff;
border-color: #1074b9;
}
.chip-pending.active-filter {
background-color: #b45309;
border-color: #b45309;
}
.chip-approved.active-filter {
background-color: #388e3c;
border-color: #388e3c;
}
.chip-rejected.active-filter {
background-color: #d32f2f;
border-color: #d32f2f;
}
}
.result-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
&.badge-pending {
background-color: rgb(180 83 9 / 12%);
color: #b45309;
}
&.badge-approved {
background-color: rgb(56 142 60 / 14%);
color: #2e7d32;
}
&.badge-rejected {
background-color: rgb(211 47 47 / 12%);
color: #c62828;
}
&.badge-generated {
background-color: rgb(16 116 185 / 12%);
color: #1074b9;
}
}
.reason-cell {
max-width: 320px;
span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rejection-note {
margin-top: 2px;
font-size: 11px;
color: #c62828;
}
}
.stacked {
display: flex;
flex-direction: column;
line-height: 1.35;
}
.muted {
color: #6b7280;
font-size: 11px;
}
.row-actions {
display: flex;
gap: 8px;
align-items: center;
button {
font-size: 12px;
}
fa-icon {
margin-right: 5px;
}
}
/* Stands in for the download button on rows the viewer did not raise. */
.withheld-note {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-style: italic;
white-space: nowrap;
}
.no-data {
padding: 40px 0;
text-align: center;
color: #6b7280;
&-icon {
font-size: 30px;
margin-bottom: 10px;
opacity: 0.5;
}
}
@@ -0,0 +1,255 @@
/**
* 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, ViewChild, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, DatePipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DatabaseExportService } from './database-export.service';
/**
* Full database export, gated behind an approval. The requester states why the export is needed; an
* administrator with APPROVE_DATABASEEXPORT approves or rejects it, and only then can the archive be
* downloaded. The backend refuses self-approval, so the two roles are always two different people.
*/
@Component({
selector: 'mifosx-database-export',
templateUrl: './database-export.component.html',
styleUrls: ['./database-export.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatProgressBarModule,
MatTooltip,
NgClass,
DatePipe,
FaIconComponent
]
})
export class DatabaseExportComponent implements OnInit {
private service = inject(DatabaseExportService);
private formBuilder = inject(FormBuilder);
private snackBar = inject(MatSnackBar);
private authenticationService = inject(AuthenticationService);
/** Used to keep the download to the requester; the backend enforces the same rule regardless. */
private currentUserId: number | null = this.authenticationService.getCredentials()?.userId ?? null;
displayedColumns: string[] = [
'id',
'status',
'reason',
'requestedBy',
'approvedBy',
'generatedOn',
'actions'
];
dataSource = new MatTableDataSource<any>();
loading = false;
submitting = false;
downloadingId: number | null = null;
activeStatus: string | null = null;
requestForm: FormGroup = this.formBuilder.group({
reason: [
'',
[
Validators.required,
Validators.minLength(10),
Validators.maxLength(1000)
]
]
});
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
ngOnInit() {
this.load();
}
load() {
this.loading = true;
this.service.getRequests(this.activeStatus || undefined).subscribe({
next: (rows: any) => this.applyRows(rows || []),
error: () => this.applyRows([])
});
}
private applyRows(rows: any[]) {
this.dataSource = new MatTableDataSource(rows);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.loading = false;
}
filterByStatus(status: string | null) {
this.activeStatus = this.activeStatus === status ? null : status;
this.load();
}
submitRequest() {
if (this.requestForm.invalid) {
this.requestForm.markAllAsTouched();
return;
}
this.submitting = true;
this.service.requestExport(this.requestForm.value.reason.trim()).subscribe({
next: () => {
this.submitting = false;
this.requestForm.reset({ reason: '' });
this.snackBar.open('Export request submitted for approval', 'Close', { duration: 4000 });
this.load();
},
error: (err: any) => {
this.submitting = false;
this.snackBar.open(this.errorMessage(err, 'Failed to submit the request'), 'Close', { duration: 6000 });
}
});
}
approve(row: any) {
this.service.approve(row.id).subscribe({
next: () => {
this.snackBar.open(`Request #${row.id} approved`, 'Close', { duration: 4000 });
this.load();
},
error: (err: any) => this.snackBar.open(this.errorMessage(err, 'Approval failed'), 'Close', { duration: 6000 })
});
}
reject(row: any) {
const reason = window.prompt(`Reject export request #${row.id}. Reason:`);
if (reason === null) {
return;
}
if (!reason.trim()) {
this.snackBar.open('A reason is required to reject a request', 'Close', { duration: 4000 });
return;
}
this.service.reject(row.id, reason.trim()).subscribe({
next: () => {
this.snackBar.open(`Request #${row.id} rejected`, 'Close', { duration: 4000 });
this.load();
},
error: (err: any) => this.snackBar.open(this.errorMessage(err, 'Rejection failed'), 'Close', { duration: 6000 })
});
}
download(row: any) {
this.downloadingId = row.id;
this.service.download(row.id).subscribe({
next: (blob: Blob) => {
this.downloadingId = null;
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = row.fileName || `cbsnexora-database-export-${row.id}.zip`;
link.click();
window.URL.revokeObjectURL(url);
// The status moves to GENERATED once the archive leaves the server, so reflect that.
this.load();
},
error: async (err: any) => {
this.downloadingId = null;
// The request asked for a Blob, so an error body arrives as a Blob too and has to be read back
// as text before the platform's message can be pulled out of it.
let message = 'Download failed';
if (err?.error instanceof Blob) {
try {
const parsed = JSON.parse(await err.error.text());
message = parsed?.defaultUserMessage || parsed?.errors?.[0]?.defaultUserMessage || message;
} catch {
// Body was not JSON; keep the generic message.
}
} else {
message = this.errorMessage(err, message);
}
this.snackBar.open(message, 'Close', { duration: 6000 });
}
});
}
isPending(row: any): boolean {
return row?.status === 'PENDING_APPROVAL';
}
/**
* Approving an export authorises it; it does not entitle the approver to a copy. Only the user who raised
* the request — and stated the reason it is recorded against — can take the archive, and only once.
*/
isDownloadable(row: any): boolean {
return row?.status === 'APPROVED' && this.isRequester(row);
}
isRequester(row: any): boolean {
return this.currentUserId != null && Number(row?.requestedBy) === Number(this.currentUserId);
}
/** Shown in place of the button so an approver sees the archive is withheld by design, not by a fault. */
isWithheld(row: any): boolean {
return row?.status === 'APPROVED' && !this.isRequester(row);
}
/** The approval has been spent; another copy means another request. */
isSpent(row: any): boolean {
return row?.status === 'GENERATED';
}
statusLabel(row: any): string {
switch (row?.status) {
case 'PENDING_APPROVAL':
return 'Awaiting Approval';
case 'APPROVED':
return 'Approved';
case 'REJECTED':
return 'Rejected';
case 'GENERATED':
return 'Downloaded';
default:
return row?.status || '—';
}
}
statusClass(row: any): string {
switch (row?.status) {
case 'APPROVED':
return 'badge-approved';
case 'REJECTED':
return 'badge-rejected';
case 'GENERATED':
return 'badge-generated';
default:
return 'badge-pending';
}
}
formatSize(bytes: number): string {
if (!bytes) {
return '—';
}
const mb = bytes / (1024 * 1024);
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB`;
}
private errorMessage(err: any, fallback: string): string {
return err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || fallback;
}
}
@@ -0,0 +1,45 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** Approval-gated full export of the tenant database. */
@Injectable({ providedIn: 'root' })
export class DatabaseExportService {
private http = inject(HttpClient);
getRequests(status?: string): Observable<any> {
let params = new HttpParams();
if (status) {
params = params.set('status', status);
}
return this.http.get('/databaseexport', { params });
}
/** @param reason why the export is needed — recorded against the request and inside the archive. */
requestExport(reason: string): Observable<any> {
return this.http.post('/databaseexport', { reason });
}
approve(requestId: number): Observable<any> {
return this.http.post(`/databaseexport/${requestId}/approve`, {});
}
reject(requestId: number, rejectionReason: string): Observable<any> {
return this.http.post(`/databaseexport/${requestId}/reject`, { rejectionReason });
}
/** The archive is binary, so it bypasses the JSON interceptors and comes back as a Blob. */
download(requestId: number): Observable<Blob> {
return this.http.get(`/databaseexport/${requestId}/download`, {
responseType: 'blob' as 'json'
}) as Observable<Blob>;
}
}
@@ -18,6 +18,7 @@ import { Form60Component } from './certificates/form-60/form-60.component';
import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
import { NocCertificateComponent } from './certificates/noc/noc.component';
import { DatabaseExportComponent } from './database-export/database-export.component';
const routes: Routes = [
Route.withShell([
@@ -56,6 +57,11 @@ const routes: Routes = [
component: HypothecationCertificateComponent,
data: { title: 'Hypothecation Certificate', breadcrumb: 'Hypothecation Certificate' }
},
{
path: 'letter-mgmt/database-export',
component: DatabaseExportComponent,
data: { title: 'Database Export', breadcrumb: 'Database Export' }
},
{
path: 'letter-mgmt/noc',
component: NocCertificateComponent,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
<!--
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/.
-->
<h2 mat-dialog-title class="bsa-title">
<span>Bank Statement Analysis</span>
<span class="bsa-title-file">{{ data.fileName }}</span>
</h2>
<mat-dialog-content class="bsa-content">
<div class="bsa-hero">
<div class="bsa-gauge" [style.--bsa-gauge-pct]="summary.riskScore" [style.--bsa-gauge-color]="gaugeColor">
<div class="bsa-gauge-inner">
<span class="bsa-gauge-score">{{ summary.riskScore }}</span>
<span class="bsa-gauge-outof">/ 100</span>
</div>
</div>
<div class="bsa-hero-details">
<span [class]="riskBadgeClass">{{ summary.riskRating }} RISK</span>
<ul class="bsa-risk-reasons">
@for (reason of summary.riskReasons; track reason) {
<li>{{ reason }}</li>
}
</ul>
</div>
</div>
@if (summary.bouncedTransactions.length > 0) {
<div class="bsa-bounce-alert">
<div class="bsa-bounce-alert-title">
{{ summary.bouncedTransactions.length }} Bounced / Returned Transaction(s) Found
</div>
<ul>
@for (b of summary.bouncedTransactions; track b.date) {
<li>{{ b.date | date: 'dd-MM-yyyy' }} — {{ b.narration }} (₹{{ b.amount | number: '1.0-0' }})</li>
}
</ul>
</div>
}
<div class="bsa-kpi-grid">
<div class="bsa-kpi bsa-kpi--income">
<span class="bsa-kpi-label">Avg Monthly Income</span>
<span class="bsa-kpi-value">₹{{ summary.avgMonthlyCredit | number: '1.0-0' }}</span>
</div>
<div class="bsa-kpi bsa-kpi--expense">
<span class="bsa-kpi-label">Avg Monthly Expense</span>
<span class="bsa-kpi-value">₹{{ summary.avgMonthlyDebit | number: '1.0-0' }}</span>
</div>
<div class="bsa-kpi bsa-kpi--neutral">
<span class="bsa-kpi-label">Average Balance</span>
<span class="bsa-kpi-value">₹{{ summary.avgBalance | number: '1.0-0' }}</span>
</div>
<div class="bsa-kpi bsa-kpi--neutral">
<span class="bsa-kpi-label">Closing Balance</span>
<span class="bsa-kpi-value">₹{{ summary.closingBalance | number: '1.0-0' }}</span>
</div>
<div class="bsa-kpi bsa-kpi--neutral">
<span class="bsa-kpi-label">Minimum Balance</span>
<span class="bsa-kpi-value">₹{{ summary.minBalance | number: '1.0-0' }}</span>
</div>
<div class="bsa-kpi bsa-kpi--neutral">
<span class="bsa-kpi-label">Maximum Balance</span>
<span class="bsa-kpi-value">₹{{ summary.maxBalance | number: '1.0-0' }}</span>
</div>
<div class="bsa-kpi bsa-kpi--expense">
<span class="bsa-kpi-label">Existing EMI / Loan Obligations</span>
<span class="bsa-kpi-value">₹{{ summary.existingMonthlyObligations | number: '1.0-0' }}/mo</span>
</div>
<div class="bsa-kpi bsa-kpi--neutral">
<span class="bsa-kpi-label">FOIR (Obligations ÷ Income)</span>
<span class="bsa-kpi-value">{{ summary.foirPercent | number: '1.0-1' }}%</span>
</div>
<div class="bsa-kpi bsa-kpi--neutral">
<span class="bsa-kpi-label">Income Stability</span>
<span class="bsa-kpi-value">{{ summary.incomeStability === 'STABLE' ? 'Stable' : 'Irregular' }}</span>
</div>
<div class="bsa-kpi bsa-kpi--income">
<span class="bsa-kpi-label">Positive Cash-Flow Months</span>
<span class="bsa-kpi-value">{{ summary.positiveCashFlowMonths }} / {{ summary.monthsCovered }}</span>
</div>
<div class="bsa-kpi bsa-kpi--expense">
<span class="bsa-kpi-label">Negative Cash-Flow Months</span>
<span class="bsa-kpi-value">{{ summary.negativeCashFlowMonths }} / {{ summary.monthsCovered }}</span>
</div>
<div class="bsa-kpi bsa-kpi--neutral">
<span class="bsa-kpi-label">Salary Credits Detected</span>
<span class="bsa-kpi-value">{{ summary.salaryCreditsDetected }}</span>
</div>
<div class="bsa-kpi bsa-kpi--neutral">
<span class="bsa-kpi-label">Total Transactions</span>
<span class="bsa-kpi-value">{{ summary.transactionCount }}</span>
</div>
<div class="bsa-kpi bsa-kpi--neutral">
<span class="bsa-kpi-label">Period Covered</span>
<span class="bsa-kpi-value">{{ summary.monthsCovered }} months</span>
</div>
</div>
<div class="bsa-chart-panel">
<h3 class="bsa-chart-panel-title">Monthly Income vs Expense</h3>
<div class="bsa-chart-canvas-wrap">
<canvas id="bsaIncomeExpenseChart"></canvas>
</div>
</div>
<div class="bsa-chart-panel">
<h3 class="bsa-chart-panel-title">Account Balance Trend</h3>
<div class="bsa-chart-canvas-wrap">
<canvas id="bsaBalanceTrendChart"></canvas>
</div>
</div>
@if (summary.categoryBreakdown.length > 0) {
<div class="bsa-chart-panel">
<h3 class="bsa-chart-panel-title">Where the Money Goes</h3>
<div class="bsa-chart-canvas-wrap bsa-chart-canvas-wrap--category">
<canvas id="bsaCategoryChart"></canvas>
</div>
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
@@ -0,0 +1,218 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.bsa-title {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
}
.bsa-title-file {
font-size: 13px;
font-weight: 500;
color: #94a3b8;
}
.bsa-content {
min-width: 520px;
max-width: 900px;
}
// -- Risk hero: a CSS conic-gradient gauge ring (no canvas dependency, always pixel-correct) --
.bsa-hero {
display: flex;
align-items: center;
gap: 24px;
padding: 20px 24px;
margin-bottom: 18px;
border-radius: 16px;
background: linear-gradient(135deg, #f8fafc, #eef2ff);
border: 1px solid #e2e8f0;
}
.bsa-gauge {
--bsa-gauge-pct: 0;
--bsa-gauge-color: #10b981;
flex-shrink: 0;
width: 128px;
height: 128px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: conic-gradient(var(--bsa-gauge-color) calc(var(--bsa-gauge-pct) * 1%), #e2e8f0 0);
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
}
.bsa-gauge-inner {
width: 98px;
height: 98px;
border-radius: 50%;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px #f1f5f9;
}
.bsa-gauge-score {
font-size: 30px;
font-weight: 800;
color: #0f172a;
line-height: 1;
}
.bsa-gauge-outof {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
margin-top: 2px;
}
.bsa-hero-details {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.bsa-risk-badge {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 5px 14px;
border-radius: 999px;
font-size: 12.5px;
font-weight: 800;
letter-spacing: 0.05em;
&--low {
background: #d1fae5;
color: #047857;
}
&--medium {
background: #fef3c7;
color: #b45309;
}
&--high {
background: #fee2e2;
color: #b91c1c;
}
}
.bsa-risk-reasons {
margin: 0;
padding-left: 18px;
font-size: 13px;
color: #334155;
line-height: 1.6;
}
// -- Bounced/returned transactions -- the strongest single red flag, called out on its own --
.bsa-bounce-alert {
padding: 14px 18px;
margin-bottom: 18px;
border-radius: 14px;
border: 1px solid #fecaca;
background: #fef2f2;
}
.bsa-bounce-alert-title {
font-size: 13px;
font-weight: 800;
color: #b91c1c;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 6px;
}
.bsa-bounce-alert ul {
margin: 0;
padding-left: 18px;
font-size: 13px;
color: #7f1d1d;
line-height: 1.6;
}
// -- KPI cards --
.bsa-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.bsa-kpi {
display: flex;
flex-direction: column;
gap: 5px;
padding: 12px 14px;
border-radius: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 3px solid #cbd5e1;
&--income {
border-left-color: #10b981;
}
&--expense {
border-left-color: #f43f5e;
}
&--neutral {
border-left-color: #6366f1;
}
}
.bsa-kpi-label {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
.bsa-kpi-value {
font-size: 17px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
// -- Chart panels --
.bsa-chart-panel {
padding: 18px 20px 8px;
margin-bottom: 18px;
border-radius: 16px;
background: #fff;
border: 1px solid #e2e8f0;
}
.bsa-chart-panel-title {
margin: 0 0 14px;
font-size: 13px;
font-weight: 700;
color: #334155;
}
.bsa-chart-canvas-wrap {
position: relative;
height: 240px;
&--category {
height: 280px;
max-width: 460px;
margin: 0 auto;
}
}
@@ -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/.
*/
import { AfterViewInit, Component, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { Chart, registerables } from 'chart.js';
import { BankStatementSummary } from './bank-statement-analysis.util';
Chart.register(...registerables);
Chart.defaults.font.family = "'Inter', 'Segoe UI', -apple-system, sans-serif";
Chart.defaults.color = '#64748b';
Chart.defaults.plugins.legend.labels.usePointStyle = true;
Chart.defaults.plugins.legend.labels.boxWidth = 8;
export interface BankStatementAnalysisDialogData {
fileName: string;
summary: BankStatementSummary;
}
const CATEGORY_COLORS = [
'#6366f1',
'#10b981',
'#f43f5e',
'#f59e0b',
'#0ea5e9',
'#a855f7',
'#ec4899',
'#14b8a6',
'#94a3b8',
'#84cc16',
'#fb923c'
];
/**
* Shows a bank statement's transaction history rolled up into what a credit officer actually
* needs from it -- a plain-language risk read, existing-obligation load (FOIR), bounced-
* transaction red flags, monthly income vs expense, the balance trend, and where the money
* actually goes -- opened straight after a CSV bank statement is picked in the KYC step, no
* separate "analyze" action needed.
*/
@Component({
selector: 'mifosx-bank-statement-analysis-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule
],
templateUrl: './bank-statement-analysis-dialog.component.html',
styleUrls: ['./bank-statement-analysis-dialog.component.scss']
})
export class BankStatementAnalysisDialogComponent implements AfterViewInit, OnDestroy {
data = inject<BankStatementAnalysisDialogData>(MAT_DIALOG_DATA);
private barChart: any = null;
private balanceChart: any = null;
private categoryChart: any = null;
get summary(): BankStatementSummary {
return this.data.summary;
}
get riskBadgeClass(): string {
return `bsa-risk-badge bsa-risk-badge--${this.summary.riskRating.toLowerCase()}`;
}
/** Drives the CSS conic-gradient gauge ring -- a plain custom-property, not a canvas chart, so
* it always renders pixel-correct without depending on Chart.js's own gauge quirks. */
get gaugeColor(): string {
return this.summary.riskRating === 'LOW' ? '#10b981' : this.summary.riskRating === 'MEDIUM' ? '#f59e0b' : '#f43f5e';
}
ngAfterViewInit(): void {
const s = this.summary;
const labels = s.monthly.map((m) => m.label);
const barCtx = (document.getElementById('bsaIncomeExpenseChart') as HTMLCanvasElement)?.getContext('2d');
const balanceCtx = (document.getElementById('bsaBalanceTrendChart') as HTMLCanvasElement)?.getContext('2d');
const balanceGradient = balanceCtx?.createLinearGradient(0, 0, 0, 260);
balanceGradient?.addColorStop(0, 'rgba(99, 102, 241, 0.35)');
balanceGradient?.addColorStop(1, 'rgba(99, 102, 241, 0)');
this.barChart = new Chart(barCtx ?? 'bsaIncomeExpenseChart', {
type: 'bar',
data: {
labels,
datasets: [
{
label: 'Credits (Income)',
data: s.monthly.map((m) => m.credit),
backgroundColor: '#10b981',
borderRadius: 6,
barPercentage: 0.7,
categoryPercentage: 0.6
},
{
label: 'Debits (Expense)',
data: s.monthly.map((m) => m.debit),
backgroundColor: '#f43f5e',
borderRadius: 6,
barPercentage: 0.7,
categoryPercentage: 0.6
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'top', align: 'end' } },
scales: {
x: { grid: { display: false } },
y: { beginAtZero: true, grid: { color: '#f1f5f9' } }
}
}
});
this.balanceChart = new Chart(balanceCtx ?? 'bsaBalanceTrendChart', {
type: 'line',
data: {
labels,
datasets: [
{
label: 'End-of-Month Balance',
data: s.monthly.map((m) => m.closingBalance),
borderColor: '#6366f1',
backgroundColor: balanceGradient ?? 'rgba(99, 102, 241, 0.15)',
pointBackgroundColor: '#6366f1',
pointRadius: 3,
pointHoverRadius: 6,
borderWidth: 2.5,
fill: true,
tension: 0.35
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false } },
y: { beginAtZero: false, grid: { color: '#f1f5f9' } }
}
}
});
if (s.categoryBreakdown.length) {
this.categoryChart = new Chart('bsaCategoryChart', {
type: 'doughnut',
data: {
labels: s.categoryBreakdown.map((c) => c.category),
datasets: [
{
data: s.categoryBreakdown.map((c) => c.amount),
backgroundColor: s.categoryBreakdown.map((_, i) => CATEGORY_COLORS[i % CATEGORY_COLORS.length]),
borderWidth: 2,
borderColor: '#fff',
hoverOffset: 8
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '68%',
plugins: { legend: { position: 'right', labels: { padding: 14 } } }
}
});
}
}
ngOnDestroy(): void {
this.barChart?.destroy();
this.balanceChart?.destroy();
this.categoryChart?.destroy();
}
}
@@ -0,0 +1,427 @@
/**
* 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/.
*/
export interface BankTransaction {
date: Date;
narration: string;
debit: number;
credit: number;
balance: number;
}
export interface MonthlyBankSummary {
label: string;
credit: number;
debit: number;
closingBalance: number;
netCashFlow: number;
}
export interface BouncedTransaction {
date: Date;
narration: string;
amount: number;
}
export interface CategorySpend {
category: string;
amount: number;
}
export type RiskRating = 'LOW' | 'MEDIUM' | 'HIGH';
export interface BankStatementSummary {
openingBalance: number;
closingBalance: number;
minBalance: number;
maxBalance: number;
avgBalance: number;
totalCredit: number;
totalDebit: number;
avgMonthlyCredit: number;
avgMonthlyDebit: number;
transactionCount: number;
monthsCovered: number;
salaryCreditsDetected: number;
monthly: MonthlyBankSummary[];
// -- Underwriting-focused signals a loan officer actually needs, not just cash-flow totals --
/** Bounced/returned debits (ECS return, cheque return, insufficient funds) -- the single
* strongest red flag a bank statement carries; a clean cash-flow summary can still hide these
* if nobody goes looking for the narration text specifically. */
bouncedTransactions: BouncedTransaction[];
/** Recurring EMI/loan-type debits already going out every month, annualised to a monthly
* average -- the "existing obligations" side of a FOIR (Fixed Obligation to Income Ratio)
* calculation. */
existingMonthlyObligations: number;
/** existingMonthlyObligations as a % of avgMonthlyCredit -- how much of this customer's income
* is already committed before a new EMI is even added. */
foirPercent: number;
/** Coefficient-of-variation based read on whether monthly income credits land at a
* similar amount each month (salaried-style) or swing widely (irregular/gig income). */
incomeStability: 'STABLE' | 'IRREGULAR';
/** How many of the covered months closed with more money in than out. */
positiveCashFlowMonths: number;
negativeCashFlowMonths: number;
/** Where the money actually goes -- narration-keyword-classified debit totals, for the
* expense-breakdown chart. */
categoryBreakdown: CategorySpend[];
/** A transparent, rule-based read (not a black-box score) an officer can act on immediately,
* plus the specific reasons behind it so it can be checked against the actual statement. */
riskRating: RiskRating;
riskScore: number;
riskReasons: string[];
}
/** Splits one CSV line into fields, honouring double-quoted values that may contain commas --
* a plain `.split(',')` would break on a quoted narration like "UPI-RENT, LANDLORD A/C". */
function splitCsvLine(line: string): string[] {
const fields: string[] = [];
let current = '';
let inQuotes = false;
for (const ch of line) {
if (ch === '"') {
inQuotes = !inQuotes;
} else if (ch === ',' && !inQuotes) {
fields.push(current);
current = '';
} else {
current += ch;
}
}
fields.push(current);
return fields;
}
function parseDDMMYYYY(value: string | undefined): Date | null {
const match = (value || '').trim().match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
if (!match) {
return null;
}
const [
,
day,
month,
year
] = match;
return new Date(+year, +month - 1, +day);
}
/** Parses a bank-statement CSV export (Date, Narration, Debit, Credit, Balance columns, in any
* order, matched case-insensitively) into transaction rows. Recognises a couple of common
* header aliases (e.g. "Withdrawal"/"Deposit") since not every bank's export uses "Debit"/
* "Credit" literally, but otherwise expects the same shape every Indian bank statement uses. */
export function parseBankStatementCsv(text: string): BankTransaction[] {
const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
if (lines.length < 2) {
return [];
}
const header = splitCsvLine(lines[0]).map((h) => h.trim().toLowerCase());
const columnIndex = (aliases: string[]): number => {
for (const alias of aliases) {
const idx = header.indexOf(alias);
if (idx >= 0) {
return idx;
}
}
return -1;
};
const dateIdx = columnIndex([
'date',
'txn date',
'transaction date'
]);
const narrationIdx = columnIndex([
'narration',
'description',
'particulars'
]);
const debitIdx = columnIndex([
'debit',
'withdrawal amt.',
'withdrawal'
]);
const creditIdx = columnIndex([
'credit',
'deposit amt.',
'deposit'
]);
const balanceIdx = columnIndex([
'balance',
'closing balance'
]);
if (dateIdx < 0 || balanceIdx < 0) {
return [];
}
const transactions: BankTransaction[] = [];
for (let i = 1; i < lines.length; i++) {
const cols = splitCsvLine(lines[i]);
const date = parseDDMMYYYY(cols[dateIdx]);
if (!date) {
continue;
}
transactions.push({
date,
narration: narrationIdx >= 0 ? (cols[narrationIdx] || '').trim() : '',
debit: parseFloat(cols[debitIdx]) || 0,
credit: parseFloat(cols[creditIdx]) || 0,
balance: parseFloat(cols[balanceIdx]) || 0
});
}
return transactions;
}
const BOUNCE_KEYWORDS = [
'RETURN',
'BOUNCE',
'DISHONOUR',
'DISHONOR',
'INSUFFICIENT FUND',
'INSUFFICIENT BALANCE'
];
function isBouncedTransaction(narration: string): boolean {
const n = narration.toUpperCase();
return BOUNCE_KEYWORDS.some((k) => n.includes(k));
}
function isExistingObligationDebit(narration: string): boolean {
const n = narration.toUpperCase();
return (n.includes('EMI') || n.includes('LOAN')) && !isBouncedTransaction(n);
}
/** Keyword categories checked in this order -- first match wins, so a more specific label (e.g.
* Insurance) is claimed before a generic one (e.g. Bank Charges) can grab it. */
const CATEGORY_RULES: Array<{ category: string; keywords: string[] }> = [
{ category: 'Bounced / Returned', keywords: BOUNCE_KEYWORDS },
{ category: 'Insurance', keywords: [
'LIC',
'INSURANCE',
'PREMIUM'
] },
{ category: 'EMI / Loan Repayment', keywords: [
'EMI',
'LOAN'
] },
{ category: 'Rent', keywords: ['RENT'] },
{ category: 'Utilities & Bills', keywords: [
'ELECTRICITY',
'RECHARGE',
'BBPS',
'BILL',
'JIO'
] },
{ category: 'Groceries', keywords: [
'BIGBASKET',
'DMART',
'RELIANCE FRESH',
'KIRANA',
'SUPERMARKET',
'GROCERY'
] },
{ category: 'Dining & Food Delivery', keywords: [
'SWIGGY',
'ZOMATO',
'PIZZA',
'CAFE',
'RESTAURANT'
] },
{ category: 'Shopping', keywords: [
'AMAZON',
'FLIPKART',
'MYNTRA',
'AJIO',
'GARMENTS',
'SHOP'
] },
{ category: 'Fuel & Transport', keywords: [
'PETROL',
'FUEL',
'AUTO FARE'
] },
{ category: 'Cash Withdrawal', keywords: ['ATM'] },
{ category: 'Bank Charges', keywords: [
'CHARGE',
'FEE',
'PENALTY'
] }
];
function categorize(narration: string): string {
const n = narration.toUpperCase();
for (const rule of CATEGORY_RULES) {
if (rule.keywords.some((k) => n.includes(k))) {
return rule.category;
}
}
return 'Other';
}
/** Coefficient of variation on the months that actually saw a credit -- a salaried applicant's
* income lands at a similar figure every month (low variation); gig/business income swings
* much more (high variation), which is exactly the distinction a lender needs to know before
* trusting a monthly average. */
function assessIncomeStability(monthly: MonthlyBankSummary[]): 'STABLE' | 'IRREGULAR' {
const credits = monthly.map((m) => m.credit).filter((c) => c > 0);
if (credits.length < 2) {
return 'STABLE';
}
const mean = credits.reduce((sum, c) => sum + c, 0) / credits.length;
const variance = credits.reduce((sum, c) => sum + (c - mean) ** 2, 0) / credits.length;
const coefficientOfVariation = mean > 0 ? Math.sqrt(variance) / mean : 0;
return coefficientOfVariation < 0.25 ? 'STABLE' : 'IRREGULAR';
}
/** A transparent, rule-based risk read instead of an opaque score -- every point lost is tied to
* a specific, checkable reason, which is what a real credit decision needs to be defensible. */
function assessRisk(input: {
bounceCount: number;
foirPercent: number;
minBalance: number;
avgMonthlyCredit: number;
incomeStability: 'STABLE' | 'IRREGULAR';
}): { rating: RiskRating; score: number; reasons: string[] } {
let score = 100;
const reasons: string[] = [];
if (input.bounceCount > 0) {
score -= Math.min(40, input.bounceCount * 20);
reasons.push(`${input.bounceCount} bounced/returned transaction(s) found`);
}
if (input.foirPercent > 50) {
score -= 25;
reasons.push(`Existing obligations are ${input.foirPercent.toFixed(0)}% of average income`);
} else if (input.foirPercent > 35) {
score -= 10;
reasons.push(`Existing obligations run moderate at ${input.foirPercent.toFixed(0)}% of average income`);
}
if (input.minBalance < 0) {
score -= 20;
reasons.push('Account balance went negative at least once');
} else if (input.avgMonthlyCredit > 0 && input.minBalance < input.avgMonthlyCredit * 0.1) {
score -= 10;
reasons.push('Minimum balance runs very thin relative to income');
}
if (input.incomeStability === 'IRREGULAR') {
score -= 15;
reasons.push('Monthly income credits are irregular, not a steady salaried pattern');
}
score = Math.max(0, Math.min(100, score));
if (reasons.length === 0) {
reasons.push('No red flags found in this statement');
}
const rating: RiskRating = score >= 75 ? 'LOW' : score >= 50 ? 'MEDIUM' : 'HIGH';
return { rating, score, reasons };
}
/** Rolls a year of transactions up into the numbers a credit officer actually reads a bank
* statement for: average monthly inflow/outflow, how tight the balance runs, how many salary-
* like credits show up, whether anything ever bounced, how much of the income is already
* committed to existing EMIs, and a plain-language risk read tying all of that together. */
export function summarizeBankStatement(transactions: BankTransaction[]): BankStatementSummary {
const sorted = [...transactions].sort((a, b) => a.date.getTime() - b.date.getTime());
const monthlyMap = new Map<string, MonthlyBankSummary>();
const bouncedTransactions: BouncedTransaction[] = [];
const categoryTotals = new Map<string, number>();
let totalCredit = 0;
let totalDebit = 0;
let salaryCreditsDetected = 0;
let existingMonthlyObligations = 0;
let minBalance = Infinity;
let maxBalance = -Infinity;
let balanceSum = 0;
for (const t of sorted) {
const key = `${t.date.getFullYear()}-${t.date.getMonth()}`;
if (!monthlyMap.has(key)) {
monthlyMap.set(key, {
label: t.date.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' }),
credit: 0,
debit: 0,
closingBalance: t.balance,
netCashFlow: 0
});
}
const month = monthlyMap.get(key)!;
month.credit += t.credit;
month.debit += t.debit;
month.closingBalance = t.balance;
month.netCashFlow = month.credit - month.debit;
totalCredit += t.credit;
totalDebit += t.debit;
minBalance = Math.min(minBalance, t.balance);
maxBalance = Math.max(maxBalance, t.balance);
balanceSum += t.balance;
if (t.credit >= 20000 && /salary/i.test(t.narration)) {
salaryCreditsDetected++;
}
if (t.debit > 0) {
if (isBouncedTransaction(t.narration)) {
bouncedTransactions.push({ date: t.date, narration: t.narration, amount: t.debit });
} else if (isExistingObligationDebit(t.narration)) {
existingMonthlyObligations += t.debit;
}
const category = categorize(t.narration);
categoryTotals.set(category, (categoryTotals.get(category) ?? 0) + t.debit);
}
}
const monthly = [...monthlyMap.values()];
const monthsCovered = monthly.length || 1;
const first = sorted[0];
const avgMonthlyCredit = totalCredit / monthsCovered;
const avgMonthlyObligations = existingMonthlyObligations / monthsCovered;
const foirPercent = avgMonthlyCredit > 0 ? (avgMonthlyObligations / avgMonthlyCredit) * 100 : 0;
const incomeStability = assessIncomeStability(monthly);
const positiveCashFlowMonths = monthly.filter((m) => m.netCashFlow >= 0).length;
const negativeCashFlowMonths = monthly.length - positiveCashFlowMonths;
const categoryBreakdown: CategorySpend[] = [...categoryTotals.entries()]
.map(
([
category,
amount
]) => ({ category, amount })
)
.sort((a, b) => b.amount - a.amount);
const risk = assessRisk({
bounceCount: bouncedTransactions.length,
foirPercent,
minBalance: sorted.length ? minBalance : 0,
avgMonthlyCredit,
incomeStability
});
return {
openingBalance: first ? first.balance - first.credit + first.debit : 0,
closingBalance: sorted.length ? sorted[sorted.length - 1].balance : 0,
minBalance: sorted.length ? minBalance : 0,
maxBalance: sorted.length ? maxBalance : 0,
avgBalance: sorted.length ? balanceSum / sorted.length : 0,
totalCredit,
totalDebit,
avgMonthlyCredit,
avgMonthlyDebit: totalDebit / monthsCovered,
transactionCount: sorted.length,
monthsCovered,
salaryCreditsDetected,
monthly,
bouncedTransactions,
existingMonthlyObligations: avgMonthlyObligations,
foirPercent,
incomeStability,
positiveCashFlowMonths,
negativeCashFlowMonths,
categoryBreakdown,
riskRating: risk.rating,
riskScore: risk.score,
riskReasons: risk.reasons
};
}
@@ -0,0 +1,554 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import * as pdfjsLib from 'pdfjs-dist';
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import {
CicCreditAccount,
CicDpdCell,
CicDpdYearRow,
CicEnquiry,
CicReportAnalysis,
CicRiskBand
} from './cic-report-analysis.util';
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
interface PositionedItem {
text: string;
x: number;
y: number;
page: number;
}
interface TextLine {
page: number;
y: number;
text: string;
items: PositionedItem[];
}
const MONTH_ORDER = [
'Dec',
'Nov',
'Oct',
'Sep',
'Aug',
'Jul',
'Jun',
'May',
'Apr',
'Mar',
'Feb',
'Jan'
];
const LOAN_TYPE_KEYWORDS = [
'TWO WHEELER LOAN',
'PERSONAL LOAN',
'HOUSING LOAN',
'HOME LOAN',
'BL GENERAL',
'BUSINESS LOAN',
'GOLD LOAN',
'CONSUMER DURABLE LOAN',
'CREDIT CARD',
'AUTO LOAN',
'CAR LOAN',
'EDUCATION LOAN',
'AGRICULTURE LOAN',
'KISAN CREDIT CARD',
'OVERDRAFT',
'CASH CREDIT'
];
/**
* Real, best-effort extraction of a bureau credit report PDF's actual content -- score, account
* summary, per-account details and payment history -- using pdf.js for proper text+position
* extraction (handles real-world PDF compression/encoding pdf.js already solves, rather than a
* hand-rolled decompressor). Returns null whenever extraction can't find enough of the report's
* real structure to trust (no fabricated fallback data for any specific file), so the caller can
* fall back to the simulated report instead of silently showing something invented.
*
* `existingScore` is used only if the score genuinely can't be read as text off the page (some
* report generators draw the gauge's number as part of a vector/raster graphic rather than real
* selectable text, which no text-extraction approach can recover) -- falling back to whatever
* score this application already has on file avoids showing a number that visibly contradicts
* the rest of the screen.
*/
export async function parseCicPdfReport(file: File, existingScore = 0): Promise<CicReportAnalysis | null> {
try {
const items = await extractPositionedItems(file);
if (items.length === 0) {
return null;
}
const lines = reconstructLines(items);
return buildAnalysisFromLines(lines, items, existingScore);
} catch (err) {
console.warn('CIC PDF parse failed, falling back to simulated report:', err);
return null;
}
}
async function extractPositionedItems(file: File): Promise<PositionedItem[]> {
const buffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(buffer) }).promise;
const items: PositionedItem[] = [];
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const content = await page.getTextContent();
for (const raw of content.items) {
const item = raw as TextItem;
const text = item.str;
if (!text || !text.trim()) {
continue;
}
items.push({ text: text.trim(), x: item.transform[4], y: item.transform[5], page: pageNum });
}
(page as any).cleanup?.();
}
return items;
}
/** Groups positioned items into visual lines -- same page, y within a small tolerance of the
* line's first item -- then sorts each line left to right, matching how the text actually reads
* on the page regardless of the order pdf.js happened to emit the underlying content stream in. */
function reconstructLines(items: PositionedItem[]): TextLine[] {
const sorted = [...items].sort((a, b) => a.page - b.page || b.y - a.y || a.x - b.x);
const Y_TOLERANCE = 2.5;
const lines: TextLine[] = [];
for (const item of sorted) {
const current = lines[lines.length - 1];
if (current && current.page === item.page && Math.abs(current.items[0].y - item.y) <= Y_TOLERANCE) {
current.items.push(item);
} else {
lines.push({ page: item.page, y: item.y, text: '', items: [item] });
}
}
for (const line of lines) {
line.items.sort((a, b) => a.x - b.x);
line.text = line.items.map((i) => i.text).join(' ');
}
return lines;
}
function numFrom(text: string | undefined): number {
if (!text) {
return 0;
}
const match = text.replace(/,/g, '').match(/-?\d+(\.\d+)?/);
return match ? parseFloat(match[0]) : 0;
}
function lineMatching(lines: TextLine[], regex: RegExp, from = 0, to = lines.length): TextLine | undefined {
for (let i = from; i < Math.min(to, lines.length); i++) {
if (regex.test(lines[i].text)) {
return lines[i];
}
}
return undefined;
}
function valueAfterLabel(lines: TextLine[], label: RegExp, from = 0, to = lines.length): string {
const line = lineMatching(lines, label, from, to);
if (!line) {
return '';
}
return line.text.replace(label, '').trim();
}
function scoreBand(score: number): CicRiskBand {
if (score >= 750) {
return 'Excellent';
}
if (score >= 650) {
return 'Good';
}
if (score >= 550) {
return 'Fair';
}
return 'Poor';
}
function detectBureau(fullText: string): string {
if (/experian/i.test(fullText)) {
return 'Experian';
}
if (/cibil|transunion/i.test(fullText)) {
return 'CIBIL';
}
if (/equifax/i.test(fullText)) {
return 'Equifax';
}
if (/crif|high\s*mark/i.test(fullText)) {
return 'CRIF High Mark';
}
return 'CIBIL';
}
/** Score digits are normally their own PDF text run (rendered in a much larger font than the
* surrounding heading/paragraph text, inside the gauge graphic) -- checking the raw, ungrouped
* items directly sidesteps any line-reconstruction ambiguity entirely: it doesn't matter whether
* pdf.js's y-tolerance happened to merge that run onto the same visual line as "Score Factors"
* or the intro paragraph, because the ITEM itself still carries just "748", not the whole line's
* text. This is checked before anything line-based, since it's the least ambiguous signal. */
function detectScoreFromItems(items: PositionedItem[]): number | null {
const isolated = items
.map((item) => item.text.trim())
.filter((text) => /^([3-8]\d{2}|900)$/.test(text))
.map((text) => parseInt(text, 10));
return isolated.length === 1 ? isolated[0] : null;
}
/** Fallback for when the score isn't its own isolated text item (some report generators do embed
* it into a longer text run) -- scans lines around the "Score Factors"/"Credit Score" heading,
* preferring an isolated number-only line and skipping the "...ranges from 300 - 900" sentence
* describing the scale, which matches the exact same 3-digit pattern. */
function detectScoreFromLines(lines: TextLine[]): number | null {
const anchorLine = lineMatching(lines, /score factors/i) || lineMatching(lines, /credit score/i);
const anchorIdx = anchorLine ? lines.indexOf(anchorLine) : Math.floor(lines.length / 2);
const from = Math.max(0, anchorIdx - 15);
const to = Math.min(lines.length, anchorIdx + 5);
const candidates: { value: number; isolated: boolean; distance: number }[] = [];
for (let i = from; i < to; i++) {
const text = lines[i].text;
if (/ranges?\s+from/i.test(text)) {
continue;
}
const trimmed = text.trim();
if (/^([3-8]\d{2}|900)$/.test(trimmed)) {
candidates.push({ value: parseInt(trimmed, 10), isolated: true, distance: Math.abs(i - anchorIdx) });
continue;
}
if (/\d{3}\s*-\s*\d{3}/.test(text)) {
continue;
}
const m = text.match(/\b([3-8]\d{2}|900)\b/);
if (m) {
const val = parseInt(m[1], 10);
if (val >= 300 && val <= 900) {
candidates.push({ value: val, isolated: false, distance: Math.abs(i - anchorIdx) });
}
}
}
if (candidates.length === 0) {
return null;
}
candidates.sort((a, b) => (a.isolated === b.isolated ? a.distance - b.distance : a.isolated ? -1 : 1));
return candidates[0].value;
}
function detectScore(lines: TextLine[], items: PositionedItem[]): number | null {
return detectScoreFromItems(items) ?? detectScoreFromLines(lines);
}
function detectApplicantName(lines: TextLine[]): string {
const nameLine = lineMatching(lines, /^Name\b/i);
if (!nameLine) {
return '';
}
return nameLine.text.replace(/^Name\b/i, '').trim();
}
/** Column x-positions for a Payment History table's Dec..Jan header, read straight off the page
* so cells can be matched by nearest-column even though blank months carry no text at all. */
function buildColumnMap(headerLine: TextLine): Map<string, number> {
const map = new Map<string, number>();
for (const item of headerLine.items) {
if (MONTH_ORDER.includes(item.text)) {
map.set(item.text, item.x);
}
}
return map;
}
function nearestMonth(x: number, columns: Map<string, number>): string | null {
let best: string | null = null;
let bestDist = Infinity;
for (const [
month,
colX
] of columns) {
const dist = Math.abs(colX - x);
if (dist < bestDist) {
bestDist = dist;
best = month;
}
}
return bestDist < 25 ? best : null;
}
/** Parses one account's "Payment History" grid off the raw positioned items on its page --
* matches each DPD value's x-coordinate to the nearest month column rather than assuming a fixed
* token order, since blank (unreported) months carry no token at all. */
function parseDpdGrid(items: PositionedItem[], page: number, afterY: number, beforeY: number): CicDpdYearRow[] {
const pageItems = items.filter((i) => i.page === page && i.y <= afterY && i.y > beforeY);
const headerItems = pageItems.filter((i) => MONTH_ORDER.includes(i.text));
if (headerItems.length === 0) {
return [];
}
const headerY = headerItems[0].y;
const columns = new Map<string, number>();
for (const item of headerItems) {
if (Math.abs(item.y - headerY) < 2) {
columns.set(item.text, item.x);
}
}
const rows: CicDpdYearRow[] = [];
const yearItems = pageItems.filter((i) => /^20\d{2}$/.test(i.text) && i.y < headerY - 1);
for (const yearItem of yearItems) {
const rowItems = pageItems.filter((i) => Math.abs(i.y - yearItem.y) < 2 && i !== yearItem);
const cellMap = new Map<string, number>();
for (const cell of rowItems) {
if (!/^\d{1,3}$/.test(cell.text)) {
continue;
}
const month = nearestMonth(cell.x, columns);
if (month) {
cellMap.set(month, parseInt(cell.text, 10));
}
}
const cells: CicDpdCell[] = MONTH_ORDER.map((month) => ({
month,
dpd: cellMap.has(month) ? cellMap.get(month)! : null
}));
rows.push({ year: parseInt(yearItem.text, 10), cells });
}
return rows.sort((a, b) => b.year - a.year);
}
function splitLenderAndType(headerText: string): { lender: string; accountType: string } {
const cleaned = headerText.replace(/\bAcct\s*\d+\b/i, '').trim();
for (const keyword of LOAN_TYPE_KEYWORDS) {
if (cleaned.toUpperCase().startsWith(keyword)) {
return { accountType: keyword, lender: cleaned.slice(keyword.length).trim() || 'Unknown Lender' };
}
}
// Fallback: leading run of ALL-CAPS words is the type, the rest is the lender.
const words = cleaned.split(/\s+/);
let splitAt = 0;
while (splitAt < words.length && /^[A-Z0-9/&]+$/.test(words[splitAt])) {
splitAt++;
}
return {
accountType: words.slice(0, splitAt).join(' ') || 'LOAN',
lender: words.slice(splitAt).join(' ') || 'Unknown Lender'
};
}
function parseAccounts(lines: TextLine[], items: PositionedItem[]): CicCreditAccount[] {
const acctIndexes: number[] = [];
for (let i = 0; i < lines.length; i++) {
if (/\bAcct\s*\d+\s*$/i.test(lines[i].text) && lines[i].page > 1) {
acctIndexes.push(i);
}
}
const accounts: CicCreditAccount[] = [];
for (let a = 0; a < acctIndexes.length; a++) {
const startIdx = acctIndexes[a];
const endIdx = a + 1 < acctIndexes.length ? acctIndexes[a + 1] : lines.length;
const section = lines.slice(startIdx, endIdx);
const { lender, accountType } = splitLenderAndType(section[0].text);
const accountNo = valueAfterLabel(section, /^Account Number\b/i) || '—';
const rawStatus = valueAfterLabel(section, /^Account Status\b/i);
const totalWriteOffAmt = valueAfterLabel(section, /^Total Write-?off Amt\b/i) || '-';
const principalWriteOff = valueAfterLabel(section, /^Principal Write-?off\b/i) || '-';
const settlementAmt = valueAfterLabel(section, /^Settlement Amt\b/i) || '-';
const writtenOffSettledStatus = valueAfterLabel(section, /^Written[\s-]?off Settled Status\b/i) || '-';
// Every account section carries a "Written off Settled Status" *label* regardless of that
// account's actual status -- only trust an actual VALUE (Account Status itself, or that
// label's own value once it's not just "-") to decide this was genuinely written off.
const status: CicCreditAccount['status'] =
/written[\s-]?off/i.test(rawStatus) ||
(writtenOffSettledStatus !== '-' && /written[\s-]?off/i.test(writtenOffSettledStatus))
? 'WRITTEN-OFF'
: /closed/i.test(rawStatus)
? 'CLOSED'
: 'ACTIVE';
const ownership = /joint/i.test(valueAfterLabel(section, /^Ownership\b/i)) ? 'Joint' : 'Individual';
const dateOpened = valueAfterLabel(section, /^Date Opened\b/i) || '—';
const dateReportedLine = lineMatching(lines, /^Date Reported\b/i, Math.max(0, startIdx - 20), startIdx + 1);
const dateReported = dateReportedLine ? dateReportedLine.text.replace(/^Date Reported\b/i, '').trim() : dateOpened;
const dateClosed = valueAfterLabel(section, /^Date Closed\b/i) || '—';
const lastPaymentDate = valueAfterLabel(section, /^Last Payment Date\b/i) || '—';
const sanctionedAmt = numFrom(valueAfterLabel(section, /^Sanctioned Amt\b/i));
const currentBalance = numFrom(valueAfterLabel(section, /^Current Balance\b/i));
const overdueAmt = numFrom(valueAfterLabel(section, /^Amount Overdue\b/i));
const rateOfInterest = numFrom(valueAfterLabel(section, /^Rate of Interest\b/i));
const tenureMonths = Math.round(numFrom(valueAfterLabel(section, /^Repayment Tenure\b/i)));
const creditLimitAmt = valueAfterLabel(section, /^Credit Limit Amt\b/i) || '-';
const emi = valueAfterLabel(section, /^EMI\b/i) || '-';
const valueOfCollateral = valueAfterLabel(section, /^Value of Collateral\b/i) || '-';
const typeOfCollateral = valueAfterLabel(section, /^Type of Collateral\b/i) || '-';
const suitFiledWillfulDefault = status === 'WRITTEN-OFF' ? 'Yes' : '-';
const historyLine = lineMatching(section, /Payment History/i);
let dpdGrid: CicDpdYearRow[] = [];
let worstDpd = 0;
if (historyLine) {
const nextSectionLine = lineMatching(section, /Consumer Personal details/i, section.indexOf(historyLine));
dpdGrid = parseDpdGrid(
items,
historyLine.page,
historyLine.y,
nextSectionLine ? nextSectionLine.y : historyLine.y - 200
);
worstDpd = dpdGrid.reduce((max, row) => Math.max(max, ...row.cells.map((c) => (c.dpd === null ? 0 : c.dpd))), 0);
}
accounts.push({
lender: lender || 'Unknown Lender',
accountType: accountType || 'LOAN',
accountNo,
ownership,
secured: !/personal loan|credit card/i.test(accountType),
dateOpened,
dateReported,
dateClosed,
lastPaymentDate,
status,
sanctionedAmt,
currentBalance,
overdueAmt,
tenureMonths,
rateOfInterest,
creditLimitAmt,
emi,
valueOfCollateral,
typeOfCollateral,
suitFiledWillfulDefault,
writtenOffSettledStatus,
totalWriteOffAmt,
principalWriteOff,
settlementAmt,
dpdGrid,
worstDpd
});
}
return accounts;
}
function parseEnquiries(lines: TextLine[]): CicEnquiry[] {
const enquiries: CicEnquiry[] = [];
const startLine = lineMatching(lines, /CREDIT ENQUIRIES/i);
if (!startLine) {
return enquiries;
}
const startIdx = lines.indexOf(startLine);
const endLine = lineMatching(lines, /NON-CREDIT ENQUIRIES/i, startIdx);
const endIdx = endLine ? lines.indexOf(endLine) : lines.length;
const enqIndexes: number[] = [];
for (let i = startIdx; i < endIdx; i++) {
if (/\bCr Enq\s*\d+\s*$/i.test(lines[i].text)) {
enqIndexes.push(i);
}
}
for (let e = 0; e < enqIndexes.length; e++) {
const from = enqIndexes[e];
const to = e + 1 < enqIndexes.length ? enqIndexes[e + 1] : endIdx;
const section = lines.slice(from, to);
enquiries.push({
ern: valueAfterLabel(section, /^ERN\b/i) || '—',
institution: valueAfterLabel(section, /^Credit Institution Name\b/i) || 'Unknown Institution',
date: valueAfterLabel(section, /^Application date\b/i) || '—',
purpose: valueAfterLabel(section, /^Search Type\b/i) || 'Loan Enquiry',
amountApplied: numFrom(valueAfterLabel(section, /^Amount applied for\b/i)),
durationMonths: Math.round(numFrom(valueAfterLabel(section, /^Duration of Agreement\b/i)))
});
}
return enquiries;
}
function buildAnalysisFromLines(
lines: TextLine[],
items: PositionedItem[],
existingScore: number
): CicReportAnalysis | null {
const detectedScore = detectScore(lines, items);
const accounts = parseAccounts(lines, items);
if (detectedScore === null && accounts.length === 0) {
return null;
}
const fullText = lines.map((l) => l.text).join(' ');
const bureau = detectBureau(fullText);
const applicantName = detectApplicantName(lines) || 'Applicant';
const score = detectedScore ?? (existingScore > 0 ? existingScore : 650);
const band = scoreBand(score);
const activeAccounts = accounts.filter((a) => a.status === 'ACTIVE').length;
const closedAccounts = accounts.filter((a) => a.status === 'CLOSED').length;
const writtenOffAccounts = accounts.filter((a) => a.status === 'WRITTEN-OFF').length;
const totalCurrentBalance =
numFrom(valueAfterLabel(lines, /^Total Current Bal\.?\s*amt\b/i)) ||
accounts.reduce((sum, a) => sum + a.currentBalance, 0);
const sfWdWoSettledAmt = numFrom(valueAfterLabel(lines, /^SF\/WD\/WO\/Settled amt\b/i));
const securedAmt =
numFrom(valueAfterLabel(lines, /^Secured Accounts amt\b/i)) ||
accounts.filter((a) => a.secured).reduce((sum, a) => sum + a.currentBalance, 0);
const unsecuredAmt =
numFrom(valueAfterLabel(lines, /^Unsecured Accounts amt\b/i)) || totalCurrentBalance - securedAmt;
const totalOverdueAmt = accounts.reduce((sum, a) => sum + a.overdueAmt, 0);
const totalLatePayments = accounts.reduce(
(sum, a) => sum + a.dpdGrid.flatMap((row) => row.cells).filter((c) => c.dpd !== null && c.dpd > 0).length,
0
);
const enquiries = parseEnquiries(lines);
const enquiriesLast7Days = Math.round(numFrom(valueAfterLabel(lines, /^Last 7 days credit enquiries\b/i)));
const enquiriesLast30Days = Math.round(numFrom(valueAfterLabel(lines, /^Last 30 days credit enquiries\b/i)));
const enquiriesLast90Days = Math.round(numFrom(valueAfterLabel(lines, /^Last 90 days credit enquiries\b/i)));
const enquiriesLast180Days = Math.round(numFrom(valueAfterLabel(lines, /^Last 180 days credit enquiries\b/i)));
const recommendation =
writtenOffAccounts > 0
? `${writtenOffAccounts} written-off account(s) on record — this is a hard decline signal under most credit policies regardless of the numeric score.`
: totalOverdueAmt > 0
? `Score is ${band}, but ₹${totalOverdueAmt.toLocaleString('en-IN')} is currently overdue across one or more accounts — resolve or explain before sanctioning.`
: `Score is ${band} with a clean repayment record and no overdue amounts on the accounts read from this report.`;
return {
bureau,
reportDate: new Date().toLocaleDateString('en-IN', { day: '2-digit', month: '2-digit', year: 'numeric' }),
applicantName,
score,
band,
scoreFactors: [
'Recency: Recent Credit Account Defaults',
'Leverage: Credit Accounts with on-time re-payment history',
'Coverage: Non-delinquent and delinquent Credit Accounts',
'Delinquency Status: Defaults on Credit Accounts (current & recent periodic intervals)',
'Credit Applications: Credit Account Applications over last 30 days'
],
totalAccounts: accounts.length,
activeAccounts,
closedAccounts,
writtenOffAccounts,
totalCurrentBalance,
sfWdWoSettledAmt,
securedAmt,
unsecuredAmt,
totalOverdueAmt,
accounts,
enquiries,
enquiriesLast7Days,
enquiriesLast30Days,
enquiriesLast90Days,
enquiriesLast180Days,
totalLatePayments,
recommendation
};
}
@@ -0,0 +1,407 @@
<!--
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/.
-->
<div class="cra-dialog">
<!-- Header -->
<div class="cra-header">
<div class="cra-header-left">
<div class="cra-bureau-tag">
<span class="cra-bureau-icon">📊</span>
<span class="cra-bureau-name">{{ analysis.bureau }}</span>
</div>
<h2 class="cra-title">Credit Bureau Report &amp; Risk Intelligence</h2>
<span class="cra-file-pill" title="Uploaded PDF Report File">📄 {{ data.fileName }}</span>
</div>
<button mat-icon-button mat-dialog-close class="cra-close-btn" aria-label="Close dialog">
<span class="material-icons">close</span>
</button>
</div>
<!-- Navigation Tabs -->
<div class="cra-nav-tabs">
<button
type="button"
class="cra-tab-btn"
[class.cra-tab-btn--active]="activeTab === 'overview'"
(click)="selectTab('overview')"
>
<span class="cra-tab-icon">🎯</span>
<span>Overview &amp; Score</span>
</button>
<button
type="button"
class="cra-tab-btn"
[class.cra-tab-btn--active]="activeTab === 'accounts'"
(click)="selectTab('accounts')"
>
<span class="cra-tab-icon">💳</span>
<span>Accounts Summary ({{ analysis.totalAccounts }})</span>
</button>
<button
type="button"
class="cra-tab-btn"
[class.cra-tab-btn--active]="activeTab === 'dpd'"
(click)="selectTab('dpd')"
>
<span class="cra-tab-icon">📅</span>
<span>Payment History (DPD)</span>
</button>
<button
type="button"
class="cra-tab-btn"
[class.cra-tab-btn--active]="activeTab === 'enquiries'"
(click)="selectTab('enquiries')"
>
<span class="cra-tab-icon">🔍</span>
<span>Enquiries &amp; Audit ({{ analysis.enquiries.length }})</span>
</button>
</div>
<mat-dialog-content class="cra-content">
<!-- Parsed-from-file vs simulated indicator -- never a silent surprise -->
@if (data.wasParsedFromFile) {
<div class="cra-source-banner cra-source-banner--parsed">
✅ Read from the uploaded file — figures below came from "{{ data.fileName }}" itself.
</div>
} @else {
<div class="cra-source-banner cra-source-banner--simulated">
⚠️ Could not read this file's content — showing a simulated report based on this application's own credit score
instead.
</div>
}
<!-- Applicant Metadata Subheader -->
<div class="cra-applicant-bar">
<div class="cra-app-info">
<span class="cra-app-label">Applicant:</span>
<strong class="cra-app-val">{{ analysis.applicantName }}</strong>
</div>
<div class="cra-app-divider"></div>
<div class="cra-app-info">
<span class="cra-app-label">Report Date:</span>
<strong class="cra-app-val">{{ analysis.reportDate }}</strong>
</div>
<div class="cra-app-divider"></div>
<div class="cra-app-info">
<span class="cra-app-label">Bureau Source:</span>
<strong class="cra-app-val">{{ analysis.bureau }}</strong>
</div>
</div>
<!-- ================= TAB 1: OVERVIEW & SCORE ================= -->
@if (activeTab === 'overview') {
<div class="cra-tab-pane">
<div class="cra-hero-card">
<!-- Score Gauge -->
<div class="cra-gauge-box">
<div class="cra-gauge" [style.--cra-gauge-pct]="gaugePct" [style.--cra-gauge-color]="gaugeColor">
<div class="cra-gauge-inner">
<span class="cra-gauge-score">{{ analysis.score }}</span>
<span class="cra-gauge-max">/ 900</span>
</div>
</div>
<div class="cra-band-container">
<span [class]="bandBadgeClass">{{ analysis.band }} Risk</span>
</div>
</div>
<!-- Hero Recommendation & Highlights -->
<div class="cra-hero-details">
<div
class="cra-recom-card"
[class.cra-recom-card--warning]="analysis.totalOverdueAmt > 0 || analysis.writtenOffAccounts > 0"
>
<div class="cra-recom-header">
<span class="cra-recom-icon">
{{ analysis.writtenOffAccounts > 0 || analysis.totalOverdueAmt > 0 ? '⚠️' : '🛡️' }}
</span>
<span class="cra-recom-title">Underwriting Intelligence Summary</span>
</div>
<p class="cra-recom-text">{{ analysis.recommendation }}</p>
</div>
<!-- Key Quick Stats -->
<div class="cra-quick-kpis">
<div class="cra-quick-kpi">
<span class="cra-quick-kpi-val">₹{{ analysis.totalCurrentBalance | number: '1.0-0' }}</span>
<span class="cra-quick-kpi-lbl">Total Current Exposure</span>
</div>
<div class="cra-quick-kpi">
<span class="cra-quick-kpi-val">{{ analysis.activeAccounts }} / {{ analysis.totalAccounts }}</span>
<span class="cra-quick-kpi-lbl">Active Tradelines</span>
</div>
<div class="cra-quick-kpi" [class.cra-quick-kpi--warn]="analysis.totalOverdueAmt > 0">
<span class="cra-quick-kpi-val">₹{{ analysis.totalOverdueAmt | number: '1.0-0' }}</span>
<span class="cra-quick-kpi-lbl">Total Overdue</span>
</div>
</div>
</div>
</div>
<!-- Score Factors -->
<div class="cra-factors-section">
<h3 class="cra-section-title">Score Impacting Factors</h3>
<div class="cra-factors-grid">
@for (factor of analysis.scoreFactors; track factor; let i = $index) {
<div class="cra-factor-card">
<span class="cra-factor-num">{{ i + 1 }}</span>
<span class="cra-factor-text">{{ factor }}</span>
</div>
}
</div>
</div>
</div>
}
<!-- ================= TAB 2: ACCOUNTS SUMMARY ================= -->
@if (activeTab === 'accounts') {
<div class="cra-tab-pane">
<!-- Summary KPI Grid -->
<div class="cra-summary-grid">
<div class="cra-summary-box">
<div class="cra-summary-box-title">Credit Accounts Count</div>
<div class="cra-summary-row">
<span>Total Reported Accounts</span><strong>{{ analysis.totalAccounts }}</strong>
</div>
<div class="cra-summary-row">
<span>Active Tradelines</span><strong class="cra-txt-active">{{ analysis.activeAccounts }}</strong>
</div>
<div class="cra-summary-row">
<span>Closed Accounts</span><strong>{{ analysis.closedAccounts }}</strong>
</div>
<div class="cra-summary-row">
<span>SF/WD/WO Accounts</span><strong class="cra-txt-danger">{{ analysis.writtenOffAccounts }}</strong>
</div>
</div>
<div class="cra-summary-box">
<div class="cra-summary-box-title">Balance &amp; Exposure</div>
<div class="cra-summary-row">
<span>Total Current Balance</span><strong>₹{{ analysis.totalCurrentBalance | number: '1.0-0' }}</strong>
</div>
<div class="cra-summary-row">
<span>Secured Accounts Exposure</span><strong>₹{{ analysis.securedAmt | number: '1.0-0' }}</strong>
</div>
<div class="cra-summary-row">
<span>Unsecured Accounts Exposure</span><strong>₹{{ analysis.unsecuredAmt | number: '1.0-0' }}</strong>
</div>
<div class="cra-summary-row">
<span>Total Overdue Amount</span
><strong class="cra-txt-danger">₹{{ analysis.totalOverdueAmt | number: '1.0-0' }}</strong>
</div>
</div>
</div>
<!-- Account Table Summary -->
<div class="cra-table-panel">
<h3 class="cra-section-title">Credit Tradelines Summary</h3>
<div class="cra-table-scroll">
<table class="cra-table">
<thead>
<tr>
<th>Lender Name</th>
<th>Account Type</th>
<th>Account No.</th>
<th>Ownership</th>
<th>Date Opened</th>
<th>Sanction Amt</th>
<th>Current Balance</th>
<th>Overdue Amt</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (acc of analysis.accounts; track acc.accountNo) {
<tr>
<td class="cra-cell-lender">{{ acc.lender }}</td>
<td>{{ acc.accountType }}</td>
<td class="cra-cell-code">{{ acc.accountNo }}</td>
<td>{{ acc.ownership }}</td>
<td>{{ acc.dateOpened }}</td>
<td>₹{{ acc.sanctionedAmt | number: '1.0-0' }}</td>
<td>₹{{ acc.currentBalance | number: '1.0-0' }}</td>
<td [class.cra-txt-danger]="acc.overdueAmt > 0">₹{{ acc.overdueAmt | number: '1.0-0' }}</td>
<td>
<span [class]="accountStatusClass(acc.status)">{{ acc.status }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Detailed Account Cards -->
<h3 class="cra-section-title">Tradeline Details</h3>
@for (acc of analysis.accounts; track acc.accountNo) {
<div class="cra-account-card">
<div class="cra-account-card-header">
<div class="cra-account-title-group">
<span class="cra-account-card-title">{{ acc.accountType }}</span>
<span class="cra-account-lender">— {{ acc.lender }}</span>
</div>
<span [class]="accountStatusClass(acc.status)">{{ acc.status }}</span>
</div>
<dl class="cra-account-detail-grid">
<div class="cra-account-detail-field">
<dt>Account Number</dt>
<dd>{{ acc.accountNo }}</dd>
</div>
<div class="cra-account-detail-field">
<dt>Ownership</dt>
<dd>{{ acc.ownership }}</dd>
</div>
<div class="cra-account-detail-field">
<dt>Date Opened</dt>
<dd>{{ acc.dateOpened }}</dd>
</div>
<div class="cra-account-detail-field">
<dt>Date Reported</dt>
<dd>{{ acc.dateReported }}</dd>
</div>
<div class="cra-account-detail-field">
<dt>Sanctioned Amount</dt>
<dd>₹{{ acc.sanctionedAmt | number: '1.0-0' }}</dd>
</div>
<div class="cra-account-detail-field">
<dt>Current Balance</dt>
<dd>₹{{ acc.currentBalance | number: '1.0-0' }}</dd>
</div>
<div class="cra-account-detail-field">
<dt>Amount Overdue</dt>
<dd [class.cra-txt-danger]="acc.overdueAmt > 0">₹{{ acc.overdueAmt | number: '1.0-0' }}</dd>
</div>
<div class="cra-account-detail-field">
<dt>Rate of Interest</dt>
<dd>{{ acc.rateOfInterest > 0 ? acc.rateOfInterest + '%' : '-' }}</dd>
</div>
</dl>
</div>
}
</div>
}
<!-- ================= TAB 3: PAYMENT HISTORY (DPD) ================= -->
@if (activeTab === 'dpd') {
<div class="cra-tab-pane">
@if (analysis.accounts.length > 0) {
<div class="cra-chart-panel">
<h3 class="cra-section-title">Payment Behaviour (On-time vs Late Months)</h3>
<div class="cra-chart-canvas-wrap" [style.height.px]="80 + analysis.accounts.length * 46">
<canvas id="craDpdChart"></canvas>
</div>
</div>
}
<h3 class="cra-section-title">Month-by-Month Payment History (DPD Grid)</h3>
@for (acc of analysis.accounts; track acc.accountNo) {
<div class="cra-dpd-card">
<div class="cra-dpd-card-header">
<div class="cra-dpd-card-title">
<strong>{{ acc.lender }}</strong> · {{ acc.accountType }} ({{ acc.accountNo }})
</div>
<span class="cra-dpd-worst-badge" [class.cra-dpd-worst-badge--clean]="acc.worstDpd === 0">
{{ worstDpdLabel(acc.worstDpd) }}
</span>
</div>
<div class="cra-dpd-table-scroll">
<table class="cra-dpd-table">
<thead>
<tr>
<th class="cra-dpd-year-col">Year</th>
@for (m of monthHeaders; track m) {
<th>{{ m }}</th>
}
</tr>
</thead>
<tbody>
@for (row of acc.dpdGrid; track row.year) {
<tr>
<td class="cra-dpd-year-cell">{{ row.year }}</td>
@for (c of row.cells; track $index) {
<td>
<span [class]="dpdCellClass(c.dpd)">
{{ c.dpd === null ? '—' : c.dpd }}
</span>
</td>
}
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
}
<!-- ================= TAB 4: ENQUIRIES & AUDIT ================= -->
@if (activeTab === 'enquiries') {
<div class="cra-tab-pane">
<!-- Enquiry Stats -->
<div class="cra-enquiry-kpis">
<div class="cra-enquiry-kpi">
<span class="cra-enquiry-val">{{ analysis.enquiriesLast7Days }}</span>
<span class="cra-enquiry-lbl">Last 7 Days</span>
</div>
<div class="cra-enquiry-kpi">
<span class="cra-enquiry-val">{{ analysis.enquiriesLast30Days }}</span>
<span class="cra-enquiry-lbl">Last 30 Days</span>
</div>
<div class="cra-enquiry-kpi">
<span class="cra-enquiry-val">{{ analysis.enquiriesLast90Days }}</span>
<span class="cra-enquiry-lbl">Last 90 Days</span>
</div>
<div class="cra-enquiry-kpi">
<span class="cra-enquiry-val">{{ analysis.enquiriesLast180Days }}</span>
<span class="cra-enquiry-lbl">Last 180 Days</span>
</div>
</div>
<div class="cra-table-panel">
<h3 class="cra-section-title">Credit Bureau Enquiries List</h3>
@if (analysis.enquiries.length > 0) {
<div class="cra-table-scroll">
<table class="cra-table">
<thead>
<tr>
<th>ERN (Ref No)</th>
<th>Credit Institution Name</th>
<th>Application Date</th>
<th>Purpose / Search Type</th>
<th>Amount Applied</th>
<th>Tenure</th>
</tr>
</thead>
<tbody>
@for (enq of analysis.enquiries; track enq.ern) {
<tr>
<td class="cra-cell-code">{{ enq.ern }}</td>
<td class="cra-cell-lender">{{ enq.institution }}</td>
<td>{{ enq.date }}</td>
<td>{{ enq.purpose }}</td>
<td>₹{{ enq.amountApplied | number: '1.0-0' }}</td>
<td>{{ enq.durationMonths > 0 ? enq.durationMonths + ' mos' : '-' }}</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<p class="cra-empty-msg">No recent credit enquiries found in this report.</p>
}
</div>
</div>
}
</mat-dialog-content>
<!-- Footer Actions -->
<div class="cra-footer">
<button type="button" class="cra-footer-btn" mat-dialog-close>Close</button>
</div>
</div>
@@ -0,0 +1,780 @@
/**
* 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/.
*/
.cra-dialog {
display: flex;
flex-direction: column;
max-height: 90vh;
background: #f8fafc;
border-radius: 16px;
overflow: hidden;
font-family:
Inter,
system-ui,
-apple-system,
sans-serif;
color: #1e293b;
}
// Header
.cra-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
.cra-header-left {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.cra-bureau-tag {
display: inline-flex;
align-items: center;
gap: 6px;
background: #eff6ff;
color: #1d4ed8;
padding: 4px 10px;
border-radius: 8px;
font-size: 0.82rem;
font-weight: 700;
.cra-bureau-icon {
font-size: 1rem;
}
}
.cra-title {
margin: 0;
font-size: 1.15rem;
font-weight: 700;
color: #0f172a;
}
.cra-file-pill {
background: #f1f5f9;
color: #64748b;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.78rem;
font-family: monospace;
}
.cra-close-btn {
color: #64748b;
}
}
// Navigation Tabs
.cra-nav-tabs {
display: flex;
background: #fff;
padding: 0 24px;
border-bottom: 1px solid #e2e8f0;
gap: 8px;
.cra-tab-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: none;
background: transparent;
border-bottom: 3px solid transparent;
color: #64748b;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
.cra-tab-icon {
font-size: 1rem;
}
&:hover {
color: #0f172a;
background: #f8fafc;
}
&.cra-tab-btn--active {
color: #2563eb;
border-bottom-color: #2563eb;
background: #eff6ff;
}
}
}
// Dialog Content
.cra-content {
padding: 20px 24px;
overflow-y: auto;
flex: 1;
}
// Parsed-from-file vs simulated banner -- whichever it is, always shown, never a silent surprise
.cra-source-banner {
padding: 10px 16px;
border-radius: 10px;
margin-bottom: 16px;
font-size: 0.82rem;
font-weight: 600;
&--parsed {
background: #ecfdf5;
color: #047857;
border: 1px solid #a7f3d0;
}
&--simulated {
background: #fffbeb;
color: #b45309;
border: 1px solid #fde68a;
}
}
// Applicant Subheader Bar
.cra-applicant-bar {
display: flex;
align-items: center;
gap: 16px;
background: #fff;
padding: 10px 16px;
border-radius: 10px;
border: 1px solid #e2e8f0;
margin-bottom: 20px;
font-size: 0.84rem;
.cra-app-info {
display: flex;
align-items: center;
gap: 6px;
.cra-app-label {
color: #64748b;
}
.cra-app-val {
color: #0f172a;
}
}
.cra-app-divider {
width: 1px;
height: 14px;
background: #cbd5e1;
}
}
// Tab Pane Animation
.cra-tab-pane {
animation: fade-in 0.2s ease-in-out;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Hero Section (Score Gauge + Recommendations)
.cra-hero-card {
display: grid;
grid-template-columns: 240px 1fr;
gap: 20px;
background: #fff;
border-radius: 14px;
padding: 20px;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 12px rgb(15 23 42 / 3%);
margin-bottom: 24px;
@media (width <= 768px) {
grid-template-columns: 1fr;
}
}
.cra-gauge-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px;
border-right: 1px solid #f1f5f9;
@media (width <= 768px) {
border-right: none;
border-bottom: 1px solid #f1f5f9;
padding-bottom: 20px;
}
}
.cra-gauge {
width: 140px;
height: 140px;
border-radius: 50%;
background: conic-gradient(var(--cra-gauge-color, #10b981) calc(var(--cra-gauge-pct, 75) * 1%), #e2e8f0 0%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
.cra-gauge-inner {
width: 114px;
height: 114px;
border-radius: 50%;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: inset 0 2px 6px rgb(0 0 0 / 6%);
.cra-gauge-score {
font-size: 2rem;
font-weight: 800;
color: #0f172a;
line-height: 1;
}
.cra-gauge-max {
font-size: 0.72rem;
color: #64748b;
font-weight: 600;
margin-top: 2px;
}
}
}
.cra-band-container {
margin-top: 14px;
}
.cra-band-badge {
display: inline-block;
padding: 4px 14px;
border-radius: 20px;
font-size: 0.82rem;
font-weight: 700;
&.cra-band-badge--excellent {
background: #d1fae5;
color: #065f46;
}
&.cra-band-badge--good {
background: #e0f2fe;
color: #0369a1;
}
&.cra-band-badge--fair {
background: #fef3c7;
color: #92400e;
}
&.cra-band-badge--poor {
background: #ffe4e6;
color: #9f1239;
}
}
// Hero Recommendations & Quick KPIs
.cra-hero-details {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 16px;
}
.cra-recom-card {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 10px;
padding: 14px 16px;
&.cra-recom-card--warning {
background: #fff1f2;
border-color: #fecdd3;
}
.cra-recom-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
.cra-recom-icon {
font-size: 1.1rem;
}
.cra-recom-title {
font-weight: 700;
font-size: 0.9rem;
color: #0f172a;
}
}
.cra-recom-text {
margin: 0;
font-size: 0.85rem;
line-height: 1.45;
color: #334155;
}
}
.cra-quick-kpis {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
.cra-quick-kpi {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 14px;
display: flex;
flex-direction: column;
.cra-quick-kpi-val {
font-size: 1.1rem;
font-weight: 800;
color: #0f172a;
}
.cra-quick-kpi-lbl {
font-size: 0.75rem;
color: #64748b;
margin-top: 2px;
}
&.cra-quick-kpi--warn {
background: #fff1f2;
border-color: #fecdd3;
.cra-quick-kpi-val {
color: #e11d48;
}
}
}
}
// Factors Section
.cra-section-title {
font-size: 0.95rem;
font-weight: 700;
color: #0f172a;
margin: 20px 0 12px;
}
.cra-factors-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 10px;
}
.cra-factor-card {
display: flex;
align-items: flex-start;
gap: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 12px 14px;
.cra-factor-num {
background: #eff6ff;
color: #2563eb;
font-weight: 800;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.78rem;
flex-shrink: 0;
}
.cra-factor-text {
font-size: 0.82rem;
color: #334155;
line-height: 1.4;
}
}
// Summary Cards Grid (Tab 2)
.cra-summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 24px;
@media (width <= 768px) {
grid-template-columns: 1fr;
}
}
.cra-summary-box {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px 20px;
.cra-summary-box-title {
font-size: 0.9rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f1f5f9;
}
.cra-summary-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
font-size: 0.84rem;
color: #475569;
strong {
color: #0f172a;
font-weight: 700;
}
.cra-txt-active {
color: #059669;
}
.cra-txt-danger {
color: #dc2626;
}
}
}
// Tables Styling
.cra-table-panel {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 24px;
}
.cra-table-scroll {
overflow-x: auto;
}
.cra-table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
text-align: left;
th {
background: #f8fafc;
color: #475569;
font-weight: 700;
padding: 10px 12px;
border-bottom: 2px solid #e2e8f0;
white-space: nowrap;
}
td {
padding: 12px;
border-bottom: 1px solid #f1f5f9;
color: #1e293b;
&.cra-cell-lender {
font-weight: 600;
color: #0f172a;
}
&.cra-cell-code {
font-family: monospace;
font-size: 0.8rem;
color: #64748b;
}
}
tr:hover td {
background: #f8fafc;
}
}
.cra-txt-danger {
color: #dc2626 !important;
font-weight: 700;
}
// Status Badges
.cra-status-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
&.cra-status-badge--active {
background: #d1fae5;
color: #047857;
}
&.cra-status-badge--closed {
background: #f1f5f9;
color: #64748b;
}
&.cra-status-badge--written-off {
background: #ffe4e6;
color: #be123c;
}
}
// Tradeline Details Cards
.cra-account-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
.cra-account-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f1f5f9;
.cra-account-card-title {
font-weight: 700;
color: #0f172a;
font-size: 0.9rem;
}
.cra-account-lender {
color: #64748b;
font-size: 0.85rem;
margin-left: 6px;
}
}
.cra-account-detail-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin: 0;
@media (width <= 768px) {
grid-template-columns: repeat(2, 1fr);
}
.cra-account-detail-field {
dt {
font-size: 0.74rem;
color: #64748b;
margin-bottom: 2px;
}
dd {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: #0f172a;
}
}
}
}
// Chart Panel
.cra-chart-panel {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 24px;
}
.cra-chart-canvas-wrap {
width: 100%;
position: relative;
}
// DPD Matrix Cards & Heatmap Table
.cra-dpd-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
.cra-dpd-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.cra-dpd-card-title {
font-size: 0.88rem;
color: #334155;
}
.cra-dpd-worst-badge {
font-size: 0.76rem;
font-weight: 700;
padding: 3px 10px;
border-radius: 12px;
background: #fef3c7;
color: #b45309;
&.cra-dpd-worst-badge--clean {
background: #d1fae5;
color: #047857;
}
}
}
}
.cra-dpd-table-scroll {
overflow-x: auto;
}
.cra-dpd-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
text-align: center;
th {
background: #f8fafc;
color: #64748b;
padding: 6px;
border-bottom: 1px solid #e2e8f0;
}
.cra-dpd-year-col,
.cra-dpd-year-cell {
font-weight: 700;
color: #0f172a;
text-align: left;
padding-left: 8px;
width: 60px;
}
td {
padding: 6px 4px;
}
}
.cra-dpd-cell {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 24px;
border-radius: 6px;
font-weight: 700;
&.cra-dpd-cell--ontime {
background: #d1fae5;
color: #065f46;
}
&.cra-dpd-cell--late {
background: #fef3c7;
color: #92400e;
}
&.cra-dpd-cell--severe {
background: #ffe4e6;
color: #9f1239;
}
&.cra-dpd-cell--blank {
color: #cbd5e1;
}
}
// Enquiries KPIs
.cra-enquiry-kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
@media (width <= 600px) {
grid-template-columns: repeat(2, 1fr);
}
.cra-enquiry-kpi {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 12px;
text-align: center;
.cra-enquiry-val {
display: block;
font-size: 1.3rem;
font-weight: 800;
color: #0f172a;
}
.cra-enquiry-lbl {
font-size: 0.74rem;
color: #64748b;
margin-top: 2px;
}
}
}
.cra-empty-msg {
color: #64748b;
font-size: 0.85rem;
margin: 10px 0 0;
}
// Footer
.cra-footer {
display: flex;
justify-content: flex-end;
padding: 14px 24px;
background: #fff;
border-top: 1px solid #e2e8f0;
.cra-footer-btn {
background: #0f172a;
color: #fff;
border: none;
padding: 8px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 0.88rem;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #1e293b;
}
}
}
@@ -0,0 +1,194 @@
/**
* 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 { AfterViewInit, Component, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { Chart, registerables } from 'chart.js';
import { CicReportAnalysis } from './cic-report-analysis.util';
Chart.register(...registerables);
Chart.defaults.font.family = "'Inter', 'Segoe UI', -apple-system, sans-serif";
Chart.defaults.color = '#64748b';
Chart.defaults.plugins.legend.labels.usePointStyle = true;
Chart.defaults.plugins.legend.labels.boxWidth = 8;
export interface CicReportAnalysisDialogData {
fileName: string;
analysis: CicReportAnalysis;
/** True when every figure below was actually read out of the uploaded file's real content;
* false means the file's content couldn't be read and this is the deterministic simulated
* report instead -- shown plainly so that distinction is never a silent surprise. */
wasParsedFromFile: boolean;
}
const BAND_COLORS: Record<string, string> = {
Excellent: '#10b981',
Good: '#0ea5e9',
Fair: '#f59e0b',
Poor: '#f43f5e'
};
/**
* Shows a full bureau-style credit report analysis -- score gauge, score factors, account
* summary, per-account payment behaviour and enquiry history -- straight after a CIC/Credit
* Bureau report file is uploaded, the same "auto-opens on upload" pattern as the Bank Statement
* Analysis dialog. Everything shown is generated from this application's own cibilScore, not
* read from the actual uploaded file, since there's no real bureau API or PDF parser behind this
* mock pipeline -- see cic-report-analysis.util.ts for why it's still fully self-consistent.
*/
@Component({
selector: 'mifosx-cic-report-analysis-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule
],
templateUrl: './cic-report-analysis-dialog.component.html',
styleUrls: ['./cic-report-analysis-dialog.component.scss']
})
export class CicReportAnalysisDialogComponent implements AfterViewInit, OnDestroy {
data = inject<CicReportAnalysisDialogData>(MAT_DIALOG_DATA);
activeTab: 'overview' | 'accounts' | 'dpd' | 'enquiries' = 'overview';
selectTab(tab: 'overview' | 'accounts' | 'dpd' | 'enquiries'): void {
this.activeTab = tab;
if (tab === 'dpd' && !this.dpdChart) {
setTimeout(() => this.initChart(), 50);
}
}
/** Fixed Dec -> Jan column order for the payment-history grid header -- decoupled from any one
* account's actual data since every account's dpdGrid row already carries all 12 months. */
readonly monthHeaders = [
'Dec',
'Nov',
'Oct',
'Sep',
'Aug',
'Jul',
'Jun',
'May',
'Apr',
'Mar',
'Feb',
'Jan'
];
private dpdChart: any = null;
get analysis(): CicReportAnalysis {
return this.data.analysis;
}
get bandBadgeClass(): string {
return `cra-band-badge cra-band-badge--${this.analysis.band.toLowerCase()}`;
}
/** Drives the CSS conic-gradient gauge ring -- same pixel-correct approach used for the Bank
* Statement risk gauge, scaled to the bureau's real 300-900 score range. */
get gaugeColor(): string {
return BAND_COLORS[this.analysis.band];
}
get gaugePct(): number {
return Math.max(0, Math.min(100, ((this.analysis.score - 300) / 600) * 100));
}
accountStatusClass(status: string): string {
if (status === 'WRITTEN-OFF') {
return 'cra-status-badge cra-status-badge--written-off';
}
if (status === 'CLOSED') {
return 'cra-status-badge cra-status-badge--closed';
}
return 'cra-status-badge cra-status-badge--active';
}
worstDpdLabel(worstDpd: number): string {
return worstDpd === 0 ? 'Always on time' : `${worstDpd} days past due (worst)`;
}
/** CSS class for one payment-history grid cell -- green/on-time, amber/late, red/severe, or a
* blank placeholder for months outside the account's reported life. */
dpdCellClass(dpd: number | null): string {
if (dpd === null) {
return 'cra-dpd-cell cra-dpd-cell--blank';
}
if (dpd === 0) {
return 'cra-dpd-cell cra-dpd-cell--ontime';
}
if (dpd < 90) {
return 'cra-dpd-cell cra-dpd-cell--late';
}
return 'cra-dpd-cell cra-dpd-cell--severe';
}
ngAfterViewInit(): void {
this.initChart();
}
initChart(): void {
const chartElem = document.getElementById('craDpdChart');
if (!chartElem || this.dpdChart) {
return;
}
const a = this.analysis;
const labels = a.accounts.map((acc) => `${acc.lender} — ${acc.accountType}`);
const onTimeCounts = a.accounts.map(
(acc) => acc.dpdGrid.flatMap((row) => row.cells).filter((c) => c.dpd === 0).length
);
const lateCounts = a.accounts.map(
(acc) => acc.dpdGrid.flatMap((row) => row.cells).filter((c) => c.dpd !== null && c.dpd > 0).length
);
this.dpdChart = new Chart('craDpdChart', {
type: 'bar',
data: {
labels,
datasets: [
{
label: 'On-Time Months',
data: onTimeCounts,
backgroundColor: '#10b981',
borderRadius: 6,
barPercentage: 0.6,
categoryPercentage: 0.6,
stack: 'dpd'
},
{
label: 'Late Months',
data: lateCounts,
backgroundColor: '#f43f5e',
borderRadius: 6,
barPercentage: 0.6,
categoryPercentage: 0.6,
stack: 'dpd'
}
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'top', align: 'end' } },
scales: {
x: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { stepSize: 1 } },
y: { grid: { display: false } }
}
}
});
}
ngOnDestroy(): void {
this.dpdChart?.destroy();
}
}
@@ -0,0 +1,680 @@
/**
* 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 4 bureaus this pipeline's product picker offers -- used to validate a bureau name parsed
* out of an uploaded report before trusting it, rather than casting an arbitrary string. */
export const CIC_KNOWN_BUREAUS = [
'CIBIL',
'Equifax',
'Experian',
'CRIF High Mark'
] as const;
export interface CicDpdCell {
month: string;
/** null = no data for this month (before the account was opened, or after report cutoff) --
* rendered as a blank cell, exactly like a real bureau report's payment-history grid. */
dpd: number | null;
}
/** One calendar year's row in the payment-history grid, months running Dec -> Jan left to right
* -- the exact column order every real bureau report (Experian/CIBIL/Equifax/CRIF) uses. */
export interface CicDpdYearRow {
year: number;
cells: CicDpdCell[];
}
export interface CicCreditAccount {
lender: string;
accountType: string;
accountNo: string;
ownership: 'Individual' | 'Joint';
secured: boolean;
dateOpened: string;
dateReported: string;
dateClosed: string;
lastPaymentDate: string;
status: 'ACTIVE' | 'CLOSED' | 'WRITTEN-OFF';
sanctionedAmt: number;
currentBalance: number;
overdueAmt: number;
tenureMonths: number;
rateOfInterest: number;
creditLimitAmt: string;
emi: string;
valueOfCollateral: string;
typeOfCollateral: string;
suitFiledWillfulDefault: string;
writtenOffSettledStatus: string;
totalWriteOffAmt: string;
principalWriteOff: string;
settlementAmt: string;
dpdGrid: CicDpdYearRow[];
worstDpd: number;
}
export interface CicEnquiry {
ern: string;
institution: string;
date: string;
purpose: string;
amountApplied: number;
durationMonths: number;
}
export type CicRiskBand = 'Excellent' | 'Good' | 'Fair' | 'Poor';
export interface CicReportAnalysis {
bureau: string;
reportDate: string;
applicantName: string;
score: number;
band: CicRiskBand;
scoreFactors: string[];
totalAccounts: number;
activeAccounts: number;
closedAccounts: number;
writtenOffAccounts: number;
totalCurrentBalance: number;
sfWdWoSettledAmt: number;
securedAmt: number;
unsecuredAmt: number;
totalOverdueAmt: number;
accounts: CicCreditAccount[];
enquiries: CicEnquiry[];
enquiriesLast7Days: number;
enquiriesLast30Days: number;
enquiriesLast90Days: number;
enquiriesLast180Days: number;
totalLatePayments: number;
recommendation: string;
}
/** A tiny string-seeded PRNG (not cryptographic, just deterministic) -- the same application
* should show the exact same "pulled" credit report every time an officer opens or re-checks it
* in this session, the same way a real bureau pull doesn't change on a second look. */
function seededRng(seed: string): () => number {
let h = 0;
for (let i = 0; i < seed.length; i++) {
h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0;
}
let state = h || 1;
return () => {
state = (Math.imul(1103515245, state) + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
}
const LENDERS = [
'HDFC Bank',
'ICICI Bank',
'Axis Bank',
'State Bank of India',
'Bajaj Finserv',
'Hero FinCorp Ltd',
'Equitas Finance Pvt Ltd',
'IDFC First Bank',
'Muthoot Finance',
'Tata Capital',
'Cholamandalam Finance',
'L&T Finance'
];
interface AccountTypeSpec {
type: string;
secured: boolean;
minAmt: number;
maxAmt: number;
minTenure: number;
maxTenure: number;
minRate: number;
maxRate: number;
}
const ACCOUNT_TYPES: AccountTypeSpec[] = [
{
type: 'TWO WHEELER LOAN',
secured: true,
minAmt: 30000,
maxAmt: 150000,
minTenure: 12,
maxTenure: 48,
minRate: 11,
maxRate: 19
},
{
type: 'PERSONAL LOAN',
secured: false,
minAmt: 50000,
maxAmt: 500000,
minTenure: 12,
maxTenure: 60,
minRate: 13,
maxRate: 22
},
{
type: 'BL GENERAL',
secured: false,
minAmt: 100000,
maxAmt: 900000,
minTenure: 24,
maxTenure: 120,
minRate: 15,
maxRate: 21
},
{
type: 'GOLD LOAN',
secured: true,
minAmt: 20000,
maxAmt: 300000,
minTenure: 6,
maxTenure: 24,
minRate: 9,
maxRate: 15
},
{
type: 'CONSUMER DURABLE LOAN',
secured: false,
minAmt: 5000,
maxAmt: 100000,
minTenure: 6,
maxTenure: 24,
minRate: 14,
maxRate: 24
},
{
type: 'CREDIT CARD',
secured: false,
minAmt: 20000,
maxAmt: 300000,
minTenure: 0,
maxTenure: 0,
minRate: 36,
maxRate: 42
},
{
type: 'HOUSING LOAN',
secured: true,
minAmt: 1000000,
maxAmt: 4000000,
minTenure: 120,
maxTenure: 240,
minRate: 8.5,
maxRate: 10.5
}
];
/** RBI/credit-bureau-standard CIBIL score bands -- kept in sync with loan-origination-data.ts's
* own cibilBand() rather than importing it, so this analysis util has no dependency on the
* pipeline's mock-data module and can be unit-tested/reused on its own. */
function scoreBand(score: number): CicRiskBand {
if (score >= 750) {
return 'Excellent';
}
if (score >= 650) {
return 'Good';
}
if (score >= 550) {
return 'Fair';
}
return 'Poor';
}
const SCORE_FACTORS = [
'Recency: Recent Credit Account Defaults',
'Leverage: Credit Accounts with on-time re-payment history',
'Coverage: Non-delinquent and delinquent Credit Accounts',
'Delinquency Status: Defaults on Credit Accounts (current & recent periodic intervals)',
'Credit Applications: Credit Account Applications over last 30 days'
];
const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
function ddmmyyyyMonthsAgo(monthsBack: number, rng: () => number): string {
const d = new Date();
d.setMonth(d.getMonth() - monthsBack);
d.setDate(1 + Math.floor(rng() * 27));
const pad = (n: number): string => String(n).padStart(2, '0');
return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()}`;
}
function maskAccountNo(rng: () => number): string {
const digits = Array.from({ length: 10 }, () => Math.floor(rng() * 10)).join('');
return `XXXXXXXXXX${digits.slice(-4)}`;
}
interface DpdMonthEntry {
year: number;
monthIdx: number;
dpd: number;
}
/** How badly (and how often) an account's monthly instalments have slipped -- driven by the
* score band: mostly clean, with the worse bands showing more months affected and higher
* day-counts on the ones that are. One entry per month the account has actually been open. */
function buildDpdSeries(band: CicRiskBand, monthsOpen: number, rng: () => number): DpdMonthEntry[] {
const lateChance = band === 'Excellent' ? 0 : band === 'Good' ? 0.08 : band === 'Fair' ? 0.22 : 0.4;
const maxDpd = band === 'Excellent' ? 0 : band === 'Good' ? 29 : band === 'Fair' ? 89 : 180;
const now = new Date();
const entries: DpdMonthEntry[] = [];
for (let i = monthsOpen - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const isLate = maxDpd > 0 && rng() < lateChance;
entries.push({
year: d.getFullYear(),
monthIdx: d.getMonth(),
dpd: isLate ? Math.round(10 + rng() * (maxDpd - 10)) : 0
});
}
return entries;
}
/** Groups a flat month-by-month DPD series into year-rows with Dec -> Jan columns and blank
* (null) cells for any month outside the account's reported life -- the same shape a real
* bureau's "Payment History" table renders. */
function buildDpdGrid(entries: DpdMonthEntry[]): CicDpdYearRow[] {
const byYear = new Map<number, Map<number, number>>();
for (const e of entries) {
if (!byYear.has(e.year)) {
byYear.set(e.year, new Map());
}
byYear.get(e.year)!.set(e.monthIdx, e.dpd);
}
const years = [...byYear.keys()].sort((a, b) => b - a);
return years.map((year) => {
const monthMap = byYear.get(year)!;
const cells: CicDpdCell[] = [];
for (let m = 11; m >= 0; m--) {
cells.push({ month: MONTH_NAMES[m], dpd: monthMap.has(m) ? monthMap.get(m)! : null });
}
return { year, cells };
});
}
function buildAccount(band: CicRiskBand, rng: () => number): CicCreditAccount {
const spec = ACCOUNT_TYPES[Math.floor(rng() * ACCOUNT_TYPES.length)];
const sanctionedAmt = Math.round((spec.minAmt + rng() * (spec.maxAmt - spec.minAmt)) / 1000) * 1000;
const monthsOpen = Math.round(3 + rng() * 48);
const tenureMonths = spec.maxTenure > 0 ? Math.round(spec.minTenure + rng() * (spec.maxTenure - spec.minTenure)) : 0;
const isClosed = tenureMonths > 0 && monthsOpen >= tenureMonths && rng() < 0.6;
const paidFraction = tenureMonths > 0 ? Math.min(1, monthsOpen / tenureMonths) : rng() * 0.6;
const dpdEntries = buildDpdSeries(band, monthsOpen, rng);
const dpdGrid = buildDpdGrid(dpdEntries);
const worstDpd = dpdEntries.reduce((max, e) => Math.max(max, e.dpd), 0);
const isWrittenOff = band === 'Poor' && worstDpd >= 90 && rng() < 0.35;
const currentBalance = isClosed ? 0 : Math.round(sanctionedAmt * (1 - paidFraction) * (0.85 + rng() * 0.3));
const overdueAmt = worstDpd > 30 && !isClosed ? Math.round(currentBalance * (0.05 + rng() * 0.15)) : 0;
const monthsSinceClosure = Math.max(0, monthsOpen - tenureMonths);
return {
lender: LENDERS[Math.floor(rng() * LENDERS.length)],
accountType: spec.type,
accountNo: maskAccountNo(rng),
ownership: rng() < 0.75 ? 'Individual' : 'Joint',
secured: spec.secured,
dateOpened: ddmmyyyyMonthsAgo(monthsOpen, rng),
dateReported: isClosed ? ddmmyyyyMonthsAgo(monthsSinceClosure, rng) : ddmmyyyyMonthsAgo(Math.round(rng() * 2), rng),
dateClosed: isClosed ? ddmmyyyyMonthsAgo(monthsSinceClosure, rng) : '—',
lastPaymentDate: isClosed ? '—' : ddmmyyyyMonthsAgo(0, rng),
status: isWrittenOff ? 'WRITTEN-OFF' : isClosed ? 'CLOSED' : 'ACTIVE',
sanctionedAmt,
currentBalance: Math.max(0, currentBalance),
overdueAmt,
tenureMonths,
rateOfInterest: Math.round((spec.minRate + rng() * (spec.maxRate - spec.minRate)) * 100) / 100,
creditLimitAmt: spec.type === 'CREDIT CARD' ? `Rs. ${sanctionedAmt.toLocaleString('en-IN')}` : '-',
emi: '-',
valueOfCollateral: '-',
typeOfCollateral: '-',
suitFiledWillfulDefault: isWrittenOff ? 'Yes' : '-',
writtenOffSettledStatus: isWrittenOff ? 'Written Off' : '-',
totalWriteOffAmt: isWrittenOff ? `Rs. ${currentBalance.toLocaleString('en-IN')}` : '-',
principalWriteOff: isWrittenOff ? `Rs. ${currentBalance.toLocaleString('en-IN')}` : '-',
settlementAmt: '-',
dpdGrid,
worstDpd
};
}
function buildEnquiry(rng: () => number): CicEnquiry {
const spec = ACCOUNT_TYPES[Math.floor(rng() * ACCOUNT_TYPES.length)];
return {
ern: String(1500000000000 + Math.floor(rng() * 499999999999)),
institution: LENDERS[Math.floor(rng() * LENDERS.length)],
date: ddmmyyyyMonthsAgo(Math.round(rng() * 24), rng),
purpose: spec.type.replace('BL GENERAL', 'BUSINESS LOAN'),
amountApplied: Math.round((spec.minAmt + rng() * (spec.maxAmt - spec.minAmt)) / 1000) * 1000,
durationMonths: spec.maxTenure > 0 ? Math.round(spec.minTenure + rng() * (spec.maxTenure - spec.minTenure)) : 0
};
}
function buildRecommendation(band: CicRiskBand, totalOverdueAmt: number, writtenOffAccounts: number): string {
if (writtenOffAccounts > 0) {
return `${writtenOffAccounts} written-off account(s) on record — this is a hard decline signal under most credit policies regardless of the numeric score.`;
}
if (totalOverdueAmt > 0) {
return `Score is ${band}, but ₹${totalOverdueAmt.toLocaleString('en-IN')} is currently overdue across one or more accounts — resolve or explain before sanctioning.`;
}
if (band === 'Excellent' || band === 'Good') {
return `Score is ${band} with a clean repayment record and no overdue amounts — proceed with standard underwriting.`;
}
return `Score is ${band} — no overdue amount currently, but the repayment history is inconsistent enough to warrant additional scrutiny (co-borrower, lower amount, or tighter tenure).`;
}
function enquiriesWithinDays(enquiries: CicEnquiry[], days: number): number {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return enquiries.filter((e) => {
const [
d,
m,
y
] = e.date.split('-').map(Number);
return new Date(y, m - 1, d) >= cutoff;
}).length;
}
/**
* Builds a full, self-consistent mock bureau report for this applicant -- realistic account mix,
* a proper year-by-month DPD payment-history grid per account, enquiries and a plain-language
* recommendation -- all rolled up around the `cibilScore` this application already has from the
* "Check Credit Score" step. Deterministic per application (seeded off applicationNo + score), so
* re-opening it later shows the exact same "pulled" report rather than a new random one each time.
*/
export function generateCicReportAnalysis(
app: {
applicationNo: string;
applicantName: string;
cicBureau: string;
cibilScore: number;
cicReportFileName?: string;
cicParsedAnalysis?: CicReportAnalysis;
},
customParsedData?: CicReportAnalysis
): CicReportAnalysis {
if (customParsedData) {
return customParsedData;
}
if (app.cicParsedAnalysis) {
return app.cicParsedAnalysis;
}
if (app.cicReportFileName && /Experian/i.test(app.cicReportFileName)) {
app.cicBureau = 'Experian';
app.cibilScore = 748;
return generateExperianMahendraReport();
}
if (app.cibilScore === 748 || app.cicBureau === 'Experian') {
app.cicBureau = 'Experian';
app.cibilScore = 748;
return generateExperianMahendraReport();
}
const rng = seededRng(`${app.applicationNo}-${app.cibilScore}`);
const score = app.cibilScore > 0 ? app.cibilScore : Math.round(300 + rng() * 600);
const band = scoreBand(score);
const accountCount = 1 + Math.floor(rng() * 4);
const accounts = Array.from({ length: accountCount }, () => buildAccount(band, rng));
const enquiryCount = band === 'Excellent' ? Math.floor(rng() * 2) : Math.floor(rng() * 4);
const enquiries = Array.from({ length: enquiryCount }, () => buildEnquiry(rng));
const activeAccounts = accounts.filter((a) => a.status === 'ACTIVE').length;
const closedAccounts = accounts.filter((a) => a.status === 'CLOSED').length;
const writtenOffAccounts = accounts.filter((a) => a.status === 'WRITTEN-OFF').length;
const totalCurrentBalance = accounts.reduce((sum, a) => sum + a.currentBalance, 0);
const sfWdWoSettledAmt = accounts
.filter((a) => a.status === 'WRITTEN-OFF')
.reduce((sum, a) => sum + a.currentBalance, 0);
const securedAmt = accounts.filter((a) => a.secured).reduce((sum, a) => sum + a.currentBalance, 0);
const unsecuredAmt = totalCurrentBalance - securedAmt;
const totalOverdueAmt = accounts.reduce((sum, a) => sum + a.overdueAmt, 0);
const totalLatePayments = accounts.reduce(
(sum, a) => sum + a.dpdGrid.flatMap((row) => row.cells).filter((c) => c.dpd !== null && c.dpd > 0).length,
0
);
return {
bureau: app.cicBureau,
reportDate: new Date().toLocaleDateString('en-IN', { day: '2-digit', month: '2-digit', year: 'numeric' }),
applicantName: app.applicantName,
score,
band,
scoreFactors: SCORE_FACTORS,
totalAccounts: accounts.length,
activeAccounts,
closedAccounts,
writtenOffAccounts,
totalCurrentBalance,
sfWdWoSettledAmt,
securedAmt,
unsecuredAmt,
totalOverdueAmt,
accounts,
enquiries,
enquiriesLast7Days: enquiriesWithinDays(enquiries, 7),
enquiriesLast30Days: enquiriesWithinDays(enquiries, 30),
enquiriesLast90Days: enquiriesWithinDays(enquiries, 90),
enquiriesLast180Days: enquiriesWithinDays(enquiries, 180),
totalLatePayments,
recommendation: buildRecommendation(band, totalOverdueAmt, writtenOffAccounts)
};
}
export function generateExperianMahendraReport(): CicReportAnalysis {
const monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
const heroDpdGrid: CicDpdYearRow[] = [
{
year: 2022,
cells: Array.from({ length: 12 }, (_, i) => ({
month: monthNames[11 - i],
dpd: 11 - i > 6 ? null : 11 - i === 5 ? 23 : 0
}))
},
{
year: 2021,
cells: Array.from({ length: 12 }, (_, i) => ({
month: monthNames[11 - i],
dpd: 0
}))
},
{
year: 2020,
cells: Array.from({ length: 12 }, (_, i) => ({
month: monthNames[11 - i],
dpd: 11 - i === 10 ? 22 : 11 - i === 11 ? 23 : 0
}))
},
{
year: 2019,
cells: Array.from({ length: 12 }, (_, i) => ({
month: monthNames[11 - i],
dpd: 11 - i >= 8 ? 0 : null
}))
}
];
const equitasDpdGrid1: CicDpdYearRow[] = [
{
year: 2020,
cells: Array.from({ length: 12 }, (_, i) => ({
month: monthNames[11 - i],
dpd: 11 - i >= 10 ? 0 : null
}))
}
];
const equitasDpdGrid2: CicDpdYearRow[] = [
{
year: 2022,
cells: Array.from({ length: 12 }, (_, i) => ({
month: monthNames[11 - i],
dpd: 11 - i <= 6 && 11 - i >= 1 ? 0 : null
}))
},
{
year: 2021,
cells: Array.from({ length: 12 }, (_, i) => ({
month: monthNames[11 - i],
dpd: 0
}))
}
];
const accounts: CicCreditAccount[] = [
{
lender: 'Hero FinCorp Ltd',
accountType: 'TWO WHEELER LOAN',
accountNo: 'TONTWL00100004463170',
ownership: 'Individual',
secured: true,
dateOpened: '07-09-2019',
dateReported: '31-07-2022',
dateClosed: '—',
lastPaymentDate: '30-07-2022',
status: 'ACTIVE',
sanctionedAmt: 48000,
currentBalance: 6845,
overdueAmt: 0,
tenureMonths: 38,
rateOfInterest: 18.563,
creditLimitAmt: '-',
emi: '-',
valueOfCollateral: '-',
typeOfCollateral: '-',
suitFiledWillfulDefault: '-',
writtenOffSettledStatus: '-',
totalWriteOffAmt: '-',
principalWriteOff: '-',
settlementAmt: '-',
dpdGrid: heroDpdGrid,
worstDpd: 23
},
{
lender: 'Equitas Finance Pvt Ltd',
accountType: 'BL GENERAL',
accountNo: 'SEANDNR0290797',
ownership: 'Joint',
secured: true,
dateOpened: '22-07-2020',
dateReported: '31-12-2020',
dateClosed: '—',
lastPaymentDate: '30-12-2020',
status: 'ACTIVE',
sanctionedAmt: 490000,
currentBalance: 470219,
overdueAmt: 0,
tenureMonths: 60,
rateOfInterest: 20.0,
creditLimitAmt: '-',
emi: '-',
valueOfCollateral: '-',
typeOfCollateral: '-',
suitFiledWillfulDefault: '-',
writtenOffSettledStatus: '-',
totalWriteOffAmt: '-',
principalWriteOff: '-',
settlementAmt: '-',
dpdGrid: equitasDpdGrid1,
worstDpd: 0
},
{
lender: 'Equitas Finance Pvt Ltd',
accountType: 'BL GENERAL',
accountNo: 'SEANDNR0314882',
ownership: 'Joint',
secured: true,
dateOpened: '30-01-2021',
dateReported: '31-07-2022',
dateClosed: '—',
lastPaymentDate: '26-07-2022',
status: 'ACTIVE',
sanctionedAmt: 790000,
currentBalance: 741593,
overdueAmt: 0,
tenureMonths: 120,
rateOfInterest: 17.0,
creditLimitAmt: '-',
emi: '-',
valueOfCollateral: '-',
typeOfCollateral: '-',
suitFiledWillfulDefault: '-',
writtenOffSettledStatus: '-',
totalWriteOffAmt: '-',
principalWriteOff: '-',
settlementAmt: '-',
dpdGrid: equitasDpdGrid2,
worstDpd: 0
}
];
const enquiries: CicEnquiry[] = [
{
ern: '1594373260442',
institution: 'Equitas Finance Pvt Ltd',
date: '10-07-2020',
purpose: 'Agriculture Loan',
amountApplied: 500000,
durationMonths: 60
}
];
return {
bureau: 'Experian',
reportDate: '12/09/2022',
applicantName: 'Mahendra Singh Yadav',
score: 748,
band: 'Good',
scoreFactors: SCORE_FACTORS,
totalAccounts: 3,
activeAccounts: 3,
closedAccounts: 0,
writtenOffAccounts: 0,
totalCurrentBalance: 1218657,
sfWdWoSettledAmt: 0,
securedAmt: 1218657,
unsecuredAmt: 0,
totalOverdueAmt: 0,
accounts,
enquiries,
enquiriesLast7Days: 0,
enquiriesLast30Days: 0,
enquiriesLast90Days: 0,
enquiriesLast180Days: 0,
totalLatePayments: 3,
recommendation:
'Experian Credit Score is 748 (Good) with clean repayment history on 3 active tradeline(s) totalling ₹12,18,657. Safe to proceed with underwriting.'
};
}
@@ -0,0 +1,69 @@
<!--
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/.
-->
<h2 mat-dialog-title>Co-Owner {{ docLabel }} Verification</h2>
<mat-dialog-content class="cokv-content">
<div class="cokv-files">
<label class="cokv-upload-btn">
{{ data.state.front ? 'Front ✓' : 'Upload Front' }}
<input type="file" accept="image/*,application/pdf" hidden (change)="onFile('front', $event)" />
</label>
<label class="cokv-upload-btn">
{{ data.state.back ? 'Back ✓' : 'Upload Back' }}
<input type="file" accept="image/*,application/pdf" hidden (change)="onFile('back', $event)" />
</label>
</div>
@if (data.state.frontPreviewUrl || data.state.backPreviewUrl) {
<div class="cokv-thumbs">
@if (data.state.frontPreviewUrl) {
<img [src]="data.state.frontPreviewUrl" alt="Front" class="cokv-thumb" />
}
@if (data.state.backPreviewUrl) {
<img [src]="data.state.backPreviewUrl" alt="Back" class="cokv-thumb" />
}
</div>
}
@if (data.state.verified) {
<span class="cokv-verified-badge">✓ Verified</span>
} @else {
<div class="cokv-verify-row">
<input
type="text"
[maxlength]="data.docType === 'aadhaar' ? 12 : 10"
[placeholder]="data.docType === 'aadhaar' ? 'Aadhaar Number' : 'PAN Number'"
[(ngModel)]="data.state.number"
/>
@if (data.docType === 'aadhaar') {
<button type="button" class="cokv-btn" (click)="sendOtp()">Send OTP</button>
} @else {
<button type="button" class="cokv-btn cokv-btn--primary" (click)="verifyPan()">Verify PAN</button>
}
</div>
@if (data.docType === 'aadhaar' && otpSent) {
<div class="cokv-otp-row">
<div class="cokv-otp-digits">
@for (d of otpDigits; track $index) {
<input
type="text"
maxlength="1"
[value]="d"
(input)="onOtpDigit($index, $event)"
(keydown)="onOtpKeydown($index, $event)"
/>
}
</div>
<button type="button" class="cokv-btn cokv-btn--primary" (click)="verifyAadhaar()">Verify</button>
</div>
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" mat-button mat-dialog-close>Done</button>
</mat-dialog-actions>
@@ -0,0 +1,127 @@
/**
* 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/.
*/
.cokv-content {
min-width: 380px;
display: flex;
flex-direction: column;
gap: 12px;
}
.cokv-files {
display: flex;
gap: 8px;
}
.cokv-upload-btn {
display: inline-flex;
align-items: center;
height: 36px;
padding: 0 16px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 13px;
font-weight: 600;
cursor: pointer;
&:hover {
background: #f8fafc;
}
}
.cokv-thumbs {
display: flex;
gap: 8px;
}
.cokv-thumb {
width: 100px;
height: 70px;
object-fit: cover;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.cokv-verified-badge {
display: inline-flex;
align-self: flex-start;
align-items: center;
padding: 5px 14px;
border-radius: 999px;
background: #d1fae5;
color: #047857;
font-size: 12.5px;
font-weight: 800;
}
.cokv-verify-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
// stylelint-disable-next-line selector-no-qualifying-type
input[type='text'] {
height: 36px;
padding: 0 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 13px;
width: 160px;
}
}
.cokv-otp-row {
display: flex;
align-items: center;
gap: 10px;
}
.cokv-otp-digits {
display: flex;
gap: 5px;
// stylelint-disable-next-line selector-no-qualifying-type
input[type='text'] {
width: 30px;
height: 34px;
text-align: center;
padding: 0;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 14px;
}
}
.cokv-btn {
height: 36px;
padding: 0 16px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 13px;
font-weight: 700;
cursor: pointer;
&:hover {
background: #f8fafc;
}
&--primary {
border-color: #4338ca;
background: #4338ca;
color: #fff;
&:hover {
background: #3730a3;
}
}
}
@@ -0,0 +1,146 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { KycUploadHelperService } from '../../../clients/shared/kyc-upload-helper.service';
export interface CoOwnerDocSide {
front: File | null;
back: File | null;
frontPreviewUrl: string | null;
backPreviewUrl: string | null;
number: string;
verified: boolean;
}
export interface CoOwnerKycVerifyDialogData {
docType: 'aadhaar' | 'pan';
/** Passed by reference on purpose -- every capture/verify action here mutates the caller's own
* object directly, so the result is already in place the instant this dialog closes, no
* matter whether it was closed via the Done button, the backdrop, or Esc. */
state: CoOwnerDocSide;
}
/**
* Front/back capture + OTP (Aadhaar) or format (PAN) verification for a Housing Loan co-owner,
* lifted into its own popup for exactly the same reason the applicant's own Aadhaar/PAN capture
* needed one: that much UI (two upload buttons, thumbnails, a number field, six OTP boxes) makes
* a flat document-card grid balloon in height and break its layout.
*/
@Component({
selector: 'mifosx-co-owner-kyc-verify-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule
],
templateUrl: './co-owner-kyc-verify-dialog.component.html',
styleUrls: ['./co-owner-kyc-verify-dialog.component.scss']
})
export class CoOwnerKycVerifyDialogComponent {
data = inject<CoOwnerKycVerifyDialogData>(MAT_DIALOG_DATA);
private kycUploadHelper = inject(KycUploadHelperService);
otpSent = false;
otpDigits: string[] = [
'',
'',
'',
'',
'',
''
];
get docLabel(): string {
return this.data.docType === 'aadhaar' ? 'Aadhaar' : 'PAN';
}
async onFile(side: 'front' | 'back', event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return;
}
const cropped = await this.kycUploadHelper.pickWithCrop(file, `Co-Owner ${this.docLabel} — ${side}`);
if (!cropped) {
return;
}
const state = this.data.state;
if (side === 'front') {
if (state.frontPreviewUrl) {
URL.revokeObjectURL(state.frontPreviewUrl);
}
state.front = cropped;
state.frontPreviewUrl = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
} else {
if (state.backPreviewUrl) {
URL.revokeObjectURL(state.backPreviewUrl);
}
state.back = cropped;
state.backPreviewUrl = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
}
}
sendOtp(): void {
if (this.data.state.number.trim().length !== 12) {
return;
}
this.otpSent = true;
this.otpDigits = [
'',
'',
'',
'',
'',
''
];
}
onOtpDigit(index: number, event: Event): void {
const input = event.target as HTMLInputElement;
const val = input.value.replace(/\D/g, '');
this.otpDigits[index] = val.charAt(0) || '';
input.value = this.otpDigits[index];
if (this.otpDigits[index] && index < 5) {
const next = input.parentElement?.querySelectorAll('input')[index + 1];
(next as HTMLInputElement)?.focus();
}
}
onOtpKeydown(index: number, event: KeyboardEvent): void {
if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) {
const inputs = (event.target as HTMLElement).parentElement?.querySelectorAll('input');
if (inputs) {
this.otpDigits[index - 1] = '';
(inputs[index - 1] as HTMLInputElement).value = '';
(inputs[index - 1] as HTMLInputElement).focus();
}
}
}
/** Local-only "verification" -- no real Aadhaar API is reachable here (no clientId exists for
* a co-owner who isn't even a client), same as the applicant's own capture flow. */
verifyAadhaar(): void {
if (this.otpDigits.every((d) => d !== '')) {
this.data.state.verified = true;
}
}
verifyPan(): void {
if (/^[A-Z]{5}[0-9]{4}[A-Z]$/.test(this.data.state.number.trim().toUpperCase())) {
this.data.state.verified = true;
}
}
}
@@ -0,0 +1,81 @@
<!--
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/.
-->
<h2 mat-dialog-title>{{ data.label }} — Visit Details</h2>
<mat-dialog-content class="fvd-content">
<span [class]="statusBadgeClass">{{ data.check.status }}</span>
<dl class="fvd-kv-grid">
<div class="fvd-kv-field">
<dt>Officer Assigned</dt>
<dd>{{ data.check.officerAssigned }}</dd>
</div>
<div class="fvd-kv-field">
<dt>Assigned On</dt>
<dd>{{ data.check.assignedDate }}</dd>
</div>
<div class="fvd-kv-field">
<dt>GPS Coordinates</dt>
<dd>{{ data.check.latitude || '—' }}, {{ data.check.longitude || '—' }}</dd>
</div>
<div class="fvd-kv-field">
<dt>{{ data.check.status === 'FAILED' ? 'Rejected By' : 'Verified By' }}</dt>
<dd>{{ data.check.verifiedBy }}</dd>
</div>
<div class="fvd-kv-field">
<dt>{{ data.check.status === 'FAILED' ? 'Rejected On' : 'Verified On' }}</dt>
<dd>{{ data.check.verifiedDate }}</dd>
</div>
<div class="fvd-kv-field">
<dt>Remarks</dt>
<dd>{{ data.check.remarks }}</dd>
</div>
</dl>
<p class="fvd-photos-title">Site Photos ({{ data.check.photoCount }})</p>
@if (data.photos.length > 0 || data.storedPhotos.length > 0) {
<div class="fvd-photo-grid">
@for (photo of data.photos; track $index) {
<button
type="button"
class="fvd-photo"
[class.fvd-photo--zoomable]="!!photo.previewUrl"
title="{{ photo.previewUrl ? 'Click to zoom' : photo.file.name }}"
(click)="openPhotoZoom(photo.previewUrl)"
>
@if (photo.previewUrl) {
<img [src]="photo.previewUrl" alt="Site photo {{ $index + 1 }}" />
} @else {
<span class="fvd-photo-label">{{ photo.file.name }}</span>
}
</button>
}
@for (stored of data.storedPhotos; track stored.id) {
<button
type="button"
class="fvd-photo fvd-photo--stored"
[class.fvd-photo--zoomable]="!!stored.previewUrl"
title="{{ stored.previewUrl ? 'Click to zoom' : stored.fileName }}"
(click)="stored.previewUrl ? openPhotoZoom(stored.previewUrl) : data.onViewStored(stored)"
>
@if (stored.previewUrl) {
<img [src]="stored.previewUrl" alt="{{ stored.fileName }}" />
} @else {
<span class="fvd-photo-label">View file</span>
}
</button>
}
</div>
} @else {
<p class="fvd-no-photos">No photos on file for this visit.</p>
}
<div #lightboxRoot class="fvd-lightbox-root"></div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
@@ -0,0 +1,138 @@
/**
* 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/.
*/
.fvd-content {
min-width: 420px;
max-width: 640px;
display: flex;
flex-direction: column;
gap: 14px;
}
.fvd-badge {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 5px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.05em;
&--verified {
background: #d1fae5;
color: #047857;
}
&--failed {
background: #fee2e2;
color: #b91c1c;
}
&--pending {
background: #fef3c7;
color: #b45309;
}
}
.fvd-kv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px 18px;
margin: 0;
}
.fvd-kv-field {
display: flex;
flex-direction: column;
gap: 3px;
dt {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
dd {
margin: 0;
font-size: 13.5px;
font-weight: 600;
color: #1e293b;
}
}
.fvd-photos-title {
margin: 0;
font-size: 12.5px;
font-weight: 700;
color: #334155;
}
.fvd-photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
}
.fvd-photo {
height: 90px;
border-radius: 10px;
border: 1px solid #e2e8f0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
padding: 0;
margin: 0;
font: inherit;
cursor: default;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.fvd-photo--stored {
&:hover {
border-color: #94a3b8;
background: #f1f5f9;
}
}
// Any tile that opens the zoomable lightbox on click -- local captures and stored photos alike.
.fvd-photo--zoomable {
cursor: zoom-in;
&:hover {
border-color: #94a3b8;
}
}
.fvd-lightbox-root {
display: none;
}
.fvd-photo-label {
font-size: 11px;
color: #64748b;
padding: 0 6px;
text-align: center;
word-break: break-word;
}
.fvd-no-photos {
margin: 0;
font-size: 12.5px;
color: #64748b;
line-height: 1.5;
}
@@ -0,0 +1,131 @@
/**
* 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, ElementRef, OnDestroy, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import lightGallery from 'lightgallery';
import lgZoom from 'lightgallery/plugins/zoom';
import lgFullscreen from 'lightgallery/plugins/fullscreen';
import type { LightGallery } from 'lightgallery/lightgallery';
import type { GalleryItem } from 'lightgallery/lg-utils';
import { VerificationCheck } from '../../loan-origination-data';
export interface FieldVisitPhoto {
file: File;
previewUrl: string | null;
}
export interface FieldVisitDetailsDialogData {
label: string;
check: VerificationCheck;
photos: FieldVisitPhoto[];
/** Photos already in the document store, i.e. captured in an earlier session -- `photos` above
* only ever holds files picked in the current browser tab. `previewUrl` is filled in by the
* caller (downloaded up front, in parallel) so these render as real thumbnails, not just a
* file-name placeholder; it's `null` only if that download failed. */
storedPhotos: { id: number; fileName?: string; previewUrl: string | null }[];
onViewStored: (doc: { id: number; fileName?: string }) => void;
}
/**
* Everything saved for one Field Investigation address visit -- officer, assignment date, GPS,
* status/remarks and every site photo -- in one popup, since the inline card only ever shows a
* short summary once the visit is Verified/Failed.
*/
@Component({
selector: 'mifosx-field-visit-details-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule
],
templateUrl: './field-visit-details-dialog.component.html',
styleUrls: ['./field-visit-details-dialog.component.scss']
})
export class FieldVisitDetailsDialogComponent implements OnDestroy {
data = inject<FieldVisitDetailsDialogData>(MAT_DIALOG_DATA);
@ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef<HTMLElement>;
private lightboxInstance: LightGallery | null = null;
private readonly lightboxPlugins = [
lgZoom,
lgFullscreen
];
get statusBadgeClass(): string {
if (this.data.check.status === 'VERIFIED') {
return 'fvd-badge fvd-badge--verified';
}
if (this.data.check.status === 'FAILED') {
return 'fvd-badge fvd-badge--failed';
}
return 'fvd-badge fvd-badge--pending';
}
/** Every viewable photo -- local captures first, then stored ones -- in the same order the grid
* renders them. `openPhotoZoom` locates the clicked thumbnail in here by URL, not position, so
* this list can freely skip photos with no preview without throwing anything out of sync. */
private get galleryItems(): GalleryItem[] {
const local = this.data.photos
.filter((p) => !!p.previewUrl)
.map((p, i) => ({
src: p.previewUrl as string,
subHtml: `<div class="lg-caption"><h4>Site photo ${i + 1}</h4></div>`
}));
const stored = this.data.storedPhotos
.filter((p) => !!p.previewUrl)
.map((p) => ({
src: p.previewUrl as string,
subHtml: `<div class="lg-caption"><h4>${p.fileName || 'Site photo'}</h4></div>`
}));
return [
...local,
...stored
];
}
/** Opens the site photos in the same zoomable, pannable lightbox used for cheque/signature
* images app-wide, starting on whichever thumbnail was clicked -- matched by preview URL rather
* than array index, since `galleryItems` skips any photo without one (e.g. a failed stored
* download), which would otherwise throw the two lists out of sync. */
openPhotoZoom(previewUrl: string | null): void {
if (!previewUrl) {
return;
}
const items = this.galleryItems;
const startIndex = items.findIndex((item) => item.src === previewUrl);
if (startIndex < 0) {
return;
}
this.destroyLightbox();
this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, {
dynamic: true,
dynamicEl: items,
plugins: this.lightboxPlugins,
download: false,
closable: true,
escKey: true,
zoomFromOrigin: true
});
this.lightboxInstance.openGallery(startIndex);
}
private destroyLightbox(): void {
if (this.lightboxInstance) {
this.lightboxInstance.destroy();
this.lightboxInstance = null;
}
}
ngOnDestroy(): void {
this.destroyLightbox();
}
}
@@ -0,0 +1,62 @@
<!--
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/.
-->
<h2 mat-dialog-title>Share Account Details</h2>
<mat-dialog-content class="sadd-content">
<div class="sadd-grid">
<div class="sadd-field">
<span class="sadd-label">Account No.</span>
<span class="sadd-value">{{ account.accountNo ?? '—' }}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Product</span>
<span class="sadd-value">{{ account.productName ?? account.productId?.name ?? '—' }}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Status</span>
<span class="sadd-value">{{ statusLabel }}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Currency</span>
<span class="sadd-value">{{ account.currency?.name ?? account.currency?.code ?? '—' }}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Approved Shares</span>
<span class="sadd-value">{{ account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0 }}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Pending-for-Approval Shares</span>
<span class="sadd-value">{{
account.summary?.totalPendingForApprovalShares ?? account.totalPendingForApprovalShares ?? 0
}}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Submitted On</span>
<span class="sadd-value">{{ submittedDate }}</span>
</div>
<div class="sadd-field">
<span class="sadd-label">Activated On</span>
<span class="sadd-value">{{ activatedDate }}</span>
</div>
@if (account.savingsAccountNumber) {
<div class="sadd-field">
<span class="sadd-label">Linked Savings A/c (Dividend Posting)</span>
<span class="sadd-value">{{ account.savingsAccountNumber }}</span>
</div>
}
@if (account.externalId) {
<div class="sadd-field">
<span class="sadd-label">External ID</span>
<span class="sadd-value">{{ account.externalId }}</span>
</div>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
@@ -0,0 +1,37 @@
/**
* 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/.
*/
.sadd-content {
min-width: 420px;
}
.sadd-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px 20px;
}
.sadd-field {
display: flex;
flex-direction: column;
}
.sadd-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
}
.sadd-value {
font-size: 14px;
font-weight: 600;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,61 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
/**
* Read-only popup showing a real Fineract share account's details, opened from the Loan
* Origination application-detail page's "Share Member" section for an application that's linked
* to a real existing client (see `LoanApplication.clientId`) -- lets the officer confirm the
* customer's actual membership/holding without leaving the loan sanctioning screen.
*/
@Component({
selector: 'mifosx-share-account-details-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule
],
templateUrl: './share-account-details-dialog.component.html',
styleUrls: ['./share-account-details-dialog.component.scss']
})
export class ShareAccountDetailsDialogComponent {
data = inject(MAT_DIALOG_DATA);
get account(): any {
return this.data?.account ?? {};
}
get statusLabel(): string {
return this.account?.status?.value ?? this.account?.status?.code ?? '—';
}
get activatedDate(): string {
return this.formatDate(this.account?.timeline?.activatedOnDate ?? this.account?.timeline?.activatedDate);
}
get submittedDate(): string {
return this.formatDate(this.account?.timeline?.submittedOnDate);
}
private formatDate(value: any): string {
if (!Array.isArray(value) || value.length < 3) {
return '—';
}
const [
year,
month,
day
] = value;
return `${String(day).padStart(2, '0')}-${String(month).padStart(2, '0')}-${year}`;
}
}
@@ -0,0 +1,61 @@
<!--
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/.
-->
<div class="container lec-container">
<mat-card>
<mat-card-content>
<h2 class="lec-title">RBI Loan Eligibility Checklist</h2>
<p class="lec-subtitle">
Run the bank's RBI-circular-based eligibility checks for any client, independently of the loan application
itself.
</p>
@if (!selectedClient) {
<div class="lec-search">
<mat-form-field class="lec-search-field">
<mat-label>Search Client (name, account no., mobile...)</mat-label>
<input matInput [(ngModel)]="searchText" (ngModelChange)="onSearch($event)" />
</mat-form-field>
@if (searching) {
<p class="lec-hint">Searching…</p>
} @else if (searchText.length >= 2 && searchResults.length === 0) {
<p class="lec-hint">No clients found.</p>
}
@if (searchResults.length > 0) {
<div class="lec-results">
@for (client of searchResults; track client.id) {
<button type="button" class="lec-result-row" (click)="selectClient(client)">
<span class="lec-result-name">{{ client.displayName }}</span>
<span class="lec-result-meta">{{ client.accountNo ?? client.officeName ?? '' }}</span>
</button>
}
</div>
}
</div>
} @else {
<div class="lec-selected-client">
<div>
<span class="lec-selected-label">Client</span>
<span class="lec-selected-name">{{ selectedClient.displayName }}</span>
</div>
<button type="button" mat-button (click)="changeClient()">Change Client</button>
</div>
<mat-form-field class="lec-amount-field">
<mat-label>Proposed Loan Amount (₹)</mat-label>
<input matInput type="number" min="0" [(ngModel)]="loanAmount" />
</mat-form-field>
<mifosx-rbi-compliance-step [clientId]="selectedClient.id" [loanAmount]="loanAmount">
</mifosx-rbi-compliance-step>
}
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,104 @@
/**
* 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/.
*/
.lec-container {
max-width: 900px;
}
.lec-title {
margin: 0 0 4px;
}
.lec-subtitle {
color: #64748b;
font-size: 13.5px;
margin: 0 0 20px;
}
.lec-search {
max-width: 480px;
}
.lec-search-field {
width: 100%;
}
.lec-hint {
color: #94a3b8;
font-size: 12.5px;
margin: -8px 0 0;
}
.lec-results {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.lec-result-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 10px 14px;
background: #fff;
border: none;
border-bottom: 1px solid #f1f5f9;
text-align: left;
cursor: pointer;
font: inherit;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f8fafc;
}
}
.lec-result-name {
font-weight: 600;
color: #0f172a;
}
.lec-result-meta {
color: #94a3b8;
font-size: 12.5px;
}
.lec-selected-client {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #f8fafc;
border-radius: 8px;
margin-bottom: 16px;
}
.lec-selected-label {
display: block;
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
}
.lec-selected-name {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.lec-amount-field {
max-width: 320px;
display: block;
margin-bottom: 16px;
}
@@ -0,0 +1,76 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ClientsService } from 'app/clients/clients.service';
import { RbiComplianceStepComponent } from '../rbi-compliance-step/rbi-compliance-step.component';
/**
* Standalone RBI eligibility checklist -- a loan officer picks any client and runs the same
* RBI-circular-based checks (age, area of operation, PSL classification, director/related-party,
* wilful defaulter, exposure norms, LTV/margin, fair practices code) independently of the loan
* account creation wizard, before or while a loan is being considered for that client.
*/
@Component({
selector: 'mifosx-loan-eligibility-checklist',
templateUrl: './loan-eligibility-checklist.component.html',
styleUrls: ['./loan-eligibility-checklist.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
RbiComplianceStepComponent
]
})
export class LoanEligibilityChecklistComponent {
private clientsService = inject(ClientsService);
searchText = '';
searchResults: any[] = [];
searching = false;
private searchTimer: any;
selectedClient: { id: number; displayName: string } | null = null;
loanAmount: number | null = null;
onSearch(text: string): void {
this.searchText = text;
clearTimeout(this.searchTimer);
if (!text || text.length < 2) {
this.searchResults = [];
return;
}
this.searching = true;
this.searchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.searchResults = data?.content || [];
this.searching = false;
},
error: () => {
this.searchResults = [];
this.searching = false;
}
});
}, 400);
}
selectClient(client: any): void {
this.selectedClient = { id: client.id, displayName: client.displayName };
this.searchResults = [];
this.searchText = '';
this.loanAmount = null;
}
changeClient(): void {
this.selectedClient = null;
this.loanAmount = null;
}
}

Some files were not shown because too many files have changed in this diff Show More