From 325228c514d02ceea8a0fcc667b3f989f6270f24 Mon Sep 17 00:00:00 2001 From: Aditya Date: Tue, 19 May 2026 10:09:06 +0530 Subject: [PATCH] feat: letter-mgmt module + digital UPI/IMPS/ATM-POS reports + header redesign letter-mgmt (new module): - Notice page: overdue loan list with filters, KPI strip, bulk actions, call disposition logging (Promise-to-Pay, Not Reachable, etc.), Speed Post dispatch dialog with consignment + post-office tracking, PDF preview + download, call-contact dialog with primary / alt / family / nominee numbers and per-contact disposition history. - Certificates: Loan Interest, Share, Balance, Form 60 (no-PAN flow), Form 15G/H (auto age-based), Hypothecation, NOC - each with shared preview dialog (six tone variants) and jsPDF generator using a common letterhead / summary-grid / signature-seal helper. digital: - IMPS module: report (P2A/P2P/Aadhaar tabs) + entry form with MMID/mobile lookup, NPCI switch resolve-to-A/C+IFSC BANL flow, sender autocomplete, hide sender field for P2P/Aadhaar, charges + 18% GST + waive checkbox, per-sender today-only history dialog. - UPI report with app-mix tab and full transaction filtering. - ATM/POS/ECOM hub with three ticket cards routing to today/status/cycle. - NEFT/RTGS entry: sender-account autocomplete, charges + GST + waive, field reorder (sender before amount), NEFT cap / RTGS floor validation, History button opening per-sender today's transactions. - NEFT/RTGS report: horizontal-bar trend leaderboard replacing tall bars. shell + theme: - Header redesigned with IBM Plex Sans 12/500 typography, 11-column grid per row, top-accent hover indicator, bordered cells via grid-gap. - Live KPI ticker below menu (CASH, TXN TODAY, CLEARING IN/OUT, NPA, CASA, CD RATIO, RBI CIRC, REPO) with marquee scroll, pause-on-hover, brand-blue gradient, amber bottom strip, pulsing red LIVE chip. - Sidebar 'KYC Management' -> 'KYC & ReKYC Management'. - Letter Mgmt / certificate menu items wired with green icon hints for items that have a working page. access-control: - MFA card layout compacted; method header inlines back button + title. icons: - Register faTruck/TruckFast, faEllipsisV/H, faCommentDots. - Add IBM Plex Sans Google Fonts import to index.html. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../access-control.component.html | 52 +- .../access-control.component.scss | 242 ++- .../access-control.component.ts | 12 + .../access-control/access-control.service.ts | 77 +- .../mfa-logs-dialog.component.html | 299 ++++ .../mfa-logs-dialog.component.scss | 702 ++++++++ .../mfa-logs-dialog.component.ts | 319 ++++ src/app/app.module.ts | 2 + .../activate-client.component.html | 93 +- .../activate-client.component.scss | 10 +- .../activate-client.component.ts | 4 +- .../add-client-charge.component.html | 249 +-- .../add-client-charge.component.scss | 10 +- .../add-client-charge.component.ts | 4 +- .../add-client-collateral.component.html | 177 +- .../add-client-collateral.component.scss | 10 +- .../add-client-collateral.component.ts | 4 +- .../client-screen-reports.component.html | 105 +- .../client-screen-reports.component.scss | 89 +- .../close-client/close-client.component.html | 125 +- .../close-client/close-client.component.scss | 10 +- .../close-client/close-client.component.ts | 4 +- .../reactivate-client.component.html | 52 +- .../reactivate-client.component.scss | 10 +- .../reactivate-client.component.ts | 4 +- .../reject-client.component.html | 125 +- .../reject-client.component.scss | 10 +- .../reject-client/reject-client.component.ts | 4 +- .../transfer-client.component.html | 139 +- .../transfer-client.component.scss | 10 +- .../transfer-client.component.ts | 4 +- .../undo-client-rejection.component.html | 51 +- .../undo-client-rejection.component.ts | 4 +- ...date-client-savings-account.component.html | 82 +- ...date-client-savings-account.component.scss | 10 +- ...update-client-savings-account.component.ts | 4 +- .../withdraw-client.component.html | 125 +- .../withdraw-client.component.scss | 10 +- .../withdraw-client.component.ts | 4 +- src/app/core/shell/shell.component.html | 123 +- src/app/core/shell/shell.component.scss | 344 +++- src/app/core/shell/shell.component.ts | 3 + .../core/shell/sidenav/sidenav.component.html | 4 +- .../atm-pos-ecom/atm-pos-ecom.component.html | 60 + .../atm-pos-ecom/atm-pos-ecom.component.scss | 336 ++++ .../atm-pos-ecom/atm-pos-ecom.component.ts | 92 + src/app/digital/digital-routing.module.ts | 26 +- src/app/digital/digital.module.ts | 14 +- .../imps-entry/imps-entry.component.html | 504 ++++++ .../imps-entry/imps-entry.component.ts | 691 ++++++++ .../imps-report/imps-report.component.html | 451 +++++ .../imps-report/imps-report.component.ts | 717 ++++++++ .../imps-sender-history-dialog.component.html | 137 ++ .../imps-sender-history-dialog.component.ts | 108 ++ .../neft-rtgs-entry.component.html | 161 +- .../neft-rtgs-entry.component.scss | 267 +++ .../neft-rtgs-entry.component.ts | 208 ++- .../neft-rtgs-report.component.html | 25 +- .../neft-rtgs-report.component.scss | 134 +- .../neft-rtgs-report.component.ts | 585 +++---- .../sender-history-dialog.component.html | 117 ++ .../sender-history-dialog.component.scss | 315 ++++ .../sender-history-dialog.component.ts | 113 ++ .../upi-report/upi-report.component.html | 425 +++++ .../upi-report/upi-report.component.ts | 618 +++++++ .../balance-certificate.component.html | 152 ++ .../balance-certificate.component.ts | 234 +++ .../certificates/certificate-pdf.ts | 1058 ++++++++++++ .../certificate-preview-dialog.component.html | 703 ++++++++ .../certificate-preview-dialog.component.scss | 607 +++++++ .../certificate-preview-dialog.component.ts | 322 ++++ .../certificates/certificates.shared.scss | 470 +++++ .../form-15gh/form-15gh.component.html | 171 ++ .../form-15gh/form-15gh.component.ts | 241 +++ .../form-60/form-60.component.html | 178 ++ .../certificates/form-60/form-60.component.ts | 236 +++ .../hypothecation.component.html | 169 ++ .../hypothecation/hypothecation.component.ts | 242 +++ .../loan-interest.component.html | 155 ++ .../loan-interest/loan-interest.component.ts | 336 ++++ .../certificates/noc/noc.component.html | 179 ++ .../certificates/noc/noc.component.ts | 306 ++++ .../share-certificate.component.html | 164 ++ .../share-certificate.component.ts | 263 +++ .../letter-mgmt/letter-mgmt-routing.module.ts | 71 + src/app/letter-mgmt/letter-mgmt.module.ts | 46 + .../call-contact-dialog.component.html | 209 +++ .../call-contact-dialog.component.scss | 765 +++++++++ .../call-contact-dialog.component.ts | 306 ++++ .../notice-history-dialog.component.html | 148 ++ .../notice-history-dialog.component.scss | 435 +++++ .../notice-history-dialog.component.ts | 133 ++ .../notice-preview-dialog.component.html | 150 ++ .../notice-preview-dialog.component.scss | 501 ++++++ .../notice-preview-dialog.component.ts | 159 ++ .../letter-mgmt/notice/notice.component.html | 371 ++++ .../letter-mgmt/notice/notice.component.scss | 1028 +++++++++++ .../letter-mgmt/notice/notice.component.ts | 1526 +++++++++++++++++ .../speed-post-dialog.component.html | 97 ++ .../speed-post-dialog.component.scss | 282 +++ .../speed-post-dialog.component.ts | 91 + .../current-products.component.html | 184 +- .../current-products.component.scss | 20 +- .../current-products.component.ts | 38 +- ...current-product-general-tab.component.html | 1138 ++++++------ ...current-product-general-tab.component.scss | 17 + .../fixed-deposit-products.component.html | 198 ++- .../fixed-deposit-products.component.scss | 12 +- .../fixed-deposit-products.component.ts | 26 +- .../fixed-deposit-general-tab.component.html | 995 ++++++----- .../fixed-deposit-general-tab.component.scss | 9 + .../loan-products.component.html | 236 ++- .../loan-products.component.scss | 12 +- .../loan-products/loan-products.component.ts | 23 +- .../general-tab/general-tab.component.html | 76 +- .../general-tab/general-tab.component.scss | 10 + .../recurring-deposit-products.component.html | 225 ++- .../recurring-deposit-products.component.scss | 12 +- .../recurring-deposit-products.component.ts | 22 +- ...curring-deposit-general-tab.component.html | 1243 +++++++------- ...curring-deposit-general-tab.component.scss | 9 + .../saving-products.component.html | 188 +- .../saving-products.component.scss | 538 +++++- .../saving-products.component.ts | 33 +- .../saving-product-general-tab.component.html | 1139 ++++++------ .../saving-product-general-tab.component.scss | 557 ++++-- .../saving-product-general-tab.component.ts | 2 - .../share-products.component.html | 190 +- .../share-products.component.scss | 12 +- .../share-products.component.ts | 23 +- .../share-product-general-tab.component.html | 474 ++--- .../share-product-general-tab.component.scss | 9 + src/app/shared/icons.module.ts | 20 +- src/assets/styles/_client-action.scss | 266 +++ src/assets/styles/_product-list.scss | 605 +++++++ src/assets/styles/_product-view.scss | 575 +++++++ src/environments/.env.ts | 4 +- src/index.html | 1 + 138 files changed, 27473 insertions(+), 3997 deletions(-) create mode 100644 src/app/access-control/mfa-logs-dialog/mfa-logs-dialog.component.html create mode 100644 src/app/access-control/mfa-logs-dialog/mfa-logs-dialog.component.scss create mode 100644 src/app/access-control/mfa-logs-dialog/mfa-logs-dialog.component.ts create mode 100644 src/app/digital/atm-pos-ecom/atm-pos-ecom.component.html create mode 100644 src/app/digital/atm-pos-ecom/atm-pos-ecom.component.scss create mode 100644 src/app/digital/atm-pos-ecom/atm-pos-ecom.component.ts create mode 100644 src/app/digital/imps-entry/imps-entry.component.html create mode 100644 src/app/digital/imps-entry/imps-entry.component.ts create mode 100644 src/app/digital/imps-report/imps-report.component.html create mode 100644 src/app/digital/imps-report/imps-report.component.ts create mode 100644 src/app/digital/imps-sender-history-dialog/imps-sender-history-dialog.component.html create mode 100644 src/app/digital/imps-sender-history-dialog/imps-sender-history-dialog.component.ts create mode 100644 src/app/digital/sender-history-dialog/sender-history-dialog.component.html create mode 100644 src/app/digital/sender-history-dialog/sender-history-dialog.component.scss create mode 100644 src/app/digital/sender-history-dialog/sender-history-dialog.component.ts create mode 100644 src/app/digital/upi-report/upi-report.component.html create mode 100644 src/app/digital/upi-report/upi-report.component.ts create mode 100644 src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.html create mode 100644 src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.ts create mode 100644 src/app/letter-mgmt/certificates/certificate-pdf.ts create mode 100644 src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html create mode 100644 src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss create mode 100644 src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.ts create mode 100644 src/app/letter-mgmt/certificates/certificates.shared.scss create mode 100644 src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html create mode 100644 src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts create mode 100644 src/app/letter-mgmt/certificates/form-60/form-60.component.html create mode 100644 src/app/letter-mgmt/certificates/form-60/form-60.component.ts create mode 100644 src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.html create mode 100644 src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.ts create mode 100644 src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.html create mode 100644 src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.ts create mode 100644 src/app/letter-mgmt/certificates/noc/noc.component.html create mode 100644 src/app/letter-mgmt/certificates/noc/noc.component.ts create mode 100644 src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.html create mode 100644 src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts create mode 100644 src/app/letter-mgmt/letter-mgmt-routing.module.ts create mode 100644 src/app/letter-mgmt/letter-mgmt.module.ts create mode 100644 src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.html create mode 100644 src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.scss create mode 100644 src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.ts create mode 100644 src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.html create mode 100644 src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.scss create mode 100644 src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.ts create mode 100644 src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.html create mode 100644 src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.scss create mode 100644 src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.ts create mode 100644 src/app/letter-mgmt/notice/notice.component.html create mode 100644 src/app/letter-mgmt/notice/notice.component.scss create mode 100644 src/app/letter-mgmt/notice/notice.component.ts create mode 100644 src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.html create mode 100644 src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.scss create mode 100644 src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.ts create mode 100644 src/assets/styles/_client-action.scss create mode 100644 src/assets/styles/_product-list.scss create mode 100644 src/assets/styles/_product-view.scss diff --git a/src/app/access-control/access-control.component.html b/src/app/access-control/access-control.component.html index cd4aad543..cf59d9a9d 100644 --- a/src/app/access-control/access-control.component.html +++ b/src/app/access-control/access-control.component.html @@ -6,23 +6,25 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
-
-
-

Access Control · Multi-Factor Authentication

-

- Pick a method to set up. The factor stays disabled until verification or registration completes for the - signed-in user {{ username }}. -

+
+ + @if (!selectedMethod) { +
+
+
+

Access Control · Multi-Factor Authentication

+

+ Pick a method to set up. The factor stays disabled until verification or registration completes for the + signed-in user {{ username }}. +

+
+
+ {{ anyMethodActive ? 'lock' : 'lock_open' }} + {{ anyMethodActive ? 'MFA Active' : 'MFA Off' }} +
-
- {{ anyMethodActive ? 'lock' : 'lock_open' }} - {{ anyMethodActive ? 'MFA Active' : 'MFA Off' }} -
-
+ } @if (!selectedMethod) { @@ -918,4 +920,22 @@ } } + + +
diff --git a/src/app/access-control/access-control.component.scss b/src/app/access-control/access-control.component.scss index c32fb5944..f802456ac 100644 --- a/src/app/access-control/access-control.component.scss +++ b/src/app/access-control/access-control.component.scss @@ -398,9 +398,9 @@ .ac-back { display: inline-flex; align-items: center; - gap: 12px; - padding: 6px 16px 6px 6px; - margin-bottom: 16px; + gap: 10px; + padding: 4px 14px 4px 4px; + margin-bottom: 0; border: 1px solid #e2e8f0; border-radius: 999px; background: #fff; @@ -433,8 +433,8 @@ } &__icon { - width: 32px; - height: 32px; + width: 28px; + height: 28px; border-radius: 999px; background: #eff6ff; color: #1e40af; @@ -447,9 +447,9 @@ color 0.18s; mat-icon { - font-size: 18px; - width: 18px; - height: 18px; + font-size: 16px; + width: 16px; + height: 16px; transition: transform 0.18s; } } @@ -457,12 +457,12 @@ &__text { display: flex; flex-direction: column; - line-height: 1.15; + line-height: 1.1; text-align: left; } &__sub { - font-size: 10px; + font-size: 9px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; @@ -470,13 +470,13 @@ } &__main { - font-size: 13px; + font-size: 12px; font-weight: 700; color: #0f172a; } &--inner { - margin-bottom: 14px; + margin-bottom: 10px; background: #f8fafc; .ac-back__icon { @@ -545,71 +545,99 @@ /* ───────────────────────── Setup panel ───────────────────────── */ .ac-panel { - margin-top: 14px; + margin-top: 10px; border: 1px solid #e2e8f0; border-radius: 16px; background: #fff; - padding: 16px 20px; + padding: 12px 18px 16px; scroll-margin-top: 80px; + /* Place the outer back button and the per-method header on the SAME row, + so the form below appears immediately (no extra scroll). */ + display: grid; + grid-template-columns: auto 1fr; + gap: 0 14px; + align-items: center; + + // stylelint-disable-next-line selector-max-universal + > * { + grid-column: 1 / -1; + min-width: 0; + } + + > .ac-back { + grid-column: 1; + grid-row: 1; + align-self: center; + } + + > .ac-panel__head { + grid-column: 2; + grid-row: 1; + } + &__head { display: flex; align-items: center; - gap: 14px; - padding-bottom: 12px; - border-bottom: 1px solid #f1f5f9; - margin-bottom: 16px; + gap: 12px; + padding-bottom: 0; + border-bottom: 0; + margin: 0 0 6px; } &__icon { - width: 50px; - height: 50px; - border-radius: 14px; + width: 38px; + height: 38px; + border-radius: 11px; display: inline-flex; align-items: center; justify-content: center; mat-icon { - font-size: 24px; - width: 24px; - height: 24px; + font-size: 20px; + width: 20px; + height: 20px; } } &__title { flex: 1; min-width: 0; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; h3 { - margin: 0 0 3px; - font-size: 16px; + margin: 0; + font-size: 15px; font-weight: 700; color: #0f172a; } } &__sub { - margin: 0 0 16px; - font-size: 12.5px; + margin: 0 0 10px; + font-size: 12px; color: #64748b; - line-height: 1.5; + line-height: 1.45; } &__body { display: flex; flex-direction: column; - gap: 14px; + gap: 12px; } } /* ───────────────────────── Progress stepper ───────────────────────── */ .ac-stepper { list-style: none; - margin: 0 0 14px; - padding: 10px 16px; + margin: 0 0 10px; + padding: 8px 14px; display: grid; grid-template-columns: repeat(3, 1fr); - gap: 8px; + gap: 6px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 12px; @@ -618,8 +646,8 @@ position: relative; display: flex; align-items: center; - gap: 10px; - padding: 6px 4px; + gap: 8px; + padding: 4px 2px; &::after { content: ''; @@ -638,15 +666,15 @@ } &__num { - width: 28px; - height: 28px; + width: 24px; + height: 24px; border-radius: 999px; background: #e2e8f0; color: #94a3b8; display: inline-flex; align-items: center; justify-content: center; - font-size: 12px; + font-size: 11px; font-weight: 700; flex-shrink: 0; transition: @@ -1628,4 +1656,142 @@ .ac-hw-row__cta { grid-column: 1 / -1; } + + /* Stack back button above the per-method header on narrow screens. */ + .ac-panel { + grid-template-columns: 1fr; + row-gap: 8px; + } + + .ac-panel > .ac-back, + .ac-panel > .ac-panel__head { + grid-column: 1 / -1; + } + + .ac-panel > .ac-back { + grid-row: 1; + justify-self: start; + } + + .ac-panel > .ac-panel__head { + grid-row: 2; + } +} + +/* ═══════════════════════════════════════════════════ + Floating "Logs" chip — bottom-left, sticky to viewport + ═══════════════════════════════════════════════════ */ +.ac-logs-chip { + position: fixed; + left: 22px; + bottom: 22px; + display: inline-flex; + align-items: center; + gap: 11px; + padding: 10px 16px 10px 12px; + background: linear-gradient(135deg, #0b1e3d 0%, #1d4ed8 100%); + color: #fff; + border: 0; + border-radius: 999px; + box-shadow: + 0 12px 26px rgb(11 30 61 / 35%), + 0 4px 10px rgb(11 30 61 / 22%), + inset 0 1px 0 rgb(255 255 255 / 12%); + cursor: pointer; + font-family: inherit; + z-index: 60; + transition: + transform 0.18s ease, + box-shadow 0.18s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: + 0 16px 32px rgb(11 30 61 / 42%), + 0 6px 14px rgb(11 30 61 / 28%), + inset 0 1px 0 rgb(255 255 255 / 18%); + } + + &:active { + transform: translateY(0); + } + + &:focus-visible { + outline: 2px solid #60a5fa; + outline-offset: 3px; + } +} + +.ac-logs-chip__ic { + width: 32px; + height: 32px; + border-radius: 50%; + background: rgb(255 255 255 / 14%); + border: 1px solid rgb(255 255 255 / 22%); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 13px; + flex-shrink: 0; +} + +.ac-logs-chip__label { + display: flex; + flex-direction: column; + line-height: 1.15; + text-align: left; +} + +.ac-logs-chip__title { + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.ac-logs-chip__sub { + font-size: 10px; + font-weight: 500; + color: rgb(255 255 255 / 70%); + margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +/* Narrow screens — collapse to an icon-only round button */ +@media (width <= 600px) { + .ac-logs-chip { + padding: 10px; + border-radius: 50%; + } + + .ac-logs-chip__label { + display: none; + } + + .ac-logs-chip__ic { + width: 28px; + height: 28px; + } +} + +/* ───── Dialog panel override — kill default Material padding ───── */ +:host ::ng-deep .ml-dialog-panel { + .mat-mdc-dialog-surface { + padding: 0 !important; + border-radius: 14px !important; + overflow: hidden !important; + box-shadow: + 0 24px 48px rgb(15 23 42 / 28%), + 0 8px 16px rgb(15 23 42 / 18%) !important; + } + + .mat-mdc-dialog-container .mdc-dialog__surface { + padding: 0 !important; + } + + .mat-mdc-dialog-content { + padding: 0 !important; + margin: 0 !important; + max-height: none !important; + } } diff --git a/src/app/access-control/access-control.component.ts b/src/app/access-control/access-control.component.ts index c1b0138c6..8ec69174d 100644 --- a/src/app/access-control/access-control.component.ts +++ b/src/app/access-control/access-control.component.ts @@ -26,6 +26,7 @@ import { EnrollDscDialogComponent } from 'app/users/dsc/enroll-dsc-dialog/enroll import { RevokeDscDialogComponent } from 'app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component'; import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component'; import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component'; +import { MfaLogsDialogComponent } from './mfa-logs-dialog/mfa-logs-dialog.component'; type HwSubKind = 'smart-card' | 'digital-signature' | 'biometric'; type MethodKey = 'sms' | 'email' | 'totp' | 'hardware'; @@ -938,6 +939,17 @@ export class AccessControlComponent implements OnInit, OnDestroy { this.dialog.open(ErrorDialogComponent, { data: message }); } + /** Floating Logs chip → open the tabbed audit-logs dialog. */ + openLogsDialog(): void { + this.dialog.open(MfaLogsDialogComponent, { + width: '880px', + maxWidth: '95vw', + maxHeight: '90vh', + autoFocus: false, + panelClass: 'ml-dialog-panel' + }); + } + /** * Reconcile the DSC tile with the server: if /v1/dsc/me returns at least one ACTIVE * DSC, the tile is considered "registered". Called from ngOnInit alongside the diff --git a/src/app/access-control/access-control.service.ts b/src/app/access-control/access-control.service.ts index 197c3eb09..deeae5609 100644 --- a/src/app/access-control/access-control.service.ts +++ b/src/app/access-control/access-control.service.ts @@ -7,7 +7,7 @@ */ import { Injectable, inject } from '@angular/core'; -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; export interface OtpGenerateResponse { @@ -46,6 +46,57 @@ export interface TotpVerifyResponse { message?: string; } +/** Which logical view the audit row belongs to. */ +export type MfaLogTab = 'enrollment' | 'login'; + +/** Factor types reported by the audit feed (matches MfaFactorType minus TOKEN). */ +export type MfaLogFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'DSC'; + +/** Status values — see API doc. Enrolment tab: ACTIVE/DISABLED; Login tab: the rest. */ +export type MfaLogStatus = 'ACTIVE' | 'DISABLED' | 'PENDING' | 'SENT' | 'VERIFIED' | 'FAILED' | 'EXPIRED' | 'LOCKED'; + +/** Unified audit-log row returned by GET /v1/mfa/logs. */ +export interface MfaLogEntry { + tab: 'ENROLLMENT' | 'LOGIN'; + id: number; + source: 'USER_MFA_FACTOR' | 'OTP_LOG' | 'DSC_CHALLENGE'; + userId: number; + username: string; + userFullName: string; + factorType: MfaLogFactorType; + destination: string; + status: MfaLogStatus; + /** Local datetime — when the event happened. */ + eventOn: string; + /** UTC offset datetime — when the verification succeeded (null if not verified). */ + verifiedOn?: string | null; + /** Login tab only. */ + attempts?: number | null; + ipAddress?: string | null; + userAgent?: string | null; + message: string; + provider: string; +} + +/** Paged response wrapper. */ +export interface MfaLogPage { + totalFilteredRecords: number; + pageItems: MfaLogEntry[]; +} + +/** Optional filters — same shape for both tabs. */ +export interface MfaLogFilters { + factorType?: MfaLogFactorType; + status?: MfaLogStatus; + userId?: number; + username?: string; + search?: string; + /** yyyy-MM-dd, treated as start-of-day. */ + dateFrom?: string; + /** yyyy-MM-dd, treated as end-of-day (inclusive). */ + dateTo?: string; +} + export interface MfaFactor { id?: number; factorType: MfaFactorType; @@ -135,4 +186,28 @@ export class AccessControlService { verifyTotp(code: string): Observable { return this.http.post('/v1/mfa/totp/verify', { code }, { headers: this.silentHeaders }); } + + // ───── MFA Logs (enrolment + login audit) ───── + /** + * Unified audit feed for MFA events. + * `tab=enrollment` returns one row per successfully verified factor + * (USER_MFA_FACTOR snapshot). `tab=login` returns every 2FA verification + * attempt at sign-in (OTP_LOG + DSC_CHALLENGE combined). + * + * Filters and pagination are applied server-side per the API spec. + */ + getMfaLogs(tab: MfaLogTab, page = 0, pageSize = 25, filters: MfaLogFilters = {}): Observable { + let params = new HttpParams().set('tab', tab).set('page', String(page)).set('pageSize', String(pageSize)); + Object.entries(filters).forEach( + ([ + k, + v + ]) => { + if (v !== undefined && v !== null && v !== '') { + params = params.set(k, String(v)); + } + } + ); + return this.http.get('/v1/mfa/logs', { params, headers: this.silentHeaders }); + } } diff --git a/src/app/access-control/mfa-logs-dialog/mfa-logs-dialog.component.html b/src/app/access-control/mfa-logs-dialog/mfa-logs-dialog.component.html new file mode 100644 index 000000000..5281e43d9 --- /dev/null +++ b/src/app/access-control/mfa-logs-dialog/mfa-logs-dialog.component.html @@ -0,0 +1,299 @@ + + +
+ +
+
+ +
+
+

MFA Activity Logs

+

Enrolled factors and 2FA / MFA sign-in attempts

+
+ +
+ + +
+ + +
+ + +
+ + + + + + +
+ + +
+ + @if (hasActiveFilters(activeTab)) { + + } + + +
+ + +
+ + @if (current.state === 'loading') { +
+ @for (i of skeletonRows(); track i) { +
+ + + + + +
+ } +
+ } + + + @if (current.state === 'empty') { +
+
+ +
+ @if (activeTab === 'enrollment') { +

No enrolled factors

+

+ Activity here will populate as factors are verified or removed. Try clearing filters if you're looking for + an older record. +

+ } @else { +

No sign-in attempts logged

+

+ 2FA / MFA challenges raised at sign-in will appear here — successful verifications, failed codes, expired + sessions, and account locks. +

+ } + @if (hasActiveFilters(activeTab)) { + + } @else { + + } +
+ } + + + @if (current.state === 'error') { +
+ +
+ Couldn't load logs +

{{ current.error }}

+
+ +
+ } + + + @if (current.state === 'loaded' && activeTab === 'enrollment') { +
+ + + + + + + + + + + + + + @for (row of enrollment.rows; track $index) { + + + + + + + + + + } + +
Event timeUserFactorDestinationStatusVerified onMessage
{{ formatTs(row.eventOn) }} +
{{ row.userFullName || row.username }}
+
{{ row.username }}
+
+ + + {{ factorLabel(row.factorType) }} + + {{ row.destination || '—' }} + {{ row.status }} + {{ formatTs(row.verifiedOn) }}{{ row.message || '—' }}
+
+ } + + + @if (current.state === 'loaded' && activeTab === 'login') { +
+ + + + + + + + + + + + + + + @for (row of login.rows; track $index) { + + + + + + + + + + + } + +
Event timeUserFactorDestinationStatusAttemptsIP / DeviceMessage
{{ formatTs(row.eventOn) }} +
{{ row.userFullName || row.username }}
+
{{ row.username }}
+
+ + + {{ factorLabel(row.factorType) }} + + {{ row.destination || '—' }} + {{ row.status }} + {{ row.attempts ?? '—' }} + {{ row.ipAddress || '—' }} + @if (row.userAgent) { +
{{ row.userAgent }}
+ } +
{{ row.message || '—' }}
+
+ } +
+ + + @if (current.state === 'loaded' || (current.state === 'empty' && current.total > 0)) { +
+
{{ currentRangeLabel }}
+
+ + + + Page {{ current.page + 1 }} of {{ currentTotalPages }} + + +
+
+ } +
diff --git a/src/app/access-control/mfa-logs-dialog/mfa-logs-dialog.component.scss b/src/app/access-control/mfa-logs-dialog/mfa-logs-dialog.component.scss new file mode 100644 index 000000000..579ff1686 --- /dev/null +++ b/src/app/access-control/mfa-logs-dialog/mfa-logs-dialog.component.scss @@ -0,0 +1,702 @@ +/** + * 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/. + */ + +:host { + --ml-navy: #0b1e3d; + --ml-blue: #1d4ed8; + --ml-blue-l: #eff6ff; + --ml-green: #15803d; + --ml-green-l: #dcfce7; + --ml-amber: #b45309; + --ml-amber-l: #fef3c7; + --ml-red: #c0392b; + --ml-red-l: #fee2e2; + --ml-gray-l: #f1f5f9; + --ml-gray: #64748b; + --ml-g200: #e2e8f0; + --ml-g500: #64748b; + --ml-g700: #334155; + --ml-g900: #0f172a; + + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: var(--ml-g900); + font-size: 13px; +} + +.ml-dialog { + display: flex; + flex-direction: column; + width: 100%; + max-height: 82vh; + background: #fff; +} + +/* ───────── HEADER ───────── */ +.ml-header { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + background: linear-gradient(135deg, #0b1e3d 0%, #1d4ed8 100%); + color: #fff; + position: sticky; + top: 0; + z-index: 2; +} + +.ml-header-ic { + width: 38px; + height: 38px; + border-radius: 10px; + background: rgb(255 255 255 / 14%); + border: 1px solid rgb(255 255 255 / 22%); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 17px; + flex-shrink: 0; +} + +.ml-header-text { + flex: 1; + + h2 { + margin: 0; + font-size: 15px; + font-weight: 700; + letter-spacing: 0.01em; + } + + p { + margin: 2px 0 0; + font-size: 11.5px; + color: rgb(255 255 255 / 80%); + } +} + +.ml-close { + width: 30px; + height: 30px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgb(255 255 255 / 10%); + color: #fff; + border: 1px solid rgb(255 255 255 / 18%); + font-size: 12px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: rgb(255 255 255 / 22%); + border-color: rgb(255 255 255 / 40%); + } +} + +/* ───────── TABS ───────── */ +.ml-tabs { + display: flex; + gap: 4px; + padding: 10px 14px 0; + background: #fff; + border-bottom: 1px solid var(--ml-g200); +} + +.ml-tab { + display: inline-flex; + align-items: center; + gap: 8px; + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + padding: 10px 14px; + font-size: 12.5px; + font-weight: 600; + color: var(--ml-g500); + cursor: pointer; + transition: all 0.15s ease; + font-family: inherit; + margin-bottom: -1px; + + fa-icon { + font-size: 12px; + } + + &:hover { + color: var(--ml-g700); + } + + &.active { + color: var(--ml-blue); + border-bottom-color: var(--ml-blue); + } +} + +.ml-tab-count { + font-size: 10px; + font-weight: 700; + padding: 2px 7px; + border-radius: 999px; + background: var(--ml-blue-l); + color: var(--ml-blue); +} + +.ml-tab.active .ml-tab-count { + background: var(--ml-blue); + color: #fff; +} + +/* ───────── BODY ───────── */ +.ml-body { + flex: 1; + overflow-y: auto; + background: var(--ml-gray-l); + padding: 14px; +} + +/* ───────── TABLE ───────── */ +.ml-table-wrap { + background: #fff; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--ml-g200); + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); +} + +.ml-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + thead th { + background: #f8fafc; + color: var(--ml-g700); + text-align: left; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 9px 12px; + border-bottom: 1px solid var(--ml-g200); + white-space: nowrap; + } + + tbody td { + padding: 9px 12px; + border-bottom: 1px solid #f1f5f9; + vertical-align: top; + } + + tbody tr:hover { + background: #f8fafc; + } + + tbody tr:last-child td { + border-bottom: 0; + } +} + +.mono { + font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; + font-size: 12px; +} + +.small { + font-size: 11.5px; +} + +.muted { + color: var(--ml-g500); +} + +/* Factor chip in the cell */ +.ml-factor-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--ml-g700); + + fa-icon { + font-size: 11px; + color: var(--ml-blue); + } +} + +/* Numeric cell alignment */ +.ml-table .num { + text-align: right; +} + +/* Pills */ +.ml-pill { + display: inline-flex; + align-items: center; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 3px 9px; + border-radius: 999px; + + &--blue { + background: #eff6ff; + color: #1d4ed8; + border: 1px solid #dbeafe; + } + + &--darkred { + background: #450a0a; + color: #fecaca; + border: 1px solid #7f1d1d; + } + + &--green { + background: var(--ml-green-l); + color: var(--ml-green); + border: 1px solid #bbf7d0; + } + + &--red { + background: var(--ml-red-l); + color: var(--ml-red); + border: 1px solid #fecaca; + } + + &--amber { + background: var(--ml-amber-l); + color: var(--ml-amber); + border: 1px solid #fde68a; + } + + &--gray { + background: #f1f5f9; + color: var(--ml-g700); + border: 1px solid #e2e8f0; + } +} + +/* ───────── SKELETON ───────── */ +.ml-skeleton { + background: #fff; + border-radius: 10px; + border: 1px solid var(--ml-g200); + padding: 12px; + overflow: hidden; +} + +.ml-skel-row { + display: flex; + align-items: center; + gap: 14px; + padding: 10px 4px; + border-bottom: 1px dashed #eef2f7; + + &:last-child { + border-bottom: 0; + } +} + +.ml-skel { + height: 10px; + border-radius: 5px; + background: linear-gradient(90deg, #eef2f7 0%, #f8fafc 50%, #eef2f7 100%); + background-size: 200% 100%; + animation: ml-shimmer 1.4s ease-in-out infinite; + + &--xs { + width: 12%; + } + + &--sm { + width: 16%; + } + + &--md { + width: 22%; + } + + &--lg { + width: 32%; + } +} + +@keyframes ml-shimmer { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } +} + +/* ───────── EMPTY ───────── */ +.ml-empty { + background: #fff; + border: 1px dashed #cbd5e1; + border-radius: 12px; + padding: 36px 24px; + text-align: center; +} + +.ml-empty-ic { + width: 56px; + height: 56px; + border-radius: 14px; + background: var(--ml-blue-l); + color: var(--ml-blue); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 22px; + margin-bottom: 12px; +} + +.ml-empty h3 { + margin: 0 0 6px; + font-size: 15px; + font-weight: 700; + color: var(--ml-g900); +} + +.ml-empty p { + margin: 0 0 18px; + font-size: 12.5px; + color: var(--ml-g500); + line-height: 1.5; + max-width: 460px; + margin-left: auto; + margin-right: auto; +} + +.ml-empty-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 16px; + border-radius: 999px; + background: var(--ml-blue-l); + color: var(--ml-blue); + border: 1px solid #dbeafe; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: var(--ml-blue); + color: #fff; + border-color: var(--ml-blue); + } +} + +/* ───────── ERROR ───────── */ +.ml-error { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border-radius: 10px; + background: var(--ml-red-l); + border: 1px solid #fecaca; + color: var(--ml-g900); + + > fa-icon { + color: var(--ml-red); + font-size: 16px; + margin-top: 2px; + } + + > div { + flex: 1; + + strong { + font-size: 13px; + color: var(--ml-red); + } + + p { + margin: 4px 0 0; + font-size: 12px; + color: var(--ml-g700); + } + } +} + +.ml-error-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: 999px; + background: #fff; + color: var(--ml-red); + border: 1px solid #fecaca; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; + + &:hover { + background: var(--ml-red); + color: #fff; + border-color: var(--ml-red); + } +} + +/* ───────── FILTER BAR ───────── */ +.ml-filters { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 10px 14px; + background: #fff; + border-bottom: 1px solid var(--ml-g200); +} + +.ml-filter-search { + position: relative; + flex: 1; + min-width: 220px; + + input { + width: 100%; + box-sizing: border-box; + padding: 7px 12px 7px 30px; + border: 1px solid var(--ml-g200); + border-radius: 8px; + background: #f8fafc; + font-size: 12.5px; + color: var(--ml-g900); + font-family: inherit; + + &::placeholder { + color: #94a3b8; + } + + &:focus { + outline: none; + border-color: var(--ml-blue); + background: #fff; + box-shadow: 0 0 0 3px rgb(29 78 216 / 14%); + } + } +} + +.ml-filter-search-ic { + position: absolute; + top: 50%; + left: 10px; + transform: translateY(-50%); + font-size: 12px; + color: #94a3b8; + pointer-events: none; +} + +.ml-filter-select { + height: 32px; + padding: 0 10px; + border: 1px solid var(--ml-g200); + border-radius: 8px; + background: #fff; + color: var(--ml-g900); + font-size: 12.5px; + font-family: inherit; + cursor: pointer; + + &:focus { + outline: none; + border-color: var(--ml-blue); + box-shadow: 0 0 0 3px rgb(29 78 216 / 14%); + } +} + +.ml-filter-dates { + display: inline-flex; + align-items: center; + gap: 6px; + + label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ml-g500); + } + + // stylelint-disable-next-line selector-no-qualifying-type + input[type='date'] { + height: 32px; + padding: 0 8px; + border: 1px solid var(--ml-g200); + border-radius: 8px; + background: #fff; + color: var(--ml-g900); + font-size: 12px; + font-family: inherit; + + &:focus { + outline: none; + border-color: var(--ml-blue); + box-shadow: 0 0 0 3px rgb(29 78 216 / 14%); + } + } +} + +.ml-filter-clear { + display: inline-flex; + align-items: center; + gap: 5px; + background: var(--ml-red-l); + color: var(--ml-red); + border: 1px solid #fecaca; + border-radius: 999px; + padding: 4px 11px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + + fa-icon { + font-size: 10px; + } + + &:hover { + background: var(--ml-red); + color: #fff; + border-color: var(--ml-red); + } +} + +.ml-filter-refresh { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid var(--ml-g200); + background: #fff; + color: var(--ml-g700); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: all 0.15s ease; + + &:hover { + background: var(--ml-blue-l); + color: var(--ml-blue); + border-color: #dbeafe; + } +} + +/* ───────── PAGINATION FOOTER ───────── */ +.ml-pager { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: #f8fafc; + border-top: 1px solid var(--ml-g200); + font-size: 12px; + flex-wrap: wrap; + gap: 10px; +} + +.ml-pager-info { + color: var(--ml-g500); + font-weight: 600; +} + +.ml-pager-ctrls { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.ml-pager-pagesize { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--ml-g500); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + + select { + height: 28px; + padding: 0 8px; + border: 1px solid var(--ml-g200); + border-radius: 6px; + background: #fff; + font-size: 12px; + font-family: inherit; + color: var(--ml-g900); + cursor: pointer; + + &:focus { + outline: none; + border-color: var(--ml-blue); + } + } +} + +.ml-pager-btn { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + padding: 0 10px; + border-radius: 6px; + background: #fff; + color: var(--ml-g700); + border: 1px solid var(--ml-g200); + font-size: 12px; + font-weight: 600; + cursor: pointer; + font-family: inherit; + transition: all 0.15s ease; + + fa-icon { + font-size: 10px; + } + + &:hover:not(:disabled) { + background: var(--ml-blue-l); + color: var(--ml-blue); + border-color: #dbeafe; + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +} + +.ml-pager-page { + font-size: 12px; + color: var(--ml-g700); + + strong { + color: var(--ml-g900); + } +} diff --git a/src/app/access-control/mfa-logs-dialog/mfa-logs-dialog.component.ts b/src/app/access-control/mfa-logs-dialog/mfa-logs-dialog.component.ts new file mode 100644 index 000000000..42816abfb --- /dev/null +++ b/src/app/access-control/mfa-logs-dialog/mfa-logs-dialog.component.ts @@ -0,0 +1,319 @@ +/** + * 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 { ChangeDetectorRef, Component, inject, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatDialogRef } from '@angular/material/dialog'; +import { MatTabsModule } from '@angular/material/tabs'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Subject, debounceTime } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { + AccessControlService, + MfaLogEntry, + MfaLogFactorType, + MfaLogFilters, + MfaLogStatus, + MfaLogTab +} from '../access-control.service'; + +type LoadState = 'loading' | 'empty' | 'loaded' | 'error'; + +interface TabState { + state: LoadState; + rows: MfaLogEntry[]; + total: number; + page: number; + pageSize: number; + error: string; + filters: MfaLogFilters; + /** Local copy of search input so we can debounce typing. */ + searchInput: string; +} + +const ENROLLMENT_STATUSES: MfaLogStatus[] = [ + 'ACTIVE', + 'DISABLED' +]; +const LOGIN_STATUSES: MfaLogStatus[] = [ + 'PENDING', + 'SENT', + 'VERIFIED', + 'FAILED', + 'EXPIRED', + 'LOCKED' +]; +const FACTOR_TYPES: MfaLogFactorType[] = [ + 'SMS', + 'EMAIL', + 'TOTP', + 'DSC' +]; + +@Component({ + selector: 'mifosx-mfa-logs-dialog', + templateUrl: './mfa-logs-dialog.component.html', + styleUrls: ['./mfa-logs-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + MatTabsModule, + FaIconComponent + ] +}) +export class MfaLogsDialogComponent implements OnInit, OnDestroy { + private service = inject(AccessControlService); + private dialogRef = inject(MatDialogRef); + private cdr = inject(ChangeDetectorRef); + + /** Active tab id. */ + activeTab: MfaLogTab = 'enrollment'; + + /** Dropdown option lists. */ + readonly factorTypes = FACTOR_TYPES; + readonly enrollmentStatuses = ENROLLMENT_STATUSES; + readonly loginStatuses = LOGIN_STATUSES; + + /** Per-tab state — each tab owns its own filters, page and rows. */ + enrollment: TabState = this.makeTabState(); + login: TabState = this.makeTabState(); + + /** Debounce search input so we don't fire a request per keystroke. */ + private search$ = new Subject<{ tab: MfaLogTab; value: string }>(); + + ngOnInit(): void { + this.search$.pipe(debounceTime(350)).subscribe(({ tab, value }) => { + const state = this.stateFor(tab); + state.filters.search = value || undefined; + state.page = 0; + this.load(tab); + }); + this.load('enrollment'); + this.load('login'); + } + + ngOnDestroy(): void { + this.search$.complete(); + } + + // ─────────────── Tab switching ─────────────── + setTab(tab: MfaLogTab): void { + this.activeTab = tab; + } + + /** Convenience accessor — current tab's state object. */ + get current(): TabState { + return this.stateFor(this.activeTab); + } + + stateFor(tab: MfaLogTab): TabState { + return tab === 'enrollment' ? this.enrollment : this.login; + } + + // ─────────────── Loading ─────────────── + load(tab: MfaLogTab): void { + const state = this.stateFor(tab); + state.state = 'loading'; + state.error = ''; + this.service.getMfaLogs(tab, state.page, state.pageSize, state.filters).subscribe({ + next: (res) => { + // Reassign to fresh array refs so Angular's CD reliably picks up the change + // even when the dialog is mid-animation or another async load is in flight. + state.rows = [ + ...(res?.pageItems || []) + ]; + state.total = res?.totalFilteredRecords ?? 0; + state.state = state.rows.length === 0 ? 'empty' : 'loaded'; + this.cdr.markForCheck(); + this.cdr.detectChanges(); + }, + error: (err) => { + if (err?.status === 404) { + state.rows = []; + state.total = 0; + state.state = 'empty'; + this.cdr.detectChanges(); + return; + } + state.error = + err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not load MFA logs.'; + state.state = 'error'; + this.cdr.detectChanges(); + } + }); + } + + refresh(tab: MfaLogTab): void { + this.load(tab); + } + + // ─────────────── Filters ─────────────── + /** Drop the prior search debounce and fire immediately. */ + onSearchInput(tab: MfaLogTab, value: string): void { + const state = this.stateFor(tab); + state.searchInput = value; + this.search$.next({ tab, value: value.trim() }); + } + + setFilter(tab: MfaLogTab, key: K, value: MfaLogFilters[K] | ''): void { + const state = this.stateFor(tab); + if (value === '' || value === null || value === undefined) { + delete state.filters[key]; + } else { + state.filters[key] = value as MfaLogFilters[K]; + } + state.page = 0; + this.load(tab); + } + + clearFilters(tab: MfaLogTab): void { + const state = this.stateFor(tab); + state.filters = {}; + state.searchInput = ''; + state.page = 0; + this.load(tab); + } + + hasActiveFilters(tab: MfaLogTab): boolean { + const state = this.stateFor(tab); + return Object.keys(state.filters).length > 0 || !!state.searchInput; + } + + // ─────────────── Pagination ─────────────── + get currentTotalPages(): number { + const s = this.current; + return Math.max(1, Math.ceil(s.total / s.pageSize)); + } + + nextPage(): void { + const s = this.current; + if (s.page + 1 >= this.currentTotalPages) return; + s.page++; + this.load(this.activeTab); + } + + prevPage(): void { + const s = this.current; + if (s.page <= 0) return; + s.page--; + this.load(this.activeTab); + } + + changePageSize(size: number): void { + const s = this.current; + s.pageSize = size; + s.page = 0; + this.load(this.activeTab); + } + + get currentRangeLabel(): string { + const s = this.current; + if (s.total === 0) return '0 of 0'; + const start = s.page * s.pageSize + 1; + const end = Math.min(s.total, start + s.rows.length - 1); + return `${start}–${end} of ${s.total}`; + } + + close(): void { + this.dialogRef.close(); + } + + // ─────────────── Display helpers ─────────────── + formatTs(ts?: string | null): string { + if (!ts) return '—'; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ts; + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + d.getFullYear() + + '-' + + pad(d.getMonth() + 1) + + '-' + + pad(d.getDate()) + + ' ' + + pad(d.getHours()) + + ':' + + pad(d.getMinutes()) + + ':' + + pad(d.getSeconds()) + ); + } + + factorLabel(f?: string): string { + switch ((f || '').toUpperCase()) { + case 'SMS': + return 'SMS OTP'; + case 'EMAIL': + return 'Email OTP'; + case 'TOTP': + return 'Authenticator'; + case 'DSC': + return 'DSC'; + default: + return f || ''; + } + } + + /** Matches the 2FA picker icon mapping in two-factor-authentication. */ + factorIcon(f?: string): string { + switch ((f || '').toUpperCase()) { + case 'SMS': + return 'mobile-alt'; + case 'EMAIL': + return 'envelope'; + case 'TOTP': + return 'qrcode'; + case 'DSC': + return 'signature'; + default: + return 'shield-alt'; + } + } + + /** Status pill colour per the API spec's suggested mapping. */ + statusColor(status?: string): string { + switch ((status || '').toUpperCase()) { + case 'ACTIVE': + case 'VERIFIED': + return 'green'; + case 'DISABLED': + return 'gray'; + case 'PENDING': + case 'SENT': + return 'blue'; + case 'FAILED': + return 'red'; + case 'EXPIRED': + return 'amber'; + case 'LOCKED': + return 'darkred'; + default: + return 'gray'; + } + } + + /** Skeleton rows for the loading shimmer (count, not data). */ + skeletonRows(n = 6): number[] { + return Array.from({ length: n }, (_, i) => i); + } + + // ─────────────── Internal ─────────────── + private makeTabState(): TabState { + return { + state: 'loading', + rows: [], + total: 0, + page: 0, + pageSize: 25, + error: '', + filters: {}, + searchInput: '' + }; + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index c3e26d686..056c7f946 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -62,6 +62,7 @@ import { DicgcModule } from './dicgc/dicgc.module'; import { FiuModule } from './fiu/fiu.module'; import { SftModule } from './sft/sft.module'; import { DigitalModule } from './digital/digital.module'; +import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module'; import { PortalModule } from '@angular/cdk/portal'; /** Main Routing Module */ @@ -160,6 +161,7 @@ export function HttpLoaderFactory(http: HttpClient) { FiuModule, SftModule, DigitalModule, + LetterMgmtModule, AppRoutingModule, NotFoundComponent, CallbackComponent, diff --git a/src/app/clients/clients-view/client-actions/activate-client/activate-client.component.html b/src/app/clients/clients-view/client-actions/activate-client/activate-client.component.html index 67133eaf7..d7d1ed013 100644 --- a/src/app/clients/clients-view/client-actions/activate-client/activate-client.component.html +++ b/src/app/clients/clients-view/client-actions/activate-client/activate-client.component.html @@ -6,41 +6,62 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
- -
- - {{ 'labels.inputs.Activated On Date' | translate }} - - - - @if (activateClientForm.controls.activationDate.hasError('required')) { - - {{ 'labels.inputs.Activated On Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - -
-
+
+ +
+
+
+
Client Action
+

{{ 'labels.heading.Activate Client' | translate }}

+

+ Activating moves this client from Pending to Active. Once active, the client + can hold accounts, take loans and transact normally. +

+
+
- - - - - - +
+
+
+ + {{ 'labels.inputs.Activated On Date' | translate }} + + + + @if (activateClientForm.controls.activationDate.hasError('required')) { + + {{ 'labels.inputs.Activated On Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
+ +
+ + + The activation date must be on or before today's business date. After confirmation, account opening and + onboarding workflows are unlocked for this client. + +
+
+ +
+ + +
+
diff --git a/src/app/clients/clients-view/client-actions/activate-client/activate-client.component.scss b/src/app/clients/clients-view/client-actions/activate-client/activate-client.component.scss index ea8dfbfca..13ae67803 100644 --- a/src/app/clients/clients-view/client-actions/activate-client/activate-client.component.scss +++ b/src/app/clients/clients-view/client-actions/activate-client/activate-client.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.container { - max-width: 30rem; +@use 'assets/styles/client-action' as ca; + +@include ca.client-action-styles; + +/* Activate-specific green accent (success / go) */ +:host { + --ca-accent: #15803d; + --ca-accent-l: #dcfce7; } diff --git a/src/app/clients/clients-view/client-actions/activate-client/activate-client.component.ts b/src/app/clients/clients-view/client-actions/activate-client/activate-client.component.ts index eb60dd5ff..aacca21c1 100644 --- a/src/app/clients/clients-view/client-actions/activate-client/activate-client.component.ts +++ b/src/app/clients/clients-view/client-actions/activate-client/activate-client.component.ts @@ -15,6 +15,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; import { SettingsService } from 'app/settings/settings.service'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; templateUrl: './activate-client.component.html', styleUrls: ['./activate-client.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent ] }) export class ActivateClientComponent implements OnInit { diff --git a/src/app/clients/clients-view/client-actions/add-client-charge/add-client-charge.component.html b/src/app/clients/clients-view/client-actions/add-client-charge/add-client-charge.component.html index e06f834de..c958b2c73 100644 --- a/src/app/clients/clients-view/client-actions/add-client-charge/add-client-charge.component.html +++ b/src/app/clients/clients-view/client-actions/add-client-charge/add-client-charge.component.html @@ -6,121 +6,150 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
- -
- - {{ 'labels.inputs.Charge' | translate }} - - @for (clientCharge of clientChargeOptions; track clientCharge) { - - {{ clientCharge.name + ' (' + clientCharge.currency.name + ')' }} - - } - - @if (clientChargeForm.controls.chargeId.hasError('required')) { +
+
+
+
+
Client Action
+

{{ 'labels.buttons.Add Charge' | translate }}

+

+ Attach a one-time fee or a recurring charge to this client. The charge can be linked to a specific account or + held at the client level for ad-hoc collection. +

+
+
+ + +
+
+ + {{ 'labels.inputs.Charge' | translate }} + + @for (clientCharge of clientChargeOptions; track clientCharge) { + {{ + clientCharge.name + ' (' + clientCharge.currency.name + ')' + }} + } + + @if (clientChargeForm.controls.chargeId.hasError('required')) { + + {{ 'labels.inputs.Charge' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
+ + @if (chargeDetails) { +
+ + {{ 'labels.inputs.Amount' | translate }} + + @if (clientChargeForm.controls.amount.hasError('required')) { - {{ 'labels.inputs.Charge' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.inputs.Amount' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} } - - @if (chargeDetails) { -
- - {{ 'labels.inputs.Amount' | translate }} - - @if (clientChargeForm.controls.amount.hasError('required')) { - - {{ 'labels.inputs.Amount' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - {{ 'labels.inputs.Charge Calculation' | translate }} - - @for (chargeCalculation of chargeDetails.chargeCalculationTypeOptions; track chargeCalculation) { - - {{ chargeCalculation.value }} - - } - - - - {{ 'labels.inputs.Charge time type' | translate }} - - @for (chargeTime of chargeDetails.chargeTimeTypeOptions; track chargeTime) { - - {{ chargeTime.value }} - - } - - - @if (clientChargeForm.contains('dueDate')) { - - {{ 'labels.inputs.Due for collection on' | translate }} - - - - @if (clientChargeForm.controls.dueDate.hasError('required')) { - - {{ 'labels.inputs.Due for collection on' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - } - @if (clientChargeForm.contains('feeOnMonthDay')) { - - {{ 'labels.inputs.Due On' | translate }} - - - - @if (clientChargeForm.controls.feeOnMonthDay.hasError('required')) { - - {{ 'labels.inputs.Due Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - } - @if (clientChargeForm.contains('feeInterval')) { - - {{ 'labels.inputs.Repeats Every' | translate }} - - - } -
- }
- - - - - - - +
+ + {{ 'labels.inputs.Charge Calculation' | translate }} + + @for (chargeCalculation of chargeDetails.chargeCalculationTypeOptions; track chargeCalculation) { + {{ chargeCalculation.value }} + } + + +
+ +
+ + {{ 'labels.inputs.Charge time type' | translate }} + + @for (chargeTime of chargeDetails.chargeTimeTypeOptions; track chargeTime) { + {{ chargeTime.value }} + } + + +
+ + @if (clientChargeForm.contains('dueDate')) { +
+ + {{ 'labels.inputs.Due for collection on' | translate }} + + + + @if (clientChargeForm.controls.dueDate.hasError('required')) { + + {{ 'labels.inputs.Due for collection on' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
+ } + + @if (clientChargeForm.contains('feeOnMonthDay')) { +
+ + {{ 'labels.inputs.Due On' | translate }} + + + + @if (clientChargeForm.controls.feeOnMonthDay.hasError('required')) { + + {{ 'labels.inputs.Due Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
+ } + + @if (clientChargeForm.contains('feeInterval')) { +
+ + {{ 'labels.inputs.Repeats Every' | translate }} + + +
+ } + +
+ + + The calculation type and timing determine when this charge is auto-applied (e.g. monthly fee, annual + membership, ad-hoc). Verify the amount and due date before submitting. + +
+ } +
+ +
+ + +
+
diff --git a/src/app/clients/clients-view/client-actions/add-client-charge/add-client-charge.component.scss b/src/app/clients/clients-view/client-actions/add-client-charge/add-client-charge.component.scss index e1ea46f9f..5107fdbda 100644 --- a/src/app/clients/clients-view/client-actions/add-client-charge/add-client-charge.component.scss +++ b/src/app/clients/clients-view/client-actions/add-client-charge/add-client-charge.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.container { - width: 37rem; +@use 'assets/styles/client-action' as ca; + +@include ca.client-action-styles; + +/* Add Charge — amber accent (financial / fee) */ +:host { + --ca-accent: #b45309; + --ca-accent-l: #fef3c7; } diff --git a/src/app/clients/clients-view/client-actions/add-client-charge/add-client-charge.component.ts b/src/app/clients/clients-view/client-actions/add-client-charge/add-client-charge.component.ts index a4d061db2..f44aed924 100644 --- a/src/app/clients/clients-view/client-actions/add-client-charge/add-client-charge.component.ts +++ b/src/app/clients/clients-view/client-actions/add-client-charge/add-client-charge.component.ts @@ -21,6 +21,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router'; import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; import { SettingsService } from 'app/settings/settings.service'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -31,7 +32,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; templateUrl: './add-client-charge.component.html', styleUrls: ['./add-client-charge.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent ] }) export class AddClientChargeComponent implements OnInit { diff --git a/src/app/clients/clients-view/client-actions/add-client-collateral/add-client-collateral.component.html b/src/app/clients/clients-view/client-actions/add-client-collateral/add-client-collateral.component.html index a80faa597..3b9553218 100644 --- a/src/app/clients/clients-view/client-actions/add-client-collateral/add-client-collateral.component.html +++ b/src/app/clients/clients-view/client-actions/add-client-collateral/add-client-collateral.component.html @@ -6,81 +6,118 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
- -
- - {{ 'labels.inputs.Collateral' | translate }} - - @for (clientCollateral of clientCollateralOptions; track clientCollateral) { - - {{ clientCollateral.name }} - - } - - @if (clientCollateralForm.controls.collateralId.hasError('required')) { +
+
+
+
+
Client Action
+

{{ 'labels.buttons.Create Collateral' | translate }}

+

+ Record an asset pledged by this client as security for credit (e.g. property, vehicle, deposit). Collateral + values feed loan eligibility and LTV calculations. +

+
+
+ + +
+
+ + {{ 'labels.inputs.Collateral' | translate }} + + @for (clientCollateral of clientCollateralOptions; track clientCollateral) { + {{ clientCollateral.name }} + } + + @if (clientCollateralForm.controls.collateralId.hasError('required')) { + + {{ 'labels.inputs.Collateral' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
+ + @if (collateralDetails) { +
+ + {{ 'labels.inputs.name' | translate }} + + +
+ +
+ + {{ 'labels.inputs.Type/Quality' | translate }} + + +
+ +
+ + {{ 'labels.inputs.Unit Type' | translate }} + + +
+ +
+ + {{ 'labels.inputs.Base Price' | translate }} + + +
+ +
+ + {{ 'labels.inputs.Percentage To Base' | translate }} + + +
+ +
+ + {{ 'labels.inputs.Quantity' | translate }} + + @if (clientCollateralForm.controls.quantity.hasError('required')) { - {{ 'labels.inputs.Collateral' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.inputs.Quantity' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} } - - @if (collateralDetails) { -
- - {{ 'labels.inputs.name' | translate }} - - - - {{ 'labels.inputs.Type/Quality' | translate }} - - - - {{ 'labels.inputs.Unit Type' | translate }} - - - - {{ 'labels.inputs.Base Price' | translate }} - - - - {{ 'labels.inputs.Percentage To Base' | translate }} - - - - {{ 'labels.inputs.Quantity' | translate }} - - @if (clientCollateralForm.controls.quantity.hasError('required')) { - - {{ 'labels.inputs.Quantity' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - {{ 'labels.inputs.Total' | translate }} - - - - {{ 'labels.inputs.Total Collateral Value' | translate }} - - -
- }
- - - - - - - +
+ + {{ 'labels.inputs.Total' | translate }} + + +
+ +
+ + {{ 'labels.inputs.Total Collateral Value' | translate }} + + +
+ +
+ + + Total Collateral Value = Base Price × Percentage-to-Base × Quantity. Verify the figures before submitting — + this is used by underwriting and LTV checks. + +
+ } +
+ +
+ + +
+
diff --git a/src/app/clients/clients-view/client-actions/add-client-collateral/add-client-collateral.component.scss b/src/app/clients/clients-view/client-actions/add-client-collateral/add-client-collateral.component.scss index e1ea46f9f..d83fb051e 100644 --- a/src/app/clients/clients-view/client-actions/add-client-collateral/add-client-collateral.component.scss +++ b/src/app/clients/clients-view/client-actions/add-client-collateral/add-client-collateral.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.container { - width: 37rem; +@use 'assets/styles/client-action' as ca; + +@include ca.client-action-styles; + +/* Create Collateral — purple accent (asset / security) */ +:host { + --ca-accent: #6d28d9; + --ca-accent-l: #ede9fe; } diff --git a/src/app/clients/clients-view/client-actions/add-client-collateral/add-client-collateral.component.ts b/src/app/clients/clients-view/client-actions/add-client-collateral/add-client-collateral.component.ts index 95dd59b31..6dae75c96 100644 --- a/src/app/clients/clients-view/client-actions/add-client-collateral/add-client-collateral.component.ts +++ b/src/app/clients/clients-view/client-actions/add-client-collateral/add-client-collateral.component.ts @@ -17,6 +17,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ClientsService } from 'app/clients/clients.service'; import { ProductsService } from 'app/products/products.service'; import { SettingsService } from 'app/settings/settings.service'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @Component({ @@ -24,7 +25,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; templateUrl: './add-client-collateral.component.html', styleUrls: ['./add-client-collateral.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent ] }) export class AddClientCollateralComponent implements OnInit { diff --git a/src/app/clients/clients-view/client-actions/client-screen-reports/client-screen-reports.component.html b/src/app/clients/clients-view/client-actions/client-screen-reports/client-screen-reports.component.html index 9c64a1b0b..9b81ac9c8 100644 --- a/src/app/clients/clients-view/client-actions/client-screen-reports/client-screen-reports.component.html +++ b/src/app/clients/clients-view/client-actions/client-screen-reports/client-screen-reports.component.html @@ -6,53 +6,72 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
- -
- - {{ 'labels.inputs.Client Screen Reports' | translate }} - - @for (template of templatesData; track template) { - - {{ template.name }} - - } - - @if (clientScreenReportForm.controls.templateId.hasError('required')) { - - {{ 'labels.inputs.Screen Report' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - +
+
+
+
+
Client Action
+

{{ 'labels.buttons.Client Screen Reports' | translate }}

+

+ Generate an on-screen client report from one of the configured templates. The rendered output is printable and + can be saved as PDF from the browser print dialog. +

+
+
+ + +
+
+ + {{ 'labels.inputs.Client Screen Reports' | translate }} + + @for (template of templatesData; track template) { + {{ template.name }} } - -
- + + @if (clientScreenReportForm.controls.templateId.hasError('required')) { + + {{ 'labels.inputs.Screen Report' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
+
- - - - - - -
- -
- -
- +
+ -
-

{{ 'labels.text.Please select a screen report.' | translate }}

+
+
+
+
+
Report Output
+
+ {{ template ? template.name : ('labels.text.Please select a screen report.' | translate) }} +
+
+ +
+ +
+ @if (!template) { +
+
+

{{ 'labels.text.Please select a screen report.' | translate }}

+
+ } +
- +
diff --git a/src/app/clients/clients-view/client-actions/client-screen-reports/client-screen-reports.component.scss b/src/app/clients/clients-view/client-actions/client-screen-reports/client-screen-reports.component.scss index 0cdc4b641..13e769a9b 100644 --- a/src/app/clients/clients-view/client-actions/client-screen-reports/client-screen-reports.component.scss +++ b/src/app/clients/clients-view/client-actions/client-screen-reports/client-screen-reports.component.scss @@ -6,32 +6,79 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.container { - max-width: 37rem; +@use 'assets/styles/client-action' as ca; - .print { - align-self: flex-end; - } +@include ca.client-action-styles; + +/* Screen Reports — indigo accent (analytics / reporting) */ +:host { + --ca-accent: #3730a3; + --ca-accent-l: #e0e7ff; } -::ng-deep mat-card { - margin-bottom: 1rem; - padding: 1rem; - box-shadow: 0 2px 4px rgb(0 0 0 / 10%); - border: 1px solid var(--border-color, #ddd); - border-radius: 8px; - background-color: var(--card-background, #fff); - transition: - background-color 0.3s ease, - border-color 0.3s ease; +/* Wider container for the report output */ +.ca-root--wide { + max-width: 1100px; } -::ng-deep .container.m-t-20 mat-card p { - display: block; - margin-top: -1.9rem; +/* Report output header inside the second card */ +.csr-output-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding-bottom: 14px; + margin-bottom: 14px; + border-bottom: 1px dashed var(--ca-g200); + flex-wrap: wrap; } -.actions-spacing { - margin-top: 1rem; - padding: 0 1rem; +.csr-output-eyebrow { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--ca-g500); + margin-bottom: 2px; +} + +.csr-output-title { + font-size: 15px; + font-weight: 700; + color: var(--ca-g900); +} + +.csr-output-body { + background: var(--ca-g50); + border: 1px solid var(--ca-g200); + border-radius: 10px; + padding: 18px; + min-height: 140px; + font-size: 13px; + color: var(--ca-g900); + overflow-x: auto; +} + +.csr-empty { + text-align: center; + padding: 28px 16px; +} + +.csr-empty-ic { + width: 52px; + height: 52px; + margin: 0 auto 10px; + border-radius: 14px; + background: var(--ca-accent-l); + color: var(--ca-accent); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 22px; +} + +.csr-empty p { + margin: 0; + font-size: 13px; + color: var(--ca-g500); } diff --git a/src/app/clients/clients-view/client-actions/close-client/close-client.component.html b/src/app/clients/clients-view/client-actions/close-client/close-client.component.html index 6c449a6a6..c67e2d58a 100644 --- a/src/app/clients/clients-view/client-actions/close-client/close-client.component.html +++ b/src/app/clients/clients-view/client-actions/close-client/close-client.component.html @@ -6,58 +6,79 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
- -
- - {{ 'labels.inputs.Closed On Date' | translate }} - - - - @if (closeClientForm.controls.closureDate.hasError('required')) { - - {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - +
+ +
+
+
+
Client Action
+

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

+

+ Closing the client moves them to a Closed state. Outstanding loans or active savings must be + settled or transferred before closure is permitted. +

+
+
- - {{ 'labels.inputs.Closure Reason' | translate }} - - @for (reason of closureData; track reason) { - - {{ reason.name }} - - } - - @if (closeClientForm.controls.closureReasonId.hasError('required')) { - - {{ 'labels.inputs.Closure Reason' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - -
- + +
+
+ + {{ 'labels.inputs.Closed On Date' | translate }} + + + + @if (closeClientForm.controls.closureDate.hasError('required')) { + + {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
- - - - - - +
+ + {{ 'labels.inputs.Closure Reason' | translate }} + + @for (reason of closureData; track reason) { + {{ reason.name }} + } + + @if (closeClientForm.controls.closureReasonId.hasError('required')) { + + {{ 'labels.inputs.Closure Reason' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
+ +
+ + + This action is irreversible. Once closed, the client cannot transact or open new accounts. + Use Reactivate only if the closure was made in error. + +
+
+ +
+ + +
+
diff --git a/src/app/clients/clients-view/client-actions/close-client/close-client.component.scss b/src/app/clients/clients-view/client-actions/close-client/close-client.component.scss index 7c3f1555b..a143b3fc7 100644 --- a/src/app/clients/clients-view/client-actions/close-client/close-client.component.scss +++ b/src/app/clients/clients-view/client-actions/close-client/close-client.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.container { - max-width: 37rem; +@use 'assets/styles/client-action' as ca; + +@include ca.client-action-styles; + +/* Close-specific red accent (destructive) */ +:host { + --ca-accent: #c0392b; + --ca-accent-l: #fee2e2; } diff --git a/src/app/clients/clients-view/client-actions/close-client/close-client.component.ts b/src/app/clients/clients-view/client-actions/close-client/close-client.component.ts index a493f595c..659bfc807 100644 --- a/src/app/clients/clients-view/client-actions/close-client/close-client.component.ts +++ b/src/app/clients/clients-view/client-actions/close-client/close-client.component.ts @@ -15,6 +15,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; import { SettingsService } from 'app/settings/settings.service'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; templateUrl: './close-client.component.html', styleUrls: ['./close-client.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent ] }) export class CloseClientComponent implements OnInit { diff --git a/src/app/clients/clients-view/client-actions/reactivate-client/reactivate-client.component.html b/src/app/clients/clients-view/client-actions/reactivate-client/reactivate-client.component.html index 5e12396e7..11ccc49b2 100644 --- a/src/app/clients/clients-view/client-actions/reactivate-client/reactivate-client.component.html +++ b/src/app/clients/clients-view/client-actions/reactivate-client/reactivate-client.component.html @@ -6,11 +6,23 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
- - +
+
+
+
+
Client Action
+

{{ 'labels.heading.Reactivate Client' | translate }}

+

+ Bring a previously Closed client back to Active. After reactivation the client + may transact and open accounts again. +

+
+
+ + +
+
+ {{ 'labels.inputs.Re Activation On Date' | translate }} } - +
- - - - - - +
+ + + Reactivation re-opens the client record. Closed loans and savings stay closed; new accounts can now be + created. + +
+
+ +
+ + +
+
diff --git a/src/app/clients/clients-view/client-actions/reactivate-client/reactivate-client.component.scss b/src/app/clients/clients-view/client-actions/reactivate-client/reactivate-client.component.scss index 7c3f1555b..bb207868c 100644 --- a/src/app/clients/clients-view/client-actions/reactivate-client/reactivate-client.component.scss +++ b/src/app/clients/clients-view/client-actions/reactivate-client/reactivate-client.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.container { - max-width: 37rem; +@use 'assets/styles/client-action' as ca; + +@include ca.client-action-styles; + +/* Reactivate-specific green accent */ +:host { + --ca-accent: #15803d; + --ca-accent-l: #dcfce7; } diff --git a/src/app/clients/clients-view/client-actions/reactivate-client/reactivate-client.component.ts b/src/app/clients/clients-view/client-actions/reactivate-client/reactivate-client.component.ts index 6209449d9..6283b15aa 100644 --- a/src/app/clients/clients-view/client-actions/reactivate-client/reactivate-client.component.ts +++ b/src/app/clients/clients-view/client-actions/reactivate-client/reactivate-client.component.ts @@ -15,6 +15,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; import { SettingsService } from 'app/settings/settings.service'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; templateUrl: './reactivate-client.component.html', styleUrls: ['./reactivate-client.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent ] }) export class ReactivateClientComponent implements OnInit { diff --git a/src/app/clients/clients-view/client-actions/reject-client/reject-client.component.html b/src/app/clients/clients-view/client-actions/reject-client/reject-client.component.html index 60bed7bfc..24de6e059 100644 --- a/src/app/clients/clients-view/client-actions/reject-client/reject-client.component.html +++ b/src/app/clients/clients-view/client-actions/reject-client/reject-client.component.html @@ -6,58 +6,79 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
- -
- - {{ 'labels.inputs.Rejection Date' | translate }} - - - - @if (rejectClientForm.controls.rejectionDate.hasError('required')) { - - {{ 'labels.inputs.Rejection Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - +
+ +
+
+
+
Client Action
+

{{ 'labels.heading.Reject Client' | translate }}

+

+ Reject this client application when onboarding cannot be approved (e.g. failed KYC, regulatory hit, document + mismatch). The reason is recorded for audit and compliance. +

+
+
- - {{ 'labels.inputs.Rejection Reason' | translate }} - - @for (reason of rejectionData; track reason) { - - {{ reason.name }} - - } - - @if (rejectClientForm.controls.rejectionReasonId.hasError('required')) { - - {{ 'labels.inputs.Rejection Reason' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - -
- + +
+
+ + {{ 'labels.inputs.Rejection Date' | translate }} + + + + @if (rejectClientForm.controls.rejectionDate.hasError('required')) { + + {{ 'labels.inputs.Rejection Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
- - - - - - +
+ + {{ 'labels.inputs.Rejection Reason' | translate }} + + @for (reason of rejectionData; track reason) { + {{ reason.name }} + } + + @if (rejectClientForm.controls.rejectionReasonId.hasError('required')) { + + {{ 'labels.inputs.Rejection Reason' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
+ +
+ + + Rejection moves the client to a Rejected state and is final. Use Undo Rejection if + the decision was made in error. + +
+
+ +
+ + +
+
diff --git a/src/app/clients/clients-view/client-actions/reject-client/reject-client.component.scss b/src/app/clients/clients-view/client-actions/reject-client/reject-client.component.scss index 7c3f1555b..4398a5cf3 100644 --- a/src/app/clients/clients-view/client-actions/reject-client/reject-client.component.scss +++ b/src/app/clients/clients-view/client-actions/reject-client/reject-client.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.container { - max-width: 37rem; +@use 'assets/styles/client-action' as ca; + +@include ca.client-action-styles; + +/* Reject-specific red accent (final / blocked) */ +:host { + --ca-accent: #c0392b; + --ca-accent-l: #fee2e2; } diff --git a/src/app/clients/clients-view/client-actions/reject-client/reject-client.component.ts b/src/app/clients/clients-view/client-actions/reject-client/reject-client.component.ts index e6bd6c034..daaa47827 100644 --- a/src/app/clients/clients-view/client-actions/reject-client/reject-client.component.ts +++ b/src/app/clients/clients-view/client-actions/reject-client/reject-client.component.ts @@ -15,6 +15,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; import { SettingsService } from 'app/settings/settings.service'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; templateUrl: './reject-client.component.html', styleUrls: ['./reject-client.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent ] }) export class RejectClientComponent implements OnInit { diff --git a/src/app/clients/clients-view/client-actions/transfer-client/transfer-client.component.html b/src/app/clients/clients-view/client-actions/transfer-client/transfer-client.component.html index 468dc228a..ef64ae4a9 100644 --- a/src/app/clients/clients-view/client-actions/transfer-client/transfer-client.component.html +++ b/src/app/clients/clients-view/client-actions/transfer-client/transfer-client.component.html @@ -6,63 +6,92 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
- -
- - {{ 'labels.inputs.Office' | translate }} - - @for (office of officeData; track office) { - - {{ office.name }} - - } - - @if (transferClientForm.controls.destinationOfficeId.hasError('required')) { - - {{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - +
+ +
+
+
+
Client Action
+

{{ 'labels.heading.Transfer Client' | translate }}

+

+ Move this client to a different office or branch. The destination office will need to + accept the proposed transfer before it takes effect. The client's accounts move with them. +

+
+
+ + +
+
+ + {{ 'labels.inputs.Office' | translate }} + + @for (office of officeData; track office) { + {{ office.name }} } - + + @if (transferClientForm.controls.destinationOfficeId.hasError('required')) { + + {{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
- - {{ 'labels.inputs.Transfer Date' | translate }} - - - - @if (transferClientForm.controls.transferDate.hasError('required')) { - - {{ 'labels.inputs.Transfer Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - +
+ + {{ 'labels.inputs.Transfer Date' | translate }} + + + + @if (transferClientForm.controls.transferDate.hasError('required')) { + + {{ 'labels.inputs.Transfer Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
- - {{ 'labels.inputs.Note' | translate }} - - -
- +
+ + {{ 'labels.inputs.Note' | translate }} + + +
- - - - - - +
+ + + The transfer is created as proposed until the destination office accepts it. Until then it + can be withdrawn from the client's Actions → Undo Transfer menu. + +
+
+ +
+ + +
+
diff --git a/src/app/clients/clients-view/client-actions/transfer-client/transfer-client.component.scss b/src/app/clients/clients-view/client-actions/transfer-client/transfer-client.component.scss index 7c3f1555b..bae33f38c 100644 --- a/src/app/clients/clients-view/client-actions/transfer-client/transfer-client.component.scss +++ b/src/app/clients/clients-view/client-actions/transfer-client/transfer-client.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.container { - max-width: 37rem; +@use 'assets/styles/client-action' as ca; + +@include ca.client-action-styles; + +/* Transfer-specific blue accent (informational / routing) */ +:host { + --ca-accent: #1d4ed8; + --ca-accent-l: #eff6ff; } diff --git a/src/app/clients/clients-view/client-actions/transfer-client/transfer-client.component.ts b/src/app/clients/clients-view/client-actions/transfer-client/transfer-client.component.ts index 72e756aa9..41ef25c7b 100644 --- a/src/app/clients/clients-view/client-actions/transfer-client/transfer-client.component.ts +++ b/src/app/clients/clients-view/client-actions/transfer-client/transfer-client.component.ts @@ -16,6 +16,7 @@ import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; import { SettingsService } from 'app/settings/settings.service'; import { CdkTextareaAutosize } from '@angular/cdk/text-field'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -27,7 +28,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; styleUrls: ['./transfer-client.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, - CdkTextareaAutosize + CdkTextareaAutosize, + FaIconComponent ] }) export class TransferClientComponent implements OnInit { diff --git a/src/app/clients/clients-view/client-actions/undo-client-rejection/undo-client-rejection.component.html b/src/app/clients/clients-view/client-actions/undo-client-rejection/undo-client-rejection.component.html index 247bd8cf6..1c6633dd2 100644 --- a/src/app/clients/clients-view/client-actions/undo-client-rejection/undo-client-rejection.component.html +++ b/src/app/clients/clients-view/client-actions/undo-client-rejection/undo-client-rejection.component.html @@ -6,11 +6,22 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
- - +
+
+
+
+
Client Action
+

{{ 'labels.heading.Undo Client Rejection' | translate }}

+

+ Reverse a prior rejection and re-open this client back to the Pending state for further review. +

+
+
+ + +
+
+ {{ 'labels.inputs.Reopened On Date' | translate }} } - +
- - - - - - +
+ + The client returns to Pending — you can then activate, withdraw or re-reject as + appropriate. +
+
+ +
+ + +
+
diff --git a/src/app/clients/clients-view/client-actions/undo-client-rejection/undo-client-rejection.component.ts b/src/app/clients/clients-view/client-actions/undo-client-rejection/undo-client-rejection.component.ts index b9fa9a2d9..517817d85 100644 --- a/src/app/clients/clients-view/client-actions/undo-client-rejection/undo-client-rejection.component.ts +++ b/src/app/clients/clients-view/client-actions/undo-client-rejection/undo-client-rejection.component.ts @@ -15,6 +15,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; import { SettingsService } from 'app/settings/settings.service'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; templateUrl: './undo-client-rejection.component.html', styleUrls: ['./undo-client-rejection.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent ] }) export class UndoClientRejectionComponent implements OnInit { diff --git a/src/app/clients/clients-view/client-actions/update-client-savings-account/update-client-savings-account.component.html b/src/app/clients/clients-view/client-actions/update-client-savings-account/update-client-savings-account.component.html index 6d86ebd3f..e3504c076 100644 --- a/src/app/clients/clients-view/client-actions/update-client-savings-account/update-client-savings-account.component.html +++ b/src/app/clients/clients-view/client-actions/update-client-savings-account/update-client-savings-account.component.html @@ -6,38 +6,54 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
- -
- - {{ 'labels.inputs.Default Savings Account' | translate }} - - @for (account of savingsAccounts; track account) { - - {{ account.accountNo }} - - } - - @if (clientSavingsAccountForm.controls.savingsAccountId.hasError('required')) { - - {{ 'labels.inputs.Account' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - -
-
+
+
+
+
+
Client Action
+

{{ 'labels.buttons.Update Default Savings' | translate }}

+

+ Pick which savings account is the default for this client. Auto-debit instructions, fee + collections and loan disbursals will use this account unless explicitly overridden. +

+
+
- - - - - - +
+
+
+ + {{ 'labels.inputs.Default Savings Account' | translate }} + + @for (account of savingsAccounts; track account) { + {{ account.accountNo }} + } + + @if (clientSavingsAccountForm.controls.savingsAccountId.hasError('required')) { + + {{ 'labels.inputs.Account' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
+ +
+ + + Changing the default does not move any existing balances or scheduled instructions — it only affects + new debits/credits that look up the client's default account. + +
+
+ +
+ + +
+
diff --git a/src/app/clients/clients-view/client-actions/update-client-savings-account/update-client-savings-account.component.scss b/src/app/clients/clients-view/client-actions/update-client-savings-account/update-client-savings-account.component.scss index 7c3f1555b..14168b4b1 100644 --- a/src/app/clients/clients-view/client-actions/update-client-savings-account/update-client-savings-account.component.scss +++ b/src/app/clients/clients-view/client-actions/update-client-savings-account/update-client-savings-account.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.container { - max-width: 37rem; +@use 'assets/styles/client-action' as ca; + +@include ca.client-action-styles; + +/* Update Default Savings — teal accent */ +:host { + --ca-accent: #0e7e6b; + --ca-accent-l: #ccfbf1; } diff --git a/src/app/clients/clients-view/client-actions/update-client-savings-account/update-client-savings-account.component.ts b/src/app/clients/clients-view/client-actions/update-client-savings-account/update-client-savings-account.component.ts index af5fcdd95..758c3ff27 100644 --- a/src/app/clients/clients-view/client-actions/update-client-savings-account/update-client-savings-account.component.ts +++ b/src/app/clients/clients-view/client-actions/update-client-savings-account/update-client-savings-account.component.ts @@ -13,6 +13,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; /** Custom Services */ import { ClientsService } from 'app/clients/clients.service'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -23,7 +24,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; templateUrl: './update-client-savings-account.component.html', styleUrls: ['./update-client-savings-account.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent ] }) export class UpdateClientSavingsAccountComponent implements OnInit { diff --git a/src/app/clients/clients-view/client-actions/withdraw-client/withdraw-client.component.html b/src/app/clients/clients-view/client-actions/withdraw-client/withdraw-client.component.html index 9c68fb059..9e90dacc7 100644 --- a/src/app/clients/clients-view/client-actions/withdraw-client/withdraw-client.component.html +++ b/src/app/clients/clients-view/client-actions/withdraw-client/withdraw-client.component.html @@ -6,58 +6,79 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
- -
- - {{ 'labels.inputs.Withdrawal Date' | translate }} - - - - @if (withdrawClientForm.controls.withdrawalDate.hasError('required')) { - - {{ 'labels.inputs.Withdrawal Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - +
+ +
+
+
+
Client Action
+

{{ 'labels.heading.Withdraw Client' | translate }}

+

+ Withdraw a Pending client when onboarding will not proceed (e.g. customer changed their mind). + The record is preserved with a withdrawn status and can be re-opened later if needed. +

+
+
- - {{ 'labels.inputs.Withdrawal Reason' | translate }} - - @for (reason of withdrawalData; track reason) { - - {{ reason.name }} - - } - - @if (withdrawClientForm.controls.withdrawalReasonId.hasError('required')) { - - {{ 'labels.inputs.Withdrawal Reason' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - -
- + +
+
+ + {{ 'labels.inputs.Withdrawal Date' | translate }} + + + + @if (withdrawClientForm.controls.withdrawalDate.hasError('required')) { + + {{ 'labels.inputs.Withdrawal Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
- - - - - - +
+ + {{ 'labels.inputs.Withdrawal Reason' | translate }} + + @for (reason of withdrawalData; track reason) { + {{ reason.name }} + } + + @if (withdrawClientForm.controls.withdrawalReasonId.hasError('required')) { + + {{ 'labels.inputs.Withdrawal Reason' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + +
+ +
+ + + A reason is required for audit. The client record is retained and can be reactivated later via the + Undo Withdrawal action. + +
+
+ +
+ + +
+
diff --git a/src/app/clients/clients-view/client-actions/withdraw-client/withdraw-client.component.scss b/src/app/clients/clients-view/client-actions/withdraw-client/withdraw-client.component.scss index 7c3f1555b..5ece5b977 100644 --- a/src/app/clients/clients-view/client-actions/withdraw-client/withdraw-client.component.scss +++ b/src/app/clients/clients-view/client-actions/withdraw-client/withdraw-client.component.scss @@ -6,6 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.container { - max-width: 37rem; +@use 'assets/styles/client-action' as ca; + +@include ca.client-action-styles; + +/* Withdraw-specific amber accent (caution / pause) */ +:host { + --ca-accent: #b45309; + --ca-accent-l: #fef3c7; } diff --git a/src/app/clients/clients-view/client-actions/withdraw-client/withdraw-client.component.ts b/src/app/clients/clients-view/client-actions/withdraw-client/withdraw-client.component.ts index f1a47f28f..97043863f 100644 --- a/src/app/clients/clients-view/client-actions/withdraw-client/withdraw-client.component.ts +++ b/src/app/clients/clients-view/client-actions/withdraw-client/withdraw-client.component.ts @@ -15,6 +15,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; import { SettingsService } from 'app/settings/settings.service'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; templateUrl: './withdraw-client.component.html', styleUrls: ['./withdraw-client.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent ] }) export class WithdrawClientComponent implements OnInit { diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 15a0246e5..89889bb8c 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -55,6 +55,40 @@ + + + CASH₹14,82,500 + │ + + TXN TODAY + 1,247 + ↑ 8.2% + + │ + CLEARING IN42 + │ + CLEARING OUT38 + │ + + NPA·GROSS + 3.47% + ↑ 0.12 + + │ + CASA58.2% + │ + CD RATIO71.8% + │ + + RBI CIRC + DBR.AML.NO.4/14.01.001 + NEW + + │ + REPO6.50% + │ + +
@@ -170,6 +204,19 @@
+ +
+ + + LIVE + +
+ + + +
+
+ - - - - - + - -
@@ -896,25 +935,30 @@
Certificates
- - - -
Others
- - + - - - - + - - diff --git a/src/app/core/shell/shell.component.scss b/src/app/core/shell/shell.component.scss index 6730a1f41..926d3abfa 100644 --- a/src/app/core/shell/shell.component.scss +++ b/src/app/core/shell/shell.component.scss @@ -106,8 +106,24 @@ $brand: #1353a0; $brand-light: #eaf1fc; $brand-hover: #ddeafb; $gray-200: #e2e8f0; +$gray-400: #94a3b8; +$gray-500: #64748b; $gray-700: #334155; +/* ============================================================ + Main header (top 2-row navigation) + -------------------------------------------------------------- + Redesigned 2026 — clean, modern enterprise SaaS feel: + • Both rows live on the same white surface separated only by a + hairline; r2 has a barely-there blue-50 wash so frequent vs + secondary menus read at a glance without dark/light contrast. + • Items are now equal-width "tabs" with NO vertical dividers + between them (the old cell borders made it look spreadsheet-y). + • Hover / active = a 3 px brand-coloured TOP bar + brand text + + coloured icon. Reads instantly without colour-flooding the row. + • Icons larger (12.5 px) and full-opacity always — easier to scan + during demos. Caret is subtle and rotates on open. + ============================================================ */ .main-header { background: #fff; border-bottom: 2.5px solid $brand; @@ -119,48 +135,70 @@ $gray-700: #334155; } .nav-row { - height: 38px; - display: flex; + height: 44px; + display: grid; + grid-template-columns: repeat(11, minmax(0, 1fr)); align-items: stretch; - justify-content: space-between; overflow: visible; position: relative; + + /* Grid container background shows through the 1 px gap as crisp 1 px borders + on every side of every cell — no doubled-up 2 px lines between adjacent + items, perfect 1 px outer frame too. */ + gap: 1px; + background: $gray-200; + border: 1px solid $gray-200; } -.r1 { +.r1 .ni { background: #fff; - border-bottom: 1px solid $gray-200; } -.r2 { +.r2 .ni { background: #f8fafc; } -// ===== Nav Item ===== +// ===== Nav Item — modern tab style ===== +// Typography spec — same family / size / weight / color for BOTH rows. .ni { position: relative; - display: flex; + display: inline-flex; align-items: center; - gap: 4px; - flex: 1; justify-content: center; + gap: 7px; height: 100%; + padding: 0 10px; cursor: pointer; white-space: nowrap; - color: $gray-700; - font-size: 11px; + font-family: + 'IBM Plex Sans', + -apple-system, + sans-serif; + font-size: 12px; font-weight: 500; + color: #0a1a2e; + line-height: 1.3; letter-spacing: 0.01em; - transition: - background 0.12s, - color 0.12s; - user-select: none; - border-right: 1px solid $gray-200; text-decoration: none; - font-family: 'DM Sans', Roboto, sans-serif; + user-select: none; + transition: + background 0.15s ease, + color 0.15s ease, + box-shadow 0.15s ease; + border-radius: 0; - &:last-child { - border-right: none; + /* 3px accent strip that fills on hover / active (uses ::before so we + don't reflow the row when toggling). */ + &::before { + content: ''; + position: absolute; + left: 8px; + right: 8px; + top: 0; + height: 3px; + background: transparent; + border-radius: 0 0 3px 3px; + transition: background 0.15s ease; } &:hover { @@ -168,48 +206,276 @@ $gray-700: #334155; color: $brand; } + &:hover::before { + background: $brand; + } + &.active { - background: $brand-light; color: $brand; - font-weight: 600; + background: $brand-light; } &.active::before { - content: ''; - position: absolute; - left: 0; - top: 18%; - bottom: 18%; - width: 2.5px; background: $brand; - border-radius: 0 2px 2px 0; + } + + &:focus-visible { + outline: 2px solid $brand; + outline-offset: -2px; } } .ni-ico { - font-size: 11px; - opacity: 0.55; + width: 12px; + height: 12px; + font-size: 12px; + color: $gray-500; flex-shrink: 0; + transition: color 0.15s ease; .ni:hover &, .ni.active & { - opacity: 1; + color: $brand; } } .ni-caret { - font-size: 8px; - opacity: 0.3; - transition: - transform 0.18s, - opacity 0.18s; + width: 7px; + height: 7px; + font-size: 7px; + color: $gray-400; flex-shrink: 0; + transition: + transform 0.18s ease, + color 0.18s ease; - .ni:hover & { - opacity: 0.7; + .ni:hover &, + .ni.active & { + color: $brand; } } +@media (width <= 1200px) { + .ni { + padding: 0 8px; + gap: 5px; + } +} + +@media (width <= 980px) { + .ni { + padding: 0 6px; + gap: 4px; + } +} + +/* ============================================================ + Live KPI ticker — sits directly under the 2-row menu + ============================================================ */ +.kpi-ticker { + position: relative; + display: flex; + align-items: center; + height: 30px; + + /* Premium rich brand-blue gradient — matches the CKYC banner you saw in the + screenshot, anchoring the ticker into the same brand surface family. + Same hues, same direction, same depth = visual cohesion across the app. */ + background: linear-gradient(135deg, #0b1e3d 0%, #1e3a8a 35%, #1d4ed8 65%, #1e40af 100%); + color: #fff; + overflow: hidden; + font-family: + 'IBM Plex Sans', + -apple-system, + sans-serif; + font-size: 11.5px; + line-height: 1; + user-select: none; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 10%); + + /* Thin amber accent strip at the bottom — classic financial-ticker premium + touch, picks up the same amber used inside the value chips. */ + border-bottom: 1.5px solid #fbbf24; +} + +/* Slow-moving sheen overlay that gives the band a subtle "live" pulse without + stealing focus from the data. Pointer-events: none so it never blocks hover + pausing on the track. */ +.kpi-ticker::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 115deg, + transparent 0%, + transparent 38%, + rgb(255 255 255 / 8%) 50%, + transparent 62%, + transparent 100% + ); + background-size: 300% 100%; + animation: kpi-sheen 8s linear infinite; + pointer-events: none; + z-index: 1; +} + +.kpi-ticker:hover::before { + animation-play-state: paused; +} + +@keyframes kpi-sheen { + from { + background-position: 200% 0; + } + + to { + background-position: -100% 0; + } +} + +/* Sticky "LIVE" badge — a punchy red chip so the eye finds it instantly on + the dark blue band. Classic financial-ticker vibe. */ +.kpi-live { + position: sticky; + left: 0; + z-index: 3; + display: inline-flex; + align-items: center; + gap: 7px; + height: 100%; + padding: 0 14px; + background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); + font-weight: 800; + letter-spacing: 0.18em; + font-size: 10px; + color: #fff; + text-transform: uppercase; + border-right: 1px solid rgb(255 255 255 / 18%); + box-shadow: + 8px 0 14px -8px rgb(11 30 61 / 60%), + inset 0 1px 0 rgb(255 255 255 / 16%); + flex-shrink: 0; + text-shadow: 0 1px 0 rgb(0 0 0 / 25%); +} + +.kpi-live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #fef08a; /* bright yellow dot on red — high-contrast attention */ + box-shadow: + 0 0 0 0 rgb(254 240 138 / 80%), + 0 0 6px 1px rgb(254 240 138 / 60%); + animation: kpi-pulse 1.3s ease-in-out infinite; +} + +@keyframes kpi-pulse { + 0% { + box-shadow: + 0 0 0 0 rgb(254 240 138 / 70%), + 0 0 6px 1px rgb(254 240 138 / 60%); + transform: scale(1); + } + + 70% { + box-shadow: + 0 0 0 8px rgb(254 240 138 / 0%), + 0 0 6px 1px rgb(254 240 138 / 0%); + transform: scale(1.18); + } + + 100% { + box-shadow: + 0 0 0 0 rgb(254 240 138 / 0%), + 0 0 6px 1px rgb(254 240 138 / 30%); + transform: scale(1); + } +} + +/* Scrolling track — rendered with the item list twice so the loop is seamless. + z-index keeps the data above the sheen overlay. */ +.kpi-track { + position: relative; + z-index: 2; + display: inline-flex; + align-items: center; + gap: 0; + white-space: nowrap; + width: max-content; + padding-left: 18px; + animation: kpi-scroll 75s linear infinite; +} + +.kpi-ticker:hover .kpi-track, +.kpi-ticker:focus-within .kpi-track { + animation-play-state: paused; +} + +@keyframes kpi-scroll { + from { + transform: translateX(0); + } + + to { + transform: translateX(-50%); + } +} + +/* Individual ticker item — key + one or more values */ +.ti { + display: inline-flex; + align-items: baseline; + gap: 8px; + padding: 0 14px; + position: relative; + z-index: 2; /* above the sheen overlay */ +} + +.ti-k { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: rgb(255 255 255 / 62%); +} + +.ti-v { + font-size: 12px; + font-weight: 700; + color: #fff; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + text-shadow: 0 1px 0 rgb(0 0 0 / 20%); + + &.amber { + /* Bright gold value — financial-ticker classic, pops on deep blue */ + color: #fcd34d; + text-shadow: + 0 1px 0 rgb(0 0 0 / 25%), + 0 0 8px rgb(252 211 77 / 30%); + } + + &.up { + color: #4ade80; /* green-400 — bright on dark blue */ + text-shadow: 0 1px 0 rgb(0 0 0 / 25%); + } + + &.down { + color: #fca5a5; /* red-300 — bright on dark blue */ + text-shadow: 0 1px 0 rgb(0 0 0 / 25%); + } +} + +/* Vertical pipe separator between items */ +.ti-d { + position: relative; + z-index: 2; + color: rgb(255 255 255 / 26%); + font-weight: 300; + font-size: 14px; + padding: 0 2px; +} + .menu-item-icon { margin-right: 8px; width: 16px; diff --git a/src/app/core/shell/shell.component.ts b/src/app/core/shell/shell.component.ts index d92aa0ff7..2b36d5041 100644 --- a/src/app/core/shell/shell.component.ts +++ b/src/app/core/shell/shell.component.ts @@ -82,6 +82,9 @@ export class ShellComponent implements OnInit, OnDestroy { rbiMenuOpen = false; rbiSearch = ''; + /* Live KPI ticker — markup is static inside the template; the marquee + animation, LIVE badge, and pause-on-hover handle the live feel. */ + matchesSubLedgerSearch(label: string): boolean { if (!this.subLedgerSearch) return true; return label.toLowerCase().includes(this.subLedgerSearch.toLowerCase()); diff --git a/src/app/core/shell/sidenav/sidenav.component.html b/src/app/core/shell/sidenav/sidenav.component.html index 06c2ebed5..1182df9ba 100644 --- a/src/app/core/shell/sidenav/sidenav.component.html +++ b/src/app/core/shell/sidenav/sidenav.component.html @@ -68,14 +68,14 @@ - KYC Management + KYC & ReKYC Management + +
+ +
+
+ +
+

ATM / POS / E-Commerce — Reports

+
+ Pick a report style. Tickets open the matching analytics view for reconciliation and ops review. +
+
+
+
+ + +
+ @for (t of tickets; track t.kind) { + + } +
+
diff --git a/src/app/digital/atm-pos-ecom/atm-pos-ecom.component.scss b/src/app/digital/atm-pos-ecom/atm-pos-ecom.component.scss new file mode 100644 index 000000000..7b915f970 --- /dev/null +++ b/src/app/digital/atm-pos-ecom/atm-pos-ecom.component.scss @@ -0,0 +1,336 @@ +/** + * 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/. + */ + +:host { + --ape-blue: #1d4ed8; + --ape-blue-d: #1e40af; + --ape-blue-l: #eff6ff; + --ape-blue-tint: #dbeafe; + --ape-green: #15803d; + --ape-green-d: #166534; + --ape-green-l: #dcfce7; + --ape-green-tint: #bbf7d0; + --ape-purple: #6d28d9; + --ape-purple-d: #5b21b6; + --ape-purple-l: #ede9fe; + --ape-purple-tint: #ddd6fe; + --ape-g50: #f8fafc; + --ape-g100: #f1f5f9; + --ape-g200: #e2e8f0; + --ape-g300: #cbd5e1; + --ape-g500: #64748b; + --ape-g700: #334155; + --ape-g900: #0f172a; + + display: block; +} + +.ape-root { + padding: 22px 26px 32px; + max-width: 1200px; + margin: 0 auto; +} + +/* ───────── Page header ───────── */ +.ape-head { + display: flex; + align-items: center; + gap: 16px; + padding: 18px 22px; + border-radius: 16px; + background: linear-gradient(135deg, var(--ape-blue-l) 0%, #fff 65%); + border: 1px solid var(--ape-blue-tint); + margin-bottom: 22px; +} + +.ape-head-l { + display: flex; + align-items: center; + gap: 14px; +} + +.ape-head-ic { + width: 48px; + height: 48px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + background: var(--ape-blue-d); + color: #fff; + font-size: 20px; +} + +.ape-head-t { + margin: 0 0 4px; + font-size: 18px; + font-weight: 800; + color: var(--ape-g900); +} + +.ape-head-s { + font-size: 13px; + color: var(--ape-g500); + line-height: 1.5; +} + +/* ───────── Tickets grid ───────── */ +.ape-tickets { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; + + @media (width <= 1024px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (width <= 640px) { + grid-template-columns: 1fr; + } +} + +/* ───────── Ticket card ───────── */ +.ape-ticket { + position: relative; + display: flex; + flex-direction: column; + gap: 16px; + padding: 22px 22px 18px; + background: #fff; + border: 1px solid var(--ape-g200); + border-radius: 16px; + text-align: left; + cursor: pointer; + overflow: visible; + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease; + + /* Subtle gradient strip on the left edge to give the ticket a "stub" look. */ + &::before { + content: ''; + position: absolute; + inset: 14px auto 14px 0; + width: 4px; + border-radius: 0 4px 4px 0; + background: var(--ape-g200); + transition: background 0.18s ease; + } + + &:hover { + transform: translateY(-3px); + box-shadow: + 0 18px 38px -22px rgb(15 23 42 / 22%), + 0 6px 14px -8px rgb(15 23 42 / 12%); + } + + &:focus-visible { + outline: 2px solid var(--ape-blue); + outline-offset: 3px; + } + + /* Accent variants */ + &.accent-blue { + &::before { + background: var(--ape-blue); + } + + &:hover { + border-color: var(--ape-blue-tint); + } + + .ape-ticket-icon { + background: var(--ape-blue-l); + color: var(--ape-blue-d); + } + + .ape-ticket-tag { + background: var(--ape-blue-l); + color: var(--ape-blue-d); + border-color: var(--ape-blue-tint); + } + + .ape-ticket-cta { + color: var(--ape-blue-d); + border-color: var(--ape-blue-tint); + } + + &:hover .ape-ticket-cta { + background: var(--ape-blue-d); + color: #fff; + border-color: var(--ape-blue-d); + } + } + + &.accent-green { + &::before { + background: var(--ape-green); + } + + &:hover { + border-color: var(--ape-green-tint); + } + + .ape-ticket-icon { + background: var(--ape-green-l); + color: var(--ape-green-d); + } + + .ape-ticket-tag { + background: var(--ape-green-l); + color: var(--ape-green-d); + border-color: var(--ape-green-tint); + } + + .ape-ticket-cta { + color: var(--ape-green-d); + border-color: var(--ape-green-tint); + } + + &:hover .ape-ticket-cta { + background: var(--ape-green-d); + color: #fff; + border-color: var(--ape-green-d); + } + } + + &.accent-purple { + &::before { + background: var(--ape-purple); + } + + &:hover { + border-color: var(--ape-purple-tint); + } + + .ape-ticket-icon { + background: var(--ape-purple-l); + color: var(--ape-purple-d); + } + + .ape-ticket-tag { + background: var(--ape-purple-l); + color: var(--ape-purple-d); + border-color: var(--ape-purple-tint); + } + + .ape-ticket-cta { + color: var(--ape-purple-d); + border-color: var(--ape-purple-tint); + } + + &:hover .ape-ticket-cta { + background: var(--ape-purple-d); + color: #fff; + border-color: var(--ape-purple-d); + } + } +} + +/* ───────── Ticket "tear" notches ───────── */ +.ape-ticket-notch { + position: absolute; + top: 50%; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--ape-g50); + border: 1px solid var(--ape-g200); + transform: translateY(-50%); + + &--l { + left: -8px; + } + + &--r { + right: -8px; + } +} + +/* ───────── Ticket header (icon + tagline pill) ───────── */ +.ape-ticket-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.ape-ticket-icon { + width: 44px; + height: 44px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + font-size: 18px; +} + +.ape-ticket-tag { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border: 1px solid transparent; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + +/* ───────── Ticket body ───────── */ +.ape-ticket-body { + flex: 1; +} + +.ape-ticket-title { + margin: 0 0 6px; + font-size: 16px; + font-weight: 800; + color: var(--ape-g900); + letter-spacing: -0.01em; +} + +.ape-ticket-desc { + margin: 0; + font-size: 12.5px; + line-height: 1.55; + color: var(--ape-g500); +} + +/* ───────── CTA chip at the foot ───────── */ +.ape-ticket-cta { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 9px 14px; + border-radius: 10px; + background: #fff; + border: 1px solid; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + align-self: stretch; + transition: + background 0.18s ease, + color 0.18s ease, + border-color 0.18s ease; +} + +.ape-ticket-cta-ic { + font-size: 11px; + transition: transform 0.18s ease; +} + +.ape-ticket:hover .ape-ticket-cta-ic { + transform: translateX(3px); +} diff --git a/src/app/digital/atm-pos-ecom/atm-pos-ecom.component.ts b/src/app/digital/atm-pos-ecom/atm-pos-ecom.component.ts new file mode 100644 index 000000000..8c58a7a11 --- /dev/null +++ b/src/app/digital/atm-pos-ecom/atm-pos-ecom.component.ts @@ -0,0 +1,92 @@ +/** + * 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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { Router } from '@angular/router'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +export type AtmPosEcomReportKind = 'today' | 'status' | 'cycle'; + +interface ReportTicket { + kind: AtmPosEcomReportKind; + /** Route segment under /digital/atm-pos-ecom/ — wired once the report component exists. */ + route: string; + icon: string; + accent: 'blue' | 'green' | 'purple'; + title: string; + tagline: string; + description: string; + cta: string; +} + +@Component({ + selector: 'mifosx-atm-pos-ecom', + templateUrl: './atm-pos-ecom.component.html', + styleUrls: ['./atm-pos-ecom.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FaIconComponent, + MatTooltip + ] +}) +export class AtmPosEcomComponent { + private snack = inject(MatSnackBar); + private router = inject(Router); + + /** The three reports surfaced as tickets. Wire `route` to a real component as it lands. */ + readonly tickets: ReportTicket[] = [ + { + kind: 'today', + route: 'today', + icon: 'calendar-check', + accent: 'blue', + title: "Today's Report", + tagline: 'Same-day snapshot', + description: + "Real-time view of today's ATM, POS and E-commerce transactions across every channel — counts, value, success rate.", + cta: 'Open Today’s Report' + }, + { + kind: 'status', + route: 'status', + icon: 'tasks', + accent: 'green', + title: 'Status-wise Report', + tagline: 'Grouped by processing state', + description: + 'Breakdown by outcome — Approved · Declined · Reversed · Pending · Timed-out. Useful for ops triage and decline-rate analysis.', + cta: 'Open Status Report' + }, + { + kind: 'cycle', + route: 'cycle', + icon: 'sync-alt', + accent: 'purple', + title: 'Cycle-wise Report', + tagline: 'Per settlement cycle', + description: + 'Compare across settlement cycles — daily, weekly, monthly — for reconciliation with the network and acquirer files.', + cta: 'Open Cycle Report' + } + ]; + + /** + * Until the underlying report components are wired, clicks surface a friendly + * "coming soon" toast. Once the report format is shared, replace this with + * `this.router.navigate(['/digital/atm-pos-ecom', t.route])`. + */ + openReport(t: ReportTicket): void { + this.snack.open(`${t.title} is coming next — drop the format and I'll wire it up.`, 'OK', { duration: 3500 }); + // this.router.navigate(['/digital/atm-pos-ecom', t.route]); + } +} diff --git a/src/app/digital/digital-routing.module.ts b/src/app/digital/digital-routing.module.ts index 0ff67712e..950301fcb 100644 --- a/src/app/digital/digital-routing.module.ts +++ b/src/app/digital/digital-routing.module.ts @@ -10,20 +10,32 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { Route } from '../core/route/route.service'; -import { NeftRtgsEntryComponent } from './neft-rtgs-entry/neft-rtgs-entry.component'; import { NeftRtgsReportComponent } from './neft-rtgs-report/neft-rtgs-report.component'; +import { ImpsReportComponent } from './imps-report/imps-report.component'; +import { UpiReportComponent } from './upi-report/upi-report.component'; +import { AtmPosEcomComponent } from './atm-pos-ecom/atm-pos-ecom.component'; const routes: Routes = [ Route.withShell([ - { - path: 'digital/neft-rtgs', - component: NeftRtgsEntryComponent, - data: { title: 'NEFT / RTGS Entry', breadcrumb: 'NEFT / RTGS' } - }, { path: 'digital/neft-rtgs-report', component: NeftRtgsReportComponent, - data: { title: 'NEFT / RTGS Report', breadcrumb: 'NEFT / RTGS Report' } + data: { title: 'NEFT / RTGS', breadcrumb: 'NEFT / RTGS' } + }, + { + path: 'digital/imps-report', + component: ImpsReportComponent, + data: { title: 'IMPS', breadcrumb: 'IMPS' } + }, + { + path: 'digital/upi-report', + component: UpiReportComponent, + data: { title: 'UPI Reports', breadcrumb: 'UPI' } + }, + { + path: 'digital/atm-pos-ecom', + component: AtmPosEcomComponent, + data: { title: 'ATM / POS / E-Commerce Reports', breadcrumb: 'ATM / POS / ECOM' } } ]) ]; diff --git a/src/app/digital/digital.module.ts b/src/app/digital/digital.module.ts index 40c9e2a7b..74ab7f536 100644 --- a/src/app/digital/digital.module.ts +++ b/src/app/digital/digital.module.ts @@ -12,13 +12,25 @@ import { SharedModule } from '../shared/shared.module'; import { DigitalRoutingModule } from './digital-routing.module'; import { NeftRtgsEntryComponent } from './neft-rtgs-entry/neft-rtgs-entry.component'; import { NeftRtgsReportComponent } from './neft-rtgs-report/neft-rtgs-report.component'; +import { ImpsEntryComponent } from './imps-entry/imps-entry.component'; +import { ImpsReportComponent } from './imps-report/imps-report.component'; +import { SenderHistoryDialogComponent } from './sender-history-dialog/sender-history-dialog.component'; +import { ImpsSenderHistoryDialogComponent } from './imps-sender-history-dialog/imps-sender-history-dialog.component'; +import { UpiReportComponent } from './upi-report/upi-report.component'; +import { AtmPosEcomComponent } from './atm-pos-ecom/atm-pos-ecom.component'; @NgModule({ imports: [ SharedModule, DigitalRoutingModule, NeftRtgsEntryComponent, - NeftRtgsReportComponent + NeftRtgsReportComponent, + ImpsEntryComponent, + ImpsReportComponent, + UpiReportComponent, + SenderHistoryDialogComponent, + ImpsSenderHistoryDialogComponent, + AtmPosEcomComponent ] }) export class DigitalModule {} diff --git a/src/app/digital/imps-entry/imps-entry.component.html b/src/app/digital/imps-entry/imps-entry.component.html new file mode 100644 index 000000000..f2af47295 --- /dev/null +++ b/src/app/digital/imps-entry/imps-entry.component.html @@ -0,0 +1,504 @@ + + +
+ +
+
+ +
+

IMPS — Immediate Payment Service

+
+ {{ txnType }} + · + + Real-time NPCI Switch · 24×7 · ₹5L per-transaction limit +
+
+
+
+
+ IMPS Limit + ₹{{ IMPS_MAX | number: '1.0-0' }} +
+ + @if (isDialog) { + + } +
+
+ + @if (entryForm) { +
+ +
+
+ + Transaction Setup +
+ +
+ + Transaction Type + + P2A — Account + IFSC + P2P — MMID + Mobile + P2A · Aadhaar (APBS) + + + @if (entryForm.get('txnType')?.hasError('required')) { + Transaction Type is required + } + + + + Value Date + + + + @if (entryForm.get('valueDate')?.hasError('required')) { + Value Date is required + } + + + @if (isP2A) { + + Sender Account No. + + + + @for (a of filteredSenderAccounts | async; track a.accountNo) { + +
+ {{ a.accountNo }} + + {{ a.holderName }} · {{ a.productName }} + Avail ₹{{ a.availableBalance | number: '1.0-0' }} + +
+
+ } + @if (!(filteredSenderAccounts | async)?.length) { + + No matching account — check the number you've typed. + + } +
+ @if (entryForm.get('senderAccount')?.hasError('required')) { + Sender Account is required + } + @if ( + entryForm.get('senderAccount')?.hasError('pattern') && + !entryForm.get('senderAccount')?.hasError('required') + ) { + Use 6–20 alphanumeric chars + } +
+ } + + + @if (currentSender) { +
+
+ + {{ currentSender.holderName }} + {{ currentSender.productName }} · {{ currentSender.accountNo }} +
+
+ Available Balance + ₹{{ currentSender.availableBalance | number: '1.2-2' }} + @if (insufficientBalance) { + + + Below total debit ₹{{ totalDebit | number: '1.2-2' }} + + } +
+
+ } + + + Amount (₹) + ₹  + + @if (entryForm.get('amount')?.hasError('required')) { + Amount is required + } + @if (entryForm.get('amount')?.hasError('min') && !entryForm.get('amount')?.hasError('required')) { + Amount must be > 0 + } + @if (entryForm.get('amount')?.hasError('max') && !entryForm.get('amount')?.hasError('required')) { + IMPS limit ₹{{ IMPS_MAX | number: '1.0-0' }} per txn + } + +
+
+ + +
+
+ + Beneficiary Details + @if (beneVerified) { + + BANL Verified + + } @else { + + Verification Required + + } + +
+ +
+ @if (isP2A) { + + Beneficiary Account + + @if (entryForm.get('beneficiaryAccount')?.hasError('required')) { + Beneficiary Account is required + } + @if ( + entryForm.get('beneficiaryAccount')?.hasError('pattern') && + !entryForm.get('beneficiaryAccount')?.hasError('required') + ) { + Use 6–20 alphanumeric chars + } + + + + IFSC Code + + @if (entryForm.get('ifscCode')?.hasError('required')) { + IFSC Code is required + } + @if (entryForm.get('ifscCode')?.hasError('pattern') && !entryForm.get('ifscCode')?.hasError('required')) { + Invalid IFSC — 11 chars (e.g. HDFC0001234) + } + + } + + @if (isP2P) { + + MMID (7 digits) + + + Or use Mobile + @if (entryForm.get('beneMmid')?.hasError('pattern')) { + MMID must be exactly 7 digits + } + + + + Beneficiary Mobile + +91  + + + Or use MMID + @if (entryForm.get('beneMobile')?.hasError('pattern')) { + Enter a valid 10-digit Indian mobile (6/7/8/9-starting) + } + + } + + @if (isAadhaar) { + + Beneficiary Aadhaar (12 digits) + + + @if (entryForm.get('beneAadhaar')?.hasError('required')) { + Aadhaar is required + } + @if ( + entryForm.get('beneAadhaar')?.hasError('pattern') && !entryForm.get('beneAadhaar')?.hasError('required') + ) { + Aadhaar must be exactly 12 digits + } + + } + + + @if (!isP2A && beneResolved) { + + Resolved Account No. + + + + + + Resolved IFSC + + + + } + + + Beneficiary Name (auto-filled by IMPS Switch) + + @if (beneVerified) { + + } @else { + + } + +
+ + @if (beneMatch) { +
+ +
+ {{ beneMatch.beneficiaryName }} + @if (beneMatch.beneficiaryAccount) { + · {{ beneMatch.beneficiaryAccount }} + } + @if (beneMatch.ifscCode) { + · {{ beneMatch.ifscCode }} + } + @if (beneMatch.mmid) { + · MMID {{ beneMatch.mmid }} + } + @if (beneMatch.mobile) { + · Mobile +91 {{ beneMatch.mobile }} + } + · {{ beneMatch.bankName }}, {{ beneMatch.branchName }} + · {{ beneMatch.status }} +
+ +
+ } + @if (lookupError) { +
+ +
{{ lookupError }}
+ +
+ } +
+ + +
+
+ + Purpose & Notes +
+ +
+ + Purpose of Remittance * + + @for (p of purposeOptions; track p.code) { + {{ p.label }} + } + + @if (entryForm.get('purposeOfRemittance')?.hasError('required')) { + Purpose is required + } + + + + Remarks (optional) + + +
+
+ + +
+
+ + Charges & GST + IMPS schedule · 18% GST on charge + +
+ +
+
+ Transfer Amount + ₹{{ amount || 0 | number: '1.2-2' }} +
+
+ + Service Charge + (IMPS) + + + @if (chargesWaived) { + ₹{{ scheduledCharge | number: '1.2-2' }} + Waived + } @else { + ₹{{ serviceCharge | number: '1.2-2' }} + } + +
+
+ GST (18% on charge) + ₹{{ gstOnCharge | number: '1.2-2' }} +
+
+ Total Debit + + ₹{{ totalDebit | number: '1.2-2' }} + +
+
+
+ + +
+ + + + @if (!beneVerified) { + + + IMPS Switch look-up required to enable Initiate + + } + + +
+
+ } +
diff --git a/src/app/digital/imps-entry/imps-entry.component.ts b/src/app/digital/imps-entry/imps-entry.component.ts new file mode 100644 index 000000000..75a172a1f --- /dev/null +++ b/src/app/digital/imps-entry/imps-entry.component.ts @@ -0,0 +1,691 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject, OnInit } from '@angular/core'; +import { AsyncPipe } from '@angular/common'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { Router } from '@angular/router'; +import { Observable, of, startWith, map } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { SenderAccount } from '../neft-rtgs-entry/neft-rtgs-entry.component'; +import { seedImpsTransactions } from '../imps-report/imps-report.component'; +import { + ImpsSenderHistoryDialogComponent, + ImpsSenderHistoryDialogData +} from '../imps-sender-history-dialog/imps-sender-history-dialog.component'; + +export type ImpsTxnType = 'P2A' | 'P2P' | 'P2AADHAAR'; + +interface PurposeOption { + code: string; + label: string; +} + +interface ImpsBeneficiaryMatch { + beneficiaryName: string; + beneficiaryAccount?: string; + ifscCode?: string; + mmid?: string; + mobile?: string; + aadhaar?: string; + bankName: string; + branchName: string; + status: 'ACTIVE' | 'DORMANT'; +} + +@Component({ + selector: 'mifosx-imps-entry', + templateUrl: './imps-entry.component.html', + styleUrls: ['../neft-rtgs-entry/neft-rtgs-entry.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTooltip, + MatAutocomplete, + MatAutocompleteTrigger, + AsyncPipe + ] +}) +export class ImpsEntryComponent implements OnInit { + private fb = inject(FormBuilder); + private snack = inject(MatSnackBar); + private router = inject(Router); + private dialog = inject(MatDialog); + private dialogRef = inject(MatDialogRef, { optional: true }); + + get isDialog(): boolean { + return this.dialogRef !== null; + } + + /** IMPS per-transaction ceiling (₹5L) per current NPCI norms. */ + readonly IMPS_MAX = 500000; + /** GST rate applied on the service charge (18% per current GST schedule). */ + readonly GST_RATE = 0.18; + + minDate = new Date(2000, 0, 1); + maxDate = new Date(2100, 11, 31); + + entryForm: FormGroup; + + /** IMPS Switch lookup state. */ + lookupBusy = false; + beneMatch: ImpsBeneficiaryMatch | null = null; + lookupError: string | null = null; + + /** Mock NPCI IMPS Switch directory — keyed by both A/C+IFSC and MMID+Mobile. */ + private impsDirectory: ImpsBeneficiaryMatch[] = [ + { + beneficiaryName: 'Rohan S. Mehta', + beneficiaryAccount: '50100123456789', + ifscCode: 'HDFC0001234', + mmid: '9234567', + mobile: '9821234567', + bankName: 'HDFC Bank', + branchName: 'Mumbai — Andheri (E)', + status: 'ACTIVE' + }, + { + beneficiaryName: 'Bluestone Trading Co.', + beneficiaryAccount: '345678901234', + ifscCode: 'ICIC0000567', + bankName: 'ICICI Bank', + branchName: 'Bengaluru — MG Road', + status: 'ACTIVE' + }, + { + beneficiaryName: 'Anita Verma', + beneficiaryAccount: '912345678901', + ifscCode: 'SBIN0011223', + mmid: '9128831', + mobile: '9845112233', + bankName: 'State Bank of India', + branchName: 'New Delhi — Connaught Place', + status: 'DORMANT' + }, + { + beneficiaryName: 'Neha Kapoor', + beneficiaryAccount: '50100654321789', + ifscCode: 'HDFC0009876', + mmid: '9650432', + mobile: '9890065432', + bankName: 'HDFC Bank', + branchName: 'Pune — Koregaon Park', + status: 'ACTIVE' + } + ]; + + /** Same demo list as NEFT/RTGS — the operator's own debit-side accounts. */ + senderAccounts: SenderAccount[] = [ + { + accountNo: 'SB10029384', + holderName: 'Anita Desai', + productName: 'Savings — Premium', + availableBalance: 482500 + }, + { + accountNo: 'CA20034112', + holderName: 'Anita Desai', + productName: 'Current — Business', + availableBalance: 1245300 + }, + { + accountNo: 'SB50200012345', + holderName: 'Anita Desai', + productName: 'Savings — Regular', + availableBalance: 92300 + }, + { + accountNo: 'OD30049827', + holderName: 'Anita Desai', + productName: 'Overdraft — Secured', + availableBalance: 750000 + }, + { + accountNo: 'CA60055123', + holderName: 'Anita Desai HUF', + productName: 'Current — HUF', + availableBalance: 318900 + } + ]; + + filteredSenderAccounts!: Observable; + + purposeOptions: PurposeOption[] = [ + { code: 'FAMILY_MAINTENANCE', label: 'Family Maintenance' }, + { code: 'VENDOR_PAYMENT', label: 'Vendor / Supplier Payment' }, + { code: 'TRADE_SETTLEMENT', label: 'Trade Settlement' }, + { code: 'SALARY_PAYMENT', label: 'Salary Payment' }, + { code: 'LOAN_REPAYMENT', label: 'Loan Repayment' }, + { code: 'TAX_PAYMENT', label: 'Tax Payment' }, + { code: 'GIFT', label: 'Gift / Personal Remittance' }, + { code: 'INVESTMENT', label: 'Investment / Securities' }, + { code: 'OTHER', label: 'Other' } + ]; + + ngOnInit(): void { + this.entryForm = this.fb.group({ + txnType: [ + 'P2A' as ImpsTxnType, + Validators.required + ], + valueDate: [ + new Date(), + Validators.required + ], + senderAccount: [ + '', + [ + Validators.required, + Validators.pattern(/^[A-Za-z0-9]{6,20}$/) + ] + ], + amount: [ + null, + [ + Validators.required, + Validators.min(1), + Validators.max(this.IMPS_MAX) + ] + ], + // P2A + beneficiaryAccount: [''], + ifscCode: [''], + // P2P + beneMmid: [''], + beneMobile: [''], + // P2AADHAAR + beneAadhaar: [''], + // Resolved + beneficiaryName: [ + '', + [ + Validators.required, + Validators.maxLength(120) + ] + ], + purposeOfRemittance: [ + '', + Validators.required + ], + remarks: [''], + waiveCharges: [false] + }); + + this.entryForm.get('txnType')?.valueChanges.subscribe((t: ImpsTxnType) => this.refreshTypeValidators(t)); + + // Any change to beneficiary identifiers invalidates the prior match. + [ + 'beneficiaryAccount', + 'ifscCode', + 'beneMmid', + 'beneMobile', + 'beneAadhaar' + ].forEach((c) => this.entryForm.get(c)?.valueChanges.subscribe(() => this.invalidateLookup())); + + this.filteredSenderAccounts = + this.entryForm.get('senderAccount')?.valueChanges.pipe( + startWith(this.entryForm.get('senderAccount')?.value || ''), + map((value) => this.filterSenderAccounts(value || '')) + ) ?? of(this.senderAccounts.slice()); + + this.refreshTypeValidators(this.entryForm.get('txnType')?.value); + } + + private filterSenderAccounts(query: string): SenderAccount[] { + const q = (query || '').toString().trim().toLowerCase(); + if (!q) return this.senderAccounts.slice(); + return this.senderAccounts.filter( + (a) => + a.accountNo.toLowerCase().includes(q) || + a.holderName.toLowerCase().includes(q) || + a.productName.toLowerCase().includes(q) + ); + } + + private invalidateLookup(): void { + if (!this.beneMatch) return; + this.beneMatch = null; + this.lookupError = null; + this.entryForm.get('beneficiaryName')?.setValue('', { emitEvent: false }); + // For P2P / Aadhaar the A/C + IFSC were auto-filled by the resolution step; + // clear them too so a stale resolution can't be submitted after the operator + // changes the underlying MMID / Mobile / Aadhaar. + if (!this.isP2A) { + this.entryForm.patchValue({ beneficiaryAccount: '', ifscCode: '' }, { emitEvent: false }); + } + } + + /** Apply the right validator set for the current IMPS sub-type. */ + private refreshTypeValidators(type: ImpsTxnType): void { + const sender = this.entryForm.get('senderAccount'); + const acct = this.entryForm.get('beneficiaryAccount'); + const ifsc = this.entryForm.get('ifscCode'); + const mmid = this.entryForm.get('beneMmid'); + const mob = this.entryForm.get('beneMobile'); + const aad = this.entryForm.get('beneAadhaar'); + + sender?.clearValidators(); + acct?.clearValidators(); + ifsc?.clearValidators(); + mmid?.clearValidators(); + mob?.clearValidators(); + aad?.clearValidators(); + + if (type === 'P2A') { + sender?.setValidators([ + Validators.required, + Validators.pattern(/^[A-Za-z0-9]{6,20}$/) + ]); + acct?.setValidators([ + Validators.required, + Validators.pattern(/^[A-Za-z0-9]{6,20}$/) + ]); + ifsc?.setValidators([ + Validators.required, + Validators.pattern(/^[A-Z]{4}0[A-Z0-9]{6}$/) + ]); + } else if (type === 'P2P') { + // Either MMID OR Mobile is sufficient — neither is individually required. + // Format is still validated when something is entered. + mmid?.setValidators([ + Validators.pattern(/^[0-9]{7}$/) + ]); + mob?.setValidators([ + Validators.pattern(/^[6-9][0-9]{9}$/) + ]); + } else { + aad?.setValidators([ + Validators.required, + Validators.pattern(/^[0-9]{12}$/) + ]); + } + + [ + sender, + acct, + ifsc, + mmid, + mob, + aad + ].forEach((c) => c?.updateValueAndValidity({ emitEvent: false })); + + // When the sender picker is hidden (P2P / Aadhaar), default the sender to the + // operator's first account so the remitter chip still shows name + balance. + if (type !== 'P2A' && !sender?.value && this.senderAccounts.length) { + sender?.setValue(this.senderAccounts[0].accountNo, { emitEvent: false }); + } + } + + // ───────── Derived ───────── + get txnType(): ImpsTxnType { + return this.entryForm?.get('txnType')?.value || 'P2A'; + } + get isP2A(): boolean { + return this.txnType === 'P2A'; + } + get isP2P(): boolean { + return this.txnType === 'P2P'; + } + get isAadhaar(): boolean { + return this.txnType === 'P2AADHAAR'; + } + get amount(): number { + return Number(this.entryForm?.get('amount')?.value || 0); + } + get overLimit(): boolean { + return this.amount > this.IMPS_MAX; + } + + /** Operator can waive the service charge (e.g., staff / internal / relationship). */ + get chargesWaived(): boolean { + return !!this.entryForm?.get('waiveCharges')?.value; + } + + /** + * IMPS charge slab (bank-set, illustrative): + * ≤ ₹1,000 → ₹2.50 + * ≤ ₹25,000 → ₹5 + * ≤ ₹1,00,000 → ₹15 + * > ₹1,00,000 → ₹25 + */ + get serviceCharge(): number { + if (this.chargesWaived) return 0; + const a = this.amount; + if (!a || a <= 0) return 0; + if (a <= 1000) return 2.5; + if (a <= 25000) return 5; + if (a <= 100000) return 15; + return 25; + } + + get gstOnCharge(): number { + return +(this.serviceCharge * this.GST_RATE).toFixed(2); + } + + get totalFee(): number { + return +(this.serviceCharge + this.gstOnCharge).toFixed(2); + } + + get totalDebit(): number { + return +(this.amount + this.totalFee).toFixed(2); + } + + /** Same slab logic but ignoring the waive flag — used to show the struck-through "original" charge. */ + get scheduledCharge(): number { + const a = this.amount; + if (!a || a <= 0) return 0; + if (a <= 1000) return 2.5; + if (a <= 25000) return 5; + if (a <= 100000) return 15; + return 25; + } + + /** Sender account record matching the currently entered/picked account number. */ + get currentSender(): SenderAccount | null { + const v = (this.entryForm?.get('senderAccount')?.value || '').toString().trim().toLowerCase(); + if (!v) return null; + return this.senderAccounts.find((a) => a.accountNo.toLowerCase() === v) || null; + } + + /** True if amount + fee would exceed the picked sender's available balance. */ + get insufficientBalance(): boolean { + const s = this.currentSender; + if (!s || this.amount <= 0) return false; + return this.totalDebit > s.availableBalance; + } + + /** Tooltip for the header history button — kept as a getter so the template avoids string escaping. */ + get historyBtnTooltip(): string { + const s = this.currentSender; + return s ? `View today's IMPS transactions for ${s.holderName}` : 'Pick a sender account first'; + } + get beneVerified(): boolean { + return this.beneMatch !== null; + } + + /** + * True once the underlying A/C + IFSC have been resolved (either entered for P2A, + * or fetched from the IMPS / APBS switch for P2P / P2A·Aadhaar). BANL only operates + * on A/C + IFSC, so we always need both before BANL can run. + */ + get beneResolved(): boolean { + return !!(this.entryForm?.get('beneficiaryAccount')?.value && this.entryForm?.get('ifscCode')?.value); + } + get canPerformLookup(): boolean { + if (this.isP2A) { + const acct = this.entryForm?.get('beneficiaryAccount'); + return !!acct && acct.value && !acct.hasError('pattern'); + } + if (this.isP2P) { + const mmid = this.entryForm?.get('beneMmid'); + const mob = this.entryForm?.get('beneMobile'); + const mmidOk = !!mmid?.value && !mmid.hasError('pattern'); + const mobOk = !!mob?.value && !mob.hasError('pattern'); + // Either MMID or Mobile is enough — both is also fine. + return mmidOk || mobOk; + } + const aad = this.entryForm?.get('beneAadhaar'); + return !!aad && aad.value && !aad.hasError('pattern'); + } + get canSubmit(): boolean { + return this.entryForm?.valid === true && this.beneVerified; + } + + // ───────── Lookup ───────── + performLookup(): void { + const t = this.txnType; + this.beneMatch = null; + this.lookupError = null; + + if (!this.canPerformLookup) { + this.lookupError = + t === 'P2A' + ? 'Enter a valid Beneficiary Account before look-up.' + : t === 'P2P' + ? 'Enter a valid 7-digit MMID or 10-digit Mobile before look-up.' + : 'Enter a valid 12-digit Aadhaar before look-up.'; + return; + } + + this.lookupBusy = true; + setTimeout(() => { + this.lookupBusy = false; + let match: ImpsBeneficiaryMatch | undefined; + if (t === 'P2A') { + const acct = this.entryForm.get('beneficiaryAccount')?.value; + const ifsc = (this.entryForm.get('ifscCode')?.value || '').toUpperCase(); + match = this.impsDirectory.find((b) => b.beneficiaryAccount === acct && (!ifsc || b.ifscCode === ifsc)); + } else if (t === 'P2P') { + const mmid = this.entryForm.get('beneMmid')?.value; + const mob = this.entryForm.get('beneMobile')?.value; + // Match on whichever identifier the operator provided — either is sufficient. + match = this.impsDirectory.find((b) => (mmid && b.mmid === mmid) || (mob && b.mobile === mob)); + } else { + const aad = this.entryForm.get('beneAadhaar')?.value; + match = this.impsDirectory.find((b) => b.aadhaar === aad); + } + + if (!match) { + match = this.synthesise(); + } + this.applyMatch(match); + }, 600); + } + + private applyMatch(match: ImpsBeneficiaryMatch): void { + this.beneMatch = match; + // BANL only takes A/C + IFSC, so for MMID / Aadhaar transfers we resolve to those + // first (here via the mock directory / synth) and write them back into the form as + // readonly fields. The name is then the BANL response on the resolved A/C + IFSC. + const patch: Record = { + beneficiaryName: match.beneficiaryName + }; + if (match.beneficiaryAccount) { + patch['beneficiaryAccount'] = match.beneficiaryAccount; + } + if (match.ifscCode) { + patch['ifscCode'] = match.ifscCode; + } + // If the operator entered only one of MMID/Mobile, auto-fill the complementary one + // from the directory match — don't overwrite anything they typed. + if (this.isP2P) { + if (match.mmid && !this.entryForm.get('beneMmid')?.value) { + patch['beneMmid'] = match.mmid; + } + if (match.mobile && !this.entryForm.get('beneMobile')?.value) { + patch['beneMobile'] = match.mobile; + } + } + this.entryForm.patchValue(patch, { emitEvent: false }); + this.snack.open(`BANL verified — ${match.beneficiaryName}`, 'OK', { duration: 2500 }); + } + + private synthesise(): ImpsBeneficiaryMatch { + const t = this.txnType; + const acct = this.entryForm.get('beneficiaryAccount')?.value || ''; + const ifsc = (this.entryForm.get('ifscCode')?.value || '').toUpperCase(); + const mmid = this.entryForm.get('beneMmid')?.value || ''; + const mobile = this.entryForm.get('beneMobile')?.value || ''; + const aadhaar = this.entryForm.get('beneAadhaar')?.value || ''; + const sampleNames = [ + 'Rohan S. Mehta', + 'Anita Verma', + 'Bluestone Trading Co.', + 'Madhavi Singh', + 'Aarav Enterprises', + 'Neha Kapoor', + 'Spectra Components', + 'Indrani Sen', + 'Vijaya Traders', + 'Pinnacle Realty Pvt Ltd' + ]; + const seedStr = t === 'P2A' ? acct : t === 'P2P' ? mmid : aadhaar; + const seed = parseInt((seedStr || '0').toString().slice(-2), 10) || 0; + const name = sampleNames[seed % sampleNames.length]; + + // BANL only operates on A/C + IFSC, so for MMID / Aadhaar we synth the underlying + // account too — mocks the IMPS / APBS switch resolution that a real implementation + // would call before BANL. + const sampleIfscs = [ + 'HDFC0001234', + 'ICIC0000567', + 'SBIN0011223', + 'KKBK0001899', + 'UTIB0000456', + 'PUNB0123400', + 'BARB0VJVATA', + 'YESB0000123', + 'IDFB0040101', + 'CNRB0001890' + ]; + const synthIfsc = + t === 'P2A' ? ifsc || sampleIfscs[seed % sampleIfscs.length] : sampleIfscs[seed % sampleIfscs.length]; + const synthAcct = t === 'P2A' ? acct : '5010' + (seedStr || '0').toString().padStart(10, '0').slice(-10); + + return { + beneficiaryName: name, + beneficiaryAccount: synthAcct, + ifscCode: synthIfsc, + mmid: t === 'P2P' ? mmid : undefined, + mobile: t === 'P2P' ? mobile : undefined, + aadhaar: t === 'P2AADHAAR' ? aadhaar : undefined, + bankName: this.bankFromIfsc(synthIfsc) || 'Partner Bank', + branchName: t === 'P2A' ? 'Verified by BANL' : 'Resolved via IMPS / APBS · Verified by BANL', + status: 'ACTIVE' + }; + } + + private bankFromIfsc(ifsc: string): string { + const map: Record = { + HDFC: 'HDFC Bank', + ICIC: 'ICICI Bank', + SBIN: 'State Bank of India', + KKBK: 'Kotak Mahindra Bank', + AXIS: 'Axis Bank', + UTIB: 'Axis Bank', + PUNB: 'Punjab National Bank', + BARB: 'Bank of Baroda', + CNRB: 'Canara Bank', + YESB: 'YES Bank', + IDFB: 'IDFC FIRST Bank' + }; + return map[(ifsc || '').slice(0, 4).toUpperCase()] || 'Partner Bank'; + } + + clearLookup(): void { + this.beneMatch = null; + this.lookupError = null; + this.entryForm.get('beneficiaryName')?.setValue('', { emitEvent: false }); + } + + // ───────── Actions ───────── + submit(): void { + if (!this.beneVerified) { + this.lookupError = 'IMPS Switch verification is mandatory before initiating.'; + this.snack.open('Run IMPS Switch look-up to verify the beneficiary.', 'OK', { duration: 3500 }); + return; + } + if (this.entryForm.invalid) { + this.entryForm.markAllAsTouched(); + this.snack.open('Please fix the highlighted fields.', 'OK', { duration: 2800 }); + return; + } + const v = this.entryForm.getRawValue(); + const txnId = 'IMPS' + Date.now().toString().slice(-10); + this.snack.open(`IMPS initiated · ₹${Number(v.amount).toLocaleString('en-IN')} · Ref ${txnId}`, 'OK', { + duration: 4500 + }); + if (this.isDialog) { + this.dialogRef!.close({ submitted: true, payload: { ...v, txnId } }); + return; + } + this.resetForm(); + } + + resetForm(): void { + this.entryForm.reset({ + txnType: 'P2A', + valueDate: new Date(), + senderAccount: '', + amount: null, + beneficiaryAccount: '', + ifscCode: '', + beneMmid: '', + beneMobile: '', + beneAadhaar: '', + beneficiaryName: '', + purposeOfRemittance: '', + remarks: '', + waiveCharges: false + }); + this.clearLookup(); + this.refreshTypeValidators('P2A'); + } + + loadSample(): void { + this.entryForm.patchValue( + { + txnType: 'P2P', + valueDate: new Date(), + senderAccount: 'SB10029384', + amount: 5000, + beneMmid: '9234567', + beneMobile: '9821234567', + purposeOfRemittance: 'FAMILY_MAINTENANCE', + remarks: 'Monthly support' + }, + { emitEvent: false } + ); + this.refreshTypeValidators('P2P'); + this.beneMatch = null; + this.lookupError = null; + this.performLookup(); + } + + back(): void { + if (this.isDialog) { + this.dialogRef!.close({ submitted: false }); + return; + } + this.router.navigate([ + '/home' + ]); + } + + /** + * Opens the IMPS sender-history dialog showing today's outward IMPS transactions + * for the currently picked sender. No-op if no sender is selected. + */ + viewSenderHistory(): void { + const sender = this.currentSender; + if (!sender) { + this.snack.open('Pick a sender account first to view its history.', 'OK', { duration: 2500 }); + return; + } + const data: ImpsSenderHistoryDialogData = { + senderName: sender.holderName, + senderAccountNo: sender.accountNo, + txns: seedImpsTransactions() + }; + this.dialog.open(ImpsSenderHistoryDialogComponent, { + width: '960px', + maxWidth: '94vw', + maxHeight: '88vh', + autoFocus: false, + restoreFocus: true, + panelClass: 'shd-dialog', + data + }); + } +} diff --git a/src/app/digital/imps-report/imps-report.component.html b/src/app/digital/imps-report/imps-report.component.html new file mode 100644 index 000000000..0c47dbf9a --- /dev/null +++ b/src/app/digital/imps-report/imps-report.component.html @@ -0,0 +1,451 @@ + + +
+ +
+
+ +
+

IMPS — Report & Reconciliation

+
+ Digital · Immediate Payment Service · Showing + {{ totalCount | number }} transactions worth + ₹{{ totalAmount | number: '1.0-0' }} +
+
+
+
+ +
+
+ + +
+
+
+
+
{{ totalCount | number }}
+
Total Transactions
+
{{ p2aCount }} P2A · {{ p2pCount }} P2P
+
+
+
+
+
+
₹{{ totalAmount | number: '1.0-0' }}
+
Total Value
+
avg ₹{{ avgTicket | number: '1.0-0' }} / txn
+
+
+
+
+
+
{{ successCount | number }}
+
Successful
+
success {{ successRate | number: '1.1-1' }}%
+
+
+
+
+
+
{{ pendingCount | number }}
+
In-Flight
+
initiated / sent / pending
+
+
+
+
+
+
{{ failedCount | number }}
+
Failed / Reversed
+
check failure codes
+
+
+
+ + +
+ @for (t of tabs; track t.id) { + + } +
+ + + @if (activeTab === 'trends') { +
+
+ Daily Throughput (Last 7 Value Dates) + P2A + P2P +
+
+ @for (b of trendBuckets; track b.date) { +
+
+ {{ b.date | date: 'EEE' }} + {{ b.date | date: 'd MMM' }} +
+
+
+
+
+ P2A {{ b.p2a }} + P2P {{ b.p2p }} +
+
₹{{ b.amount | number: '1.0-0' }}
+
+ } +
+
+ } + + + @if (activeTab === 'bank') { +
+
+ Beneficiary Bank Distribution +
+
+ + + + + + + + + + + + + @for (r of bankRows; track r.bank) { + + + + + + + + + } + +
BankCountAmountSuccessPendingFailed
+ {{ r.bank }} + {{ r.count | number }} + ₹{{ r.amount | number: '1.0-0' }} + + {{ r.success }} + + {{ r.pending }} + + {{ r.failed }} +
+
+
+ } + + + @if (activeTab === 'date') { +
+
+ Value-Date Aggregation +
+
+ + + + + + + + + + + + @for (r of dateRows; track r.date) { + + + + + + + + } + +
Value DateCountP2AP2PAmount
{{ r.date | date: 'EEE, d MMM y' }}{{ r.count | number }} + {{ r.p2a }} + + {{ r.p2p }} + + ₹{{ r.amount | number: '1.0-0' }} +
+
+
+ } + + + @if (activeTab === 'pending') { +
+
+
+ Processing-State Funnel +
+
+
+ Initiated / Sent / Pending + {{ pendingCount | number }} +
+
+ Successful + {{ successCount | number }} +
+
+ Failed / Reversed + {{ failedCount | number }} +
+
+ + + +
+
+
+
+
+ Failure-Code Breakdown +
+ @if (failureCodeRows.length === 0) { +
No failed transactions in current window.
+ } @else { + + + + + + + + + + + @for (r of failureCodeRows; track r.code) { + + + + + + + } + +
CodeReasonCountAmount
+ {{ r.code }} + {{ r.reason }}{{ r.count }}₹{{ r.amount | number: '1.0-0' }}
+ } +
+
+ } + + + @if (activeTab === 'channel') { +
+
+ Channel Mix — Branch vs Mobile +
+
+ @for (r of channelRows; track r.channel) { +
+
+ +
+
+
+ {{ r.channel === 'MOBILE' ? 'Mobile / Self-Service' : 'Branch / Counter' }} +
+
{{ r.count | number }} txns
+
₹{{ r.amount | number: '1.0-0' }}
+
+ +
+ +
+
+ } +
+
+ } + + + @if (activeTab === 'user') { +
+
+ Maker / Operator Leaderboard +
+
+ + + + + + + + + + + @for (r of userRows; track r.user + r.branch) { + + + + + + + } + +
UserBranchTxnsAmount
+ {{ r.user }} + {{ r.branch }}{{ r.count | number }} + ₹{{ r.amount | number: '1.0-0' }} +
+
+
+ } + + +
+
+ Transactions +
+ + + + @if (search || typeFilter || stateFilter) { + + } +
+
+
+ + + + + + + + + + + + + + + + + + @for (t of filteredTxns; track t.rrn) { + + + + + + + + + + + + + + } + @if (filteredTxns.length === 0) { + + + + } + +
RRN / UTRRemitter A/CBeneficiaryMMID / IFSCMobileTypeAmountValue DatePurposeStateSent
+
{{ t.rrn }}
+
{{ t.utr }}
+
{{ t.remitterAcc }} +
{{ t.beneName }}
+
+ @if (t.txnType === 'P2A') { + {{ t.beneAcc }} · {{ t.beneBank }} + } @else if (t.txnType === 'P2P') { + MMID {{ t.beneMmid }} · {{ t.beneBank }} + } @else { + Aadhaar {{ t.beneAadhaar }} · {{ t.beneBank }} + } +
+
+ @if (t.txnType === 'P2A') { + {{ t.beneIfsc }} + } @else if (t.txnType === 'P2P') { + {{ t.beneMmid }} + } @else { + — + } + {{ t.beneMobile || '—' }} + + {{ t.txnType }} + + + ₹{{ t.amount | number: '1.2-2' }} + {{ t.valueDate | date: 'd MMM y' }} + {{ t.purposeCode }} + + + {{ t.state }} + + @if (t.failureCode) { +
{{ t.failureCode }} · {{ t.failureReason }}
+ } +
{{ t.sentTimestamp }}
No transactions match the current filters.
+
+
+ + + +
diff --git a/src/app/digital/imps-report/imps-report.component.ts b/src/app/digital/imps-report/imps-report.component.ts new file mode 100644 index 000000000..aeaf16e2f --- /dev/null +++ b/src/app/digital/imps-report/imps-report.component.ts @@ -0,0 +1,717 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatDialog } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { ImpsEntryComponent } from '../imps-entry/imps-entry.component'; + +/** Person-to-Account, Person-to-Person (MMID+Mobile), Person-to-Aadhaar. */ +export type ImpsTxnType = 'P2A' | 'P2P' | 'P2AADHAAR'; +export type ImpsState = 'INITIATED' | 'SENT' | 'SUCCESS' | 'PENDING' | 'FAILED' | 'REVERSED'; + +export interface ImpsTxn { + rrn: string; + utr: string; + remitterAcc: string; + remitterBranch: string; + remitterUser: string; + remitterChannel: 'BRANCH' | 'MOBILE'; + /** P2A populates these. */ + beneAcc?: string; + beneIfsc?: string; + /** P2P populates these. */ + beneMmid?: string; + beneMobile?: string; + /** P2AADHAAR populates this. */ + beneAadhaar?: string; + beneName: string; + beneBank: string; + txnType: ImpsTxnType; + amount: number; + valueDate: string; + purposeCode: string; + state: ImpsState; + failureCode?: string; + failureReason?: string; + sentTimestamp: string; +} + +type ReportTab = 'trends' | 'bank' | 'date' | 'pending' | 'channel' | 'user'; + +interface BankRow { + bank: string; + count: number; + amount: number; + success: number; + pending: number; + failed: number; +} + +interface DateRow { + date: string; + count: number; + amount: number; + p2a: number; + p2p: number; +} + +interface UserRow { + user: string; + count: number; + amount: number; + branch: string; +} + +interface ChannelRow { + channel: 'BRANCH' | 'MOBILE'; + count: number; + amount: number; + share: number; +} + +@Component({ + selector: 'mifosx-imps-report', + templateUrl: './imps-report.component.html', + styleUrls: ['../neft-rtgs-report/neft-rtgs-report.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class ImpsReportComponent implements OnInit { + private dialog = inject(MatDialog); + + activeTab: ReportTab = 'trends'; + + search = ''; + typeFilter: '' | ImpsTxnType = ''; + stateFilter: '' | ImpsState = ''; + + txns: ImpsTxn[] = []; + + readonly tabs: { id: ReportTab; label: string; icon: string }[] = [ + { id: 'trends', label: 'Trends', icon: 'chart-line' }, + { id: 'bank', label: 'Bank-wise', icon: 'university' }, + { id: 'date', label: 'Date-wise', icon: 'calendar-alt' }, + { id: 'pending', label: 'Success / Pending / Failed', icon: 'tasks' }, + { id: 'channel', label: 'Branch / Mobile', icon: 'mobile-alt' }, + { id: 'user', label: 'User-wise', icon: 'user-tie' } + ]; + + ngOnInit(): void { + this.txns = seedImpsTransactions(); + } + + // ───────────────────── KPIs ───────────────────── + get totalCount(): number { + return this.txns.length; + } + get totalAmount(): number { + return this.txns.reduce((s, t) => s + t.amount, 0); + } + get successCount(): number { + return this.txns.filter((t) => t.state === 'SUCCESS').length; + } + get pendingCount(): number { + return this.txns.filter((t) => [ + 'INITIATED', + 'SENT', + 'PENDING' + ].includes(t.state)).length; + } + get failedCount(): number { + return this.txns.filter((t) => t.state === 'FAILED' || t.state === 'REVERSED').length; + } + get p2aCount(): number { + return this.txns.filter((t) => t.txnType === 'P2A').length; + } + get p2pCount(): number { + return this.txns.filter((t) => t.txnType === 'P2P').length; + } + get successRate(): number { + if (this.totalCount === 0) return 0; + return (this.successCount / this.totalCount) * 100; + } + get avgTicket(): number { + if (this.totalCount === 0) return 0; + return this.totalAmount / this.totalCount; + } + + // ───────────────────── Filtered list ───────────────────── + get filteredTxns(): ImpsTxn[] { + const q = this.search.trim().toLowerCase(); + return this.txns.filter((t) => { + if (this.typeFilter && t.txnType !== this.typeFilter) return false; + if (this.stateFilter && t.state !== this.stateFilter) return false; + if (!q) return true; + return ( + t.rrn.toLowerCase().includes(q) || + t.utr.toLowerCase().includes(q) || + (t.beneAcc || '').toLowerCase().includes(q) || + (t.beneIfsc || '').toLowerCase().includes(q) || + (t.beneMmid || '').toLowerCase().includes(q) || + (t.beneMobile || '').toLowerCase().includes(q) || + t.beneName.toLowerCase().includes(q) || + t.remitterAcc.toLowerCase().includes(q) + ); + }); + } + + // ───────────────────── Aggregations ───────────────────── + get bankRows(): BankRow[] { + const map = new Map(); + for (const t of this.txns) { + const key = t.beneBank; + if (!map.has(key)) { + map.set(key, { bank: key, count: 0, amount: 0, success: 0, pending: 0, failed: 0 }); + } + const r = map.get(key)!; + r.count++; + r.amount += t.amount; + if (t.state === 'SUCCESS') r.success++; + else if (t.state === 'FAILED' || t.state === 'REVERSED') r.failed++; + else r.pending++; + } + return Array.from(map.values()).sort((a, b) => b.amount - a.amount); + } + + get dateRows(): DateRow[] { + const map = new Map(); + for (const t of this.txns) { + if (!map.has(t.valueDate)) { + map.set(t.valueDate, { date: t.valueDate, count: 0, amount: 0, p2a: 0, p2p: 0 }); + } + const r = map.get(t.valueDate)!; + r.count++; + r.amount += t.amount; + if (t.txnType === 'P2A') r.p2a++; + else if (t.txnType === 'P2P') r.p2p++; + } + return Array.from(map.values()).sort((a, b) => (a.date < b.date ? 1 : -1)); + } + + get userRows(): UserRow[] { + const map = new Map(); + for (const t of this.txns) { + const key = `${t.remitterUser}|${t.remitterBranch}`; + if (!map.has(key)) { + map.set(key, { user: t.remitterUser, branch: t.remitterBranch, count: 0, amount: 0 }); + } + const r = map.get(key)!; + r.count++; + r.amount += t.amount; + } + return Array.from(map.values()).sort((a, b) => b.amount - a.amount); + } + + get channelRows(): ChannelRow[] { + const total = this.totalAmount || 1; + const map = new Map<'BRANCH' | 'MOBILE', ChannelRow>(); + for (const t of this.txns) { + if (!map.has(t.remitterChannel)) { + map.set(t.remitterChannel, { channel: t.remitterChannel, count: 0, amount: 0, share: 0 }); + } + const r = map.get(t.remitterChannel)!; + r.count++; + r.amount += t.amount; + } + map.forEach((r) => (r.share = (r.amount / total) * 100)); + return Array.from(map.values()); + } + + get trendBuckets(): DateRow[] { + return this.dateRows.slice(0, 7).reverse(); + } + + get trendMaxAmount(): number { + const buckets = this.trendBuckets; + if (!buckets.length) return 1; + return Math.max(...buckets.map((b) => b.amount)); + } + + get failureCodeRows(): { code: string; reason: string; count: number; amount: number }[] { + const map = new Map(); + for (const t of this.txns) { + if (!(t.state === 'FAILED' || t.state === 'REVERSED') || !t.failureCode) continue; + if (!map.has(t.failureCode)) { + map.set(t.failureCode, { + code: t.failureCode, + reason: t.failureReason || '', + count: 0, + amount: 0 + }); + } + const r = map.get(t.failureCode)!; + r.count++; + r.amount += t.amount; + } + return Array.from(map.values()).sort((a, b) => b.count - a.count); + } + + // ───────────────────── Actions ───────────────────── + showTab(tab: ReportTab): void { + this.activeTab = tab; + } + + openInitiateDialog(): void { + const ref = this.dialog.open(ImpsEntryComponent, { + width: '880px', + maxWidth: '94vw', + maxHeight: '88vh', + disableClose: true, + autoFocus: false, + restoreFocus: true, + panelClass: 'nr-entry-dialog', + backdropClass: 'nr-entry-backdrop' + }); + ref.afterClosed().subscribe((result) => { + if (result && result.submitted && result.payload) { + this.txns = [ + this.toTxn(result.payload), + ...this.txns + ]; + } + }); + } + + exportCsv(): void { + const headers = [ + 'RRN', + 'UTR', + 'Remitter Acc', + 'Bene Acc / MMID / Aadhaar', + 'Bene IFSC / Mobile', + 'Bene Name', + 'Txn Type', + 'Amount', + 'Value Date', + 'Purpose Code', + 'State', + 'Failure Code', + 'Sent Timestamp' + ]; + const rows: string[][] = [headers]; + for (const t of this.filteredTxns) { + rows.push([ + t.rrn, + t.utr, + t.remitterAcc, + t.beneAcc || t.beneMmid || t.beneAadhaar || '', + t.beneIfsc || t.beneMobile || '', + t.beneName, + t.txnType, + t.amount.toFixed(2), + t.valueDate, + t.purposeCode, + t.state, + t.failureCode || '', + t.sentTimestamp + ]); + } + const escape = (v: string): string => { + const s = (v || '').replace(/"/g, '""'); + return /[,"\n]/.test(s) ? `"${s}"` : s; + }; + const csv = rows.map((r) => r.map(escape).join(',')).join('\n') + '\n'; + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `IMPS_Report_${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + } + + clearFilters(): void { + this.search = ''; + this.typeFilter = ''; + this.stateFilter = ''; + } + + // ───────────────────── Helpers ───────────────────── + stateColor(state: ImpsState): string { + switch (state) { + case 'SUCCESS': + return 'green'; + case 'FAILED': + case 'REVERSED': + return 'red'; + case 'SENT': + return 'blue'; + case 'INITIATED': + return 'teal'; + case 'PENDING': + return 'amber'; + } + } + + trendBarHeight(amount: number): number { + return Math.max(6, Math.round((amount / this.trendMaxAmount) * 100)); + } + + private toTxn(p: any): ImpsTxn { + const valueDate = p.valueDate + ? new Date(p.valueDate).toISOString().slice(0, 10) + : new Date().toISOString().slice(0, 10); + const ts = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return { + rrn: 'IMPS' + Date.now().toString().slice(-10), + utr: 'UTR' + Date.now().toString().slice(-11), + remitterAcc: p.senderAccount || '', + remitterBranch: 'Main Branch', + remitterUser: 'Current User', + remitterChannel: 'BRANCH', + beneAcc: p.beneficiaryAccount, + beneIfsc: (p.ifscCode || '').toUpperCase() || undefined, + beneMmid: p.beneMmid, + beneMobile: p.beneMobile, + beneAadhaar: p.beneAadhaar, + beneName: p.beneficiaryName || '', + beneBank: this.bankFromIfsc(p.ifscCode || ''), + txnType: p.txnType || 'P2A', + amount: Number(p.amount) || 0, + valueDate, + purposeCode: this.purposeCodeFor(p.purposeOfRemittance), + state: 'INITIATED', + sentTimestamp: + ts.getFullYear() + + '-' + + pad(ts.getMonth() + 1) + + '-' + + pad(ts.getDate()) + + ' ' + + pad(ts.getHours()) + + ':' + + pad(ts.getMinutes()) + + ':' + + pad(ts.getSeconds()) + }; + } + + private bankFromIfsc(ifsc: string): string { + const code = ifsc.slice(0, 4).toUpperCase(); + const map: Record = { + HDFC: 'HDFC Bank', + ICIC: 'ICICI Bank', + SBIN: 'State Bank of India', + KKBK: 'Kotak Mahindra Bank', + AXIS: 'Axis Bank', + UTIB: 'Axis Bank', + PUNB: 'Punjab National Bank', + BARB: 'Bank of Baroda', + CNRB: 'Canara Bank', + YESB: 'YES Bank', + IDFB: 'IDFC FIRST Bank' + }; + return map[code] || code + ' Bank'; + } + + private purposeCodeFor(p: string): string { + const map: Record = { + CAPITAL_TRANSFER: 'P0102', + FAMILY_MAINTENANCE: 'P1301', + TRADE_SETTLEMENT: 'P0801', + SALARY_PAYMENT: 'P1006', + LOAN_REPAYMENT: 'P0103', + TAX_PAYMENT: 'P1099', + VENDOR_PAYMENT: 'P0802', + INVESTMENT: 'P0202', + GIFT: 'P1302', + OTHER: 'P0099' + }; + return map[p] || 'P0099'; + } + + // ───────────────────── Mock seed data ───────────────────── + /** @deprecated Use the exported `seedImpsTransactions()` instead. */ + private seedTransactions(): ImpsTxn[] { + return seedImpsTransactions(); + } +} + +// ───────────────────── Shared mock seed (top-level export) ───────────────────── +/** + * Mock IMPS history. Exported so the entry form (and any per-sender history dialogs) + * can render from the same dataset. Replace with an API call when the back-end is wired. + */ +export function seedImpsTransactions(): ImpsTxn[] { + const today = new Date(); + const dt = (offset: number, h = 10, m = 0): string => { + const d = new Date(today); + d.setDate(d.getDate() - offset); + const pad = (n: number) => String(n).padStart(2, '0'); + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(h) + ':' + pad(m) + ':00'; + }; + const vd = (offset: number): string => dt(offset).slice(0, 10); + + return [ + { + rrn: 'IMPS2608517721', + utr: 'UTRKKBK52026081', + remitterAcc: '50200012345678', + remitterBranch: 'Mumbai — BKC', + remitterUser: 'Anita Desai', + remitterChannel: 'MOBILE', + beneMmid: '9234567', + beneMobile: '9821234567', + beneName: 'Rohan S. Mehta', + beneBank: 'HDFC Bank', + txnType: 'P2P', + amount: 12500, + valueDate: vd(0), + purposeCode: 'P1301', + state: 'SUCCESS', + sentTimestamp: dt(0, 14, 5) + }, + { + rrn: 'IMPS2608517722', + utr: 'UTRHDFC2608712', + remitterAcc: '50200012345678', + remitterBranch: 'Mumbai — BKC', + remitterUser: 'Anita Desai', + remitterChannel: 'BRANCH', + beneAcc: '345678901234', + beneIfsc: 'ICIC0000567', + beneName: 'Bluestone Trading Co.', + beneBank: 'ICICI Bank', + txnType: 'P2A', + amount: 78500, + valueDate: vd(0), + purposeCode: 'P0802', + state: 'SENT', + sentTimestamp: dt(0, 14, 6) + }, + { + rrn: 'IMPS2608517723', + utr: 'UTRSBIN2608902', + remitterAcc: '50200099887766', + remitterBranch: 'Bengaluru — MG Road', + remitterUser: 'Karthik Rao', + remitterChannel: 'MOBILE', + beneMmid: '9128831', + beneMobile: '9845112233', + beneName: 'Anita Verma', + beneBank: 'State Bank of India', + txnType: 'P2P', + amount: 4500, + valueDate: vd(1), + purposeCode: 'P1301', + state: 'SUCCESS', + sentTimestamp: dt(1, 11, 22) + }, + { + rrn: 'IMPS2608517724', + utr: 'UTRAXIS2608811', + remitterAcc: '50200099887766', + remitterBranch: 'Bengaluru — MG Road', + remitterUser: 'Karthik Rao', + remitterChannel: 'MOBILE', + beneAcc: '6789012345', + beneIfsc: 'UTIB0000456', + beneName: 'Rakesh Industries', + beneBank: 'Axis Bank', + txnType: 'P2A', + amount: 195000, + valueDate: vd(1), + purposeCode: 'P0801', + state: 'FAILED', + failureCode: 'E03', + failureReason: 'Beneficiary Bank Not Available', + sentTimestamp: dt(1, 11, 25) + }, + { + rrn: 'IMPS2608517725', + utr: 'UTRPUNB2608619', + remitterAcc: '50100221122334', + remitterBranch: 'Delhi — Connaught', + remitterUser: 'Sushma Iyer', + remitterChannel: 'BRANCH', + beneAcc: '50100998877665', + beneIfsc: 'PUNB0123400', + beneName: 'Madhavi Singh', + beneBank: 'Punjab National Bank', + txnType: 'P2A', + amount: 42000, + valueDate: vd(2), + purposeCode: 'P1006', + state: 'SUCCESS', + sentTimestamp: dt(2, 9, 35) + }, + { + rrn: 'IMPS2608517726', + utr: 'UTRYESB2608520', + remitterAcc: '50100221122334', + remitterBranch: 'Delhi — Connaught', + remitterUser: 'Sushma Iyer', + remitterChannel: 'BRANCH', + beneMmid: '9001122', + beneMobile: '9818877665', + beneName: 'Sigma Constructions', + beneBank: 'YES Bank', + txnType: 'P2P', + amount: 18500, + valueDate: vd(2), + purposeCode: 'P0102', + state: 'PENDING', + sentTimestamp: dt(2, 9, 38) + }, + { + rrn: 'IMPS2608517727', + utr: 'UTRCNRB2608417', + remitterAcc: '50100887766554', + remitterBranch: 'Chennai — T. Nagar', + remitterUser: 'Ramesh Krishnan', + remitterChannel: 'MOBILE', + beneMmid: '9889911', + beneMobile: '9840012345', + beneName: 'Padma Rao', + beneBank: 'Canara Bank', + txnType: 'P2P', + amount: 2800, + valueDate: vd(3), + purposeCode: 'P1302', + state: 'SUCCESS', + sentTimestamp: dt(3, 16, 15) + }, + { + rrn: 'IMPS2608517728', + utr: 'UTRBARB2608318', + remitterAcc: '50100887766554', + remitterBranch: 'Chennai — T. Nagar', + remitterUser: 'Ramesh Krishnan', + remitterChannel: 'BRANCH', + beneAcc: '50100123456987', + beneIfsc: 'BARB0VJVATA', + beneName: 'Vijaya Traders', + beneBank: 'Bank of Baroda', + txnType: 'P2A', + amount: 125000, + valueDate: vd(3), + purposeCode: 'P0802', + state: 'INITIATED', + sentTimestamp: dt(3, 16, 20) + }, + { + rrn: 'IMPS2608517729', + utr: 'UTRIDFB2608215', + remitterAcc: '50200445566778', + remitterBranch: 'Pune — Koregaon Park', + remitterUser: 'Vikram Joshi', + remitterChannel: 'BRANCH', + beneAadhaar: 'XXXX-XXXX-7891', + beneName: 'Pinnacle Realty Pvt Ltd', + beneBank: 'IDFC FIRST Bank', + txnType: 'P2AADHAAR', + amount: 145000, + valueDate: vd(4), + purposeCode: 'P0202', + state: 'SUCCESS', + sentTimestamp: dt(4, 12, 45) + }, + { + rrn: 'IMPS2608517730', + utr: 'UTRHDFC2608116', + remitterAcc: '50200445566778', + remitterBranch: 'Pune — Koregaon Park', + remitterUser: 'Vikram Joshi', + remitterChannel: 'MOBILE', + beneMmid: '9650432', + beneMobile: '9890065432', + beneName: 'Neha Kapoor', + beneBank: 'HDFC Bank', + txnType: 'P2P', + amount: 6500, + valueDate: vd(4), + purposeCode: 'P1301', + state: 'SUCCESS', + sentTimestamp: dt(4, 12, 50) + }, + { + rrn: 'IMPS2608517731', + utr: 'UTRKKBK2608013', + remitterAcc: '50200778899001', + remitterBranch: 'Hyderabad — Banjara', + remitterUser: 'Lakshmi Naidu', + remitterChannel: 'BRANCH', + beneAcc: '12345678900011', + beneIfsc: 'KKBK0001899', + beneName: 'Aarav Enterprises', + beneBank: 'Kotak Mahindra Bank', + txnType: 'P2A', + amount: 67000, + valueDate: vd(5), + purposeCode: 'P0802', + state: 'REVERSED', + failureCode: 'E11', + failureReason: 'Beneficiary Account Closed', + sentTimestamp: dt(5, 10, 5) + }, + { + rrn: 'IMPS2608517732', + utr: 'UTRSBIN2607914', + remitterAcc: '50200778899001', + remitterBranch: 'Hyderabad — Banjara', + remitterUser: 'Lakshmi Naidu', + remitterChannel: 'BRANCH', + beneMmid: '9445566', + beneMobile: '9849988776', + beneName: 'Spectra Components', + beneBank: 'State Bank of India', + txnType: 'P2P', + amount: 9800, + valueDate: vd(5), + purposeCode: 'P0801', + state: 'SUCCESS', + sentTimestamp: dt(5, 10, 10) + }, + { + rrn: 'IMPS2608517733', + utr: 'UTRICIC2607812', + remitterAcc: '50100334455667', + remitterBranch: 'Kolkata — Park Street', + remitterUser: 'Arjun Banerjee', + remitterChannel: 'MOBILE', + beneAcc: '88990011223344', + beneIfsc: 'ICIC0001234', + beneName: 'Eastern Logistics', + beneBank: 'ICICI Bank', + txnType: 'P2A', + amount: 88000, + valueDate: vd(6), + purposeCode: 'P0802', + state: 'SUCCESS', + sentTimestamp: dt(6, 14, 30) + }, + { + rrn: 'IMPS2608517734', + utr: 'UTRAXIS2607711', + remitterAcc: '50100334455667', + remitterBranch: 'Kolkata — Park Street', + remitterUser: 'Arjun Banerjee', + remitterChannel: 'BRANCH', + beneMmid: '9009988', + beneMobile: '9831100225', + beneName: 'Indrani Sen', + beneBank: 'Axis Bank', + txnType: 'P2P', + amount: 2200, + valueDate: vd(6), + purposeCode: 'P1302', + state: 'SUCCESS', + sentTimestamp: dt(6, 14, 32) + } + ]; +} diff --git a/src/app/digital/imps-sender-history-dialog/imps-sender-history-dialog.component.html b/src/app/digital/imps-sender-history-dialog/imps-sender-history-dialog.component.html new file mode 100644 index 000000000..ac30e2016 --- /dev/null +++ b/src/app/digital/imps-sender-history-dialog/imps-sender-history-dialog.component.html @@ -0,0 +1,137 @@ + + +
+ +
+
+ +
+

{{ data.senderName }} · IMPS · Today

+
+ @if (data.senderAccountNo) { + {{ data.senderAccountNo }} · + } + {{ totalCount | number }} txns today · + ₹{{ totalAmount | number: '1.0-0' }} +
+
+
+ +
+ + +
+
+
{{ totalCount | number }}
+
Total
+
+
+
₹{{ totalAmount | number: '1.0-0' }}
+
Value
+
+
+
{{ successCount | number }}
+
Success ({{ successRate | number: '1.1-1' }}%)
+
+
+
{{ pendingCount | number }}
+
In-Flight
+
+
+
{{ failedCount | number }}
+
Failed / Reversed
+
+
+ + +
+ @if (filteredTxns.length === 0) { +
+ +
+ No IMPS transactions today for this sender. +

This view shows only today's outward transfers. Older history isn't included here.

+
+
+ } @else { +
+ + + + + + + + + + + + + + + @for (t of filteredTxns; track t.rrn) { + + + + + + + + + + + } + +
RRN / UTRBeneficiaryMMID / IFSCMobileTypeAmountStateSent
+
{{ t.rrn }}
+
{{ t.utr }}
+
+
{{ t.beneName }}
+
+ @if (t.txnType === 'P2A') { + {{ t.beneAcc }} · {{ t.beneBank }} + } @else if (t.txnType === 'P2P') { + MMID {{ t.beneMmid }} · {{ t.beneBank }} + } @else { + Aadhaar {{ t.beneAadhaar }} · {{ t.beneBank }} + } +
+
+ @if (t.txnType === 'P2A') { + {{ t.beneIfsc }} + } @else if (t.txnType === 'P2P') { + {{ t.beneMmid }} + } @else { + — + } + {{ t.beneMobile || '—' }} + + {{ t.txnType }} + + + ₹{{ t.amount | number: '1.2-2' }} + + + {{ t.state }} + + @if (t.failureCode) { +
{{ t.failureCode }} · {{ t.failureReason }}
+ } +
{{ t.sentTimestamp }}
+
+ } +
+
diff --git a/src/app/digital/imps-sender-history-dialog/imps-sender-history-dialog.component.ts b/src/app/digital/imps-sender-history-dialog/imps-sender-history-dialog.component.ts new file mode 100644 index 000000000..ddc325d8f --- /dev/null +++ b/src/app/digital/imps-sender-history-dialog/imps-sender-history-dialog.component.ts @@ -0,0 +1,108 @@ +/** + * 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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { ImpsTxn, ImpsState } from '../imps-report/imps-report.component'; + +export interface ImpsSenderHistoryDialogData { + senderName: string; + senderAccountNo: string; + txns: ImpsTxn[]; +} + +@Component({ + selector: 'mifosx-imps-sender-history-dialog', + templateUrl: './imps-sender-history-dialog.component.html', + // Reuse the styling from the NEFT/RTGS history dialog — same look & feel. + styleUrls: ['../sender-history-dialog/sender-history-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FaIconComponent, + MatTooltip + ] +}) +export class ImpsSenderHistoryDialogComponent { + private dialogRef = inject(MatDialogRef); + data: ImpsSenderHistoryDialogData = inject(MAT_DIALOG_DATA); + + /** Today's date as YYYY-MM-DD — matches the `valueDate` shape in the txn seed. */ + private get todayKey(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + } + + /** + * Today's IMPS rows for this sender — matched by exact A/C number when available, + * otherwise by case-insensitive holder name. Older value dates are excluded; this + * view is intentionally a today-only snapshot. + */ + get filteredTxns(): ImpsTxn[] { + const acc = (this.data.senderAccountNo || '').trim().toLowerCase(); + const name = (this.data.senderName || '').trim().toLowerCase(); + const today = this.todayKey; + return (this.data.txns || []).filter((t) => { + if (t.valueDate !== today) return false; + if (acc && t.remitterAcc.toLowerCase() === acc) return true; + if (name && t.remitterUser.toLowerCase() === name) return true; + return false; + }); + } + + // ─── KPI getters ─── + get totalCount(): number { + return this.filteredTxns.length; + } + get totalAmount(): number { + return this.filteredTxns.reduce((s, t) => s + t.amount, 0); + } + get successCount(): number { + return this.filteredTxns.filter((t) => t.state === 'SUCCESS').length; + } + get pendingCount(): number { + return this.filteredTxns.filter((t) => [ + 'INITIATED', + 'SENT', + 'PENDING' + ].includes(t.state)).length; + } + get failedCount(): number { + return this.filteredTxns.filter((t) => t.state === 'FAILED' || t.state === 'REVERSED').length; + } + get successRate(): number { + if (this.totalCount === 0) return 0; + return (this.successCount / this.totalCount) * 100; + } + + stateColor(state: ImpsState): string { + switch (state) { + case 'SUCCESS': + return 'green'; + case 'FAILED': + case 'REVERSED': + return 'red'; + case 'SENT': + return 'blue'; + case 'INITIATED': + return 'teal'; + case 'PENDING': + return 'amber'; + } + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.html b/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.html index e66db34a2..6b4998ad5 100644 --- a/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.html +++ b/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.html @@ -23,9 +23,26 @@
- RTGS Floor - ₹{{ RTGS_MIN | number: '1.0-0' }} + @if (isRtgs) { + RTGS Floor + ≥ ₹{{ RTGS_MIN | number: '1.0-0' }} + } @else { + NEFT Cap + ≤ ₹{{ NEFT_MAX | number: '1.0-0' }} + }
+ @if (isDialog) {
@@ -202,7 +276,6 @@ } @else { } - Fetched from the IMPS Switch via account no. + IFSC — cannot be typed manually.
@@ -270,6 +343,50 @@
+ +
+
+ + Charges & GST + {{ isRtgs ? 'RTGS' : 'NEFT' }} schedule · 18% GST on charge + +
+ +
+
+ Transfer Amount + ₹{{ amount || 0 | number: '1.2-2' }} +
+
+ + Service Charge + ({{ isRtgs ? 'RTGS' : 'NEFT' }}) + + + @if (chargesWaived) { + ₹{{ scheduledCharge | number: '1.2-2' }} + Waived + } @else { + ₹{{ serviceCharge | number: '1.2-2' }} + } + +
+
+ GST (18% on charge) + ₹{{ gstOnCharge | number: '1.2-2' }} +
+
+ Total Debit + + ₹{{ totalDebit | number: '1.2-2' }} + +
+
+
+
@for (b of trendBuckets; track b.date) { -
-
-
- ₹{{ b.amount | number: '1.0-0' }} -
+
+
+ {{ b.date | date: 'EEE' }} + {{ b.date | date: 'd MMM' }}
-
-
{{ b.date | date: 'd MMM' }}
-
- N {{ b.neft }} - R {{ b.rtgs }} -
+
+
+
+ NEFT {{ b.neft }} + RTGS {{ b.rtgs }} +
+
₹{{ b.amount | number: '1.0-0' }}
}
diff --git a/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.scss b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.scss index c5b49f241..b93731f73 100644 --- a/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.scss +++ b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.scss @@ -335,68 +335,106 @@ } /* ───────── TREND CHART ───────── */ + +/* ───────── Daily-throughput leaderboard (horizontal-bar list) ───────── */ .nrr-trend { display: flex; - align-items: flex-end; - gap: 12px; - min-height: 220px; - padding: 10px 4px; + flex-direction: column; + gap: 4px; + padding: 6px 4px 2px; } -.nrr-trend-col { - flex: 1; - display: flex; - flex-direction: column; +.nrr-trend-row { + display: grid; + grid-template-columns: 96px 1fr auto auto; + column-gap: 14px; align-items: center; - gap: 8px; - min-width: 60px; + padding: 8px; + border-radius: 8px; + transition: background 0.15s ease; + + &:hover { + background: var(--r-g50); + } } -.nrr-trend-bar-wrap { - width: 100%; - height: 160px; - display: flex; - align-items: flex-end; - justify-content: center; -} - -.nrr-trend-bar { - width: 75%; - min-height: 6px; - background: linear-gradient(180deg, var(--r-blue) 0%, var(--r-purple) 100%); - border-radius: 6px 6px 0 0; - position: relative; - transition: height 0.3s ease; -} - -.nrr-trend-amt { - position: absolute; - top: -22px; - left: 50%; - transform: translateX(-50%); - font-size: 11px; - font-weight: 700; - color: var(--r-g700); - white-space: nowrap; -} - -.nrr-trend-meta { - text-align: center; +.nrr-trend-row-date { display: flex; flex-direction: column; + line-height: 1.15; +} + +.nrr-trend-row-day { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--r-g500); +} + +.nrr-trend-row-md { + font-size: 13px; + font-weight: 700; + color: var(--r-g900); +} + +.nrr-trend-row-bar-wrap { + position: relative; + height: 10px; + background: var(--r-g100); + border-radius: 999px; + overflow: hidden; + min-width: 80px; +} + +.nrr-trend-row-bar { + height: 100%; + background: linear-gradient(90deg, var(--r-blue) 0%, var(--r-purple) 100%); + border-radius: 999px; + transition: width 0.3s ease; + min-width: 6px; +} + +.nrr-trend-row-pills { + display: inline-flex; gap: 4px; } -.nrr-trend-date { - font-size: 11px; - font-weight: 600; - color: var(--r-g700); +.nrr-trend-row-amt { + font-size: 13.5px; + font-weight: 700; + color: var(--r-g900); + font-variant-numeric: tabular-nums; + text-align: right; + min-width: 92px; } -.nrr-trend-split { - display: flex; - gap: 4px; - justify-content: center; +@media (width <= 640px) { + .nrr-trend-row { + grid-template-columns: 70px 1fr; + grid-template-areas: + 'date amt' + 'bar bar' + 'pills pills'; + gap: 6px 8px; + } + + .nrr-trend-row-date { + grid-area: date; + } + + .nrr-trend-row-amt { + grid-area: amt; + min-width: 0; + } + + .nrr-trend-row-bar-wrap { + grid-area: bar; + } + + .nrr-trend-row-pills { + grid-area: pills; + } } /* ───────── TABLE ───────── */ diff --git a/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.ts b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.ts index 4f2a6f78b..7ba6522c9 100644 --- a/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.ts +++ b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.ts @@ -110,7 +110,7 @@ export class NeftRtgsReportComponent implements OnInit { ]; ngOnInit(): void { - this.txns = this.seedTransactions(); + this.txns = seedNeftRtgsTransactions(); } // ───────────────────── KPIs ───────────────────── @@ -455,293 +455,302 @@ export class NeftRtgsReportComponent implements OnInit { } // ───────────────────── Mock seed data ───────────────────── + /** @deprecated Use the exported `seedNeftRtgsTransactions()` instead. */ private seedTransactions(): NeftRtgsTxn[] { - const today = new Date(); - const dt = (offset: number, h = 10, m = 0): string => { - const d = new Date(today); - d.setDate(d.getDate() - offset); - const pad = (n: number) => String(n).padStart(2, '0'); - return ( - d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(h) + ':' + pad(m) + ':00' - ); - }; - const vd = (offset: number): string => dt(offset).slice(0, 10); - - return [ - { - batchId: 'H2H-20260326-01', - instructionId: 'ERP77889922', - utrNumber: 'KKBKR5202608511', - remitterAcc: '50200012345678', - remitterBranch: 'Mumbai — BKC', - remitterUser: 'Anita Desai', - remitterChannel: 'BRANCH', - beneAcc: '91000222333444', - beneIfsc: 'HDFC0001234', - beneName: 'Global Logistics Ltd', - beneBank: 'HDFC Bank', - paymentType: 'RTGS', - amount: 550000, - valueDate: vd(0), - purposeCode: 'P0102', - leiCode: '549300ABCDEF1234', - processingState: 'SETTLED', - sentTimestamp: dt(0, 14, 5) - }, - { - batchId: 'H2H-20260326-01', - instructionId: 'ERP77889923', - utrNumber: 'HDFCR5202608712', - remitterAcc: '50200012345678', - remitterBranch: 'Mumbai — BKC', - remitterUser: 'Anita Desai', - remitterChannel: 'BRANCH', - beneAcc: '345678901234', - beneIfsc: 'ICIC0000567', - beneName: 'Bluestone Trading Co.', - beneBank: 'ICICI Bank', - paymentType: 'NEFT', - amount: 78500, - valueDate: vd(0), - purposeCode: 'P0802', - processingState: 'SENT', - sentTimestamp: dt(0, 14, 6) - }, - { - batchId: 'H2H-20260325-04', - instructionId: 'ERP77889811', - utrNumber: 'SBINR5202608902', - remitterAcc: '50200099887766', - remitterBranch: 'Bengaluru — MG Road', - remitterUser: 'Karthik Rao', - remitterChannel: 'MOBILE', - beneAcc: '912345678901', - beneIfsc: 'SBIN0011223', - beneName: 'Anita Verma', - beneBank: 'State Bank of India', - paymentType: 'NEFT', - amount: 15000, - valueDate: vd(1), - purposeCode: 'P1301', - processingState: 'SETTLED', - sentTimestamp: dt(1, 11, 22) - }, - { - batchId: 'H2H-20260325-04', - instructionId: 'ERP77889812', - utrNumber: 'AXISR5202608811', - remitterAcc: '50200099887766', - remitterBranch: 'Bengaluru — MG Road', - remitterUser: 'Karthik Rao', - remitterChannel: 'MOBILE', - beneAcc: '6789012345', - beneIfsc: 'UTIB0000456', - beneName: 'Rakesh Industries', - beneBank: 'Axis Bank', - paymentType: 'RTGS', - amount: 1250000, - valueDate: vd(1), - purposeCode: 'P0801', - leiCode: '549300XYZAB7890', - processingState: 'RETURNED', - returnCode: 'R01', - returnReason: 'Insufficient Funds', - sentTimestamp: dt(1, 11, 25) - }, - { - batchId: 'H2H-20260324-02', - instructionId: 'ERP77889701', - utrNumber: 'PUNBR5202608619', - remitterAcc: '50100221122334', - remitterBranch: 'Delhi — Connaught', - remitterUser: 'Sushma Iyer', - remitterChannel: 'BRANCH', - beneAcc: '50100998877665', - beneIfsc: 'PUNB0123400', - beneName: 'Madhavi Singh', - beneBank: 'Punjab National Bank', - paymentType: 'NEFT', - amount: 42000, - valueDate: vd(2), - purposeCode: 'P1006', - processingState: 'SETTLED', - sentTimestamp: dt(2, 9, 35) - }, - { - batchId: 'H2H-20260324-02', - instructionId: 'ERP77889702', - utrNumber: 'YESBR5202608520', - remitterAcc: '50100221122334', - remitterBranch: 'Delhi — Connaught', - remitterUser: 'Sushma Iyer', - remitterChannel: 'BRANCH', - beneAcc: '11223344556677', - beneIfsc: 'YESB0000123', - beneName: 'Sigma Constructions', - beneBank: 'YES Bank', - paymentType: 'RTGS', - amount: 825000, - valueDate: vd(2), - purposeCode: 'P0102', - processingState: 'PENDING', - sentTimestamp: dt(2, 9, 38) - }, - { - batchId: 'H2H-20260323-07', - instructionId: 'ERP77889601', - utrNumber: 'CNRBR5202608417', - remitterAcc: '50100887766554', - remitterBranch: 'Chennai — T. Nagar', - remitterUser: 'Ramesh Krishnan', - remitterChannel: 'MOBILE', - beneAcc: '99887766554433', - beneIfsc: 'CNRB0001890', - beneName: 'Padma Rao', - beneBank: 'Canara Bank', - paymentType: 'NEFT', - amount: 9800, - valueDate: vd(3), - purposeCode: 'P1302', - processingState: 'SETTLED', - sentTimestamp: dt(3, 16, 15) - }, - { - batchId: 'H2H-20260323-07', - instructionId: 'ERP77889602', - utrNumber: 'BARBR5202608318', - remitterAcc: '50100887766554', - remitterBranch: 'Chennai — T. Nagar', - remitterUser: 'Ramesh Krishnan', - remitterChannel: 'BRANCH', - beneAcc: '50100123456987', - beneIfsc: 'BARB0VJVATA', - beneName: 'Vijaya Traders', - beneBank: 'Bank of Baroda', - paymentType: 'NEFT', - amount: 125000, - valueDate: vd(3), - purposeCode: 'P0802', - processingState: 'VALIDATED', - sentTimestamp: dt(3, 16, 20) - }, - { - batchId: 'H2H-20260322-03', - instructionId: 'ERP77889501', - utrNumber: 'IDFBR5202608215', - remitterAcc: '50200445566778', - remitterBranch: 'Pune — Koregaon Park', - remitterUser: 'Vikram Joshi', - remitterChannel: 'BRANCH', - beneAcc: '70010023456789', - beneIfsc: 'IDFB0040101', - beneName: 'Pinnacle Realty Pvt Ltd', - beneBank: 'IDFC FIRST Bank', - paymentType: 'RTGS', - amount: 3450000, - valueDate: vd(4), - purposeCode: 'P0202', - leiCode: '549300QRSTUV5678', - processingState: 'SETTLED', - sentTimestamp: dt(4, 12, 45) - }, - { - batchId: 'H2H-20260322-03', - instructionId: 'ERP77889502', - utrNumber: 'HDFCR5202608116', - remitterAcc: '50200445566778', - remitterBranch: 'Pune — Koregaon Park', - remitterUser: 'Vikram Joshi', - remitterChannel: 'MOBILE', - beneAcc: '50100654321789', - beneIfsc: 'HDFC0009876', - beneName: 'Neha Kapoor', - beneBank: 'HDFC Bank', - paymentType: 'NEFT', - amount: 25000, - valueDate: vd(4), - purposeCode: 'P1301', - processingState: 'SETTLED', - sentTimestamp: dt(4, 12, 50) - }, - { - batchId: 'H2H-20260321-05', - instructionId: 'ERP77889411', - utrNumber: 'KKBKR5202608013', - remitterAcc: '50200778899001', - remitterBranch: 'Hyderabad — Banjara', - remitterUser: 'Lakshmi Naidu', - remitterChannel: 'BRANCH', - beneAcc: '12345678900011', - beneIfsc: 'KKBK0001899', - beneName: 'Aarav Enterprises', - beneBank: 'Kotak Mahindra Bank', - paymentType: 'NEFT', - amount: 67000, - valueDate: vd(5), - purposeCode: 'P0802', - processingState: 'RETURNED', - returnCode: 'R03', - returnReason: 'Account Closed', - sentTimestamp: dt(5, 10, 5) - }, - { - batchId: 'H2H-20260321-05', - instructionId: 'ERP77889412', - utrNumber: 'SBINR5202607914', - remitterAcc: '50200778899001', - remitterBranch: 'Hyderabad — Banjara', - remitterUser: 'Lakshmi Naidu', - remitterChannel: 'BRANCH', - beneAcc: '50100445566778', - beneIfsc: 'SBIN0008765', - beneName: 'Spectra Components', - beneBank: 'State Bank of India', - paymentType: 'RTGS', - amount: 980000, - valueDate: vd(5), - purposeCode: 'P0801', - leiCode: '549300LMNOPQ2468', - processingState: 'SETTLED', - sentTimestamp: dt(5, 10, 10) - }, - { - batchId: 'H2H-20260320-06', - instructionId: 'ERP77889301', - utrNumber: 'ICICR5202607812', - remitterAcc: '50100334455667', - remitterBranch: 'Kolkata — Park Street', - remitterUser: 'Arjun Banerjee', - remitterChannel: 'MOBILE', - beneAcc: '88990011223344', - beneIfsc: 'ICIC0001234', - beneName: 'Eastern Logistics', - beneBank: 'ICICI Bank', - paymentType: 'NEFT', - amount: 188000, - valueDate: vd(6), - purposeCode: 'P0802', - processingState: 'SETTLED', - sentTimestamp: dt(6, 14, 30) - }, - { - batchId: 'H2H-20260320-06', - instructionId: 'ERP77889302', - utrNumber: 'AXISR5202607711', - remitterAcc: '50100334455667', - remitterBranch: 'Kolkata — Park Street', - remitterUser: 'Arjun Banerjee', - remitterChannel: 'BRANCH', - beneAcc: '50200998877665', - beneIfsc: 'UTIB0001234', - beneName: 'Indrani Sen', - beneBank: 'Axis Bank', - paymentType: 'NEFT', - amount: 5500, - valueDate: vd(6), - purposeCode: 'P1302', - processingState: 'SETTLED', - sentTimestamp: dt(6, 14, 32) - } - ]; + return seedNeftRtgsTransactions(); } } + +// ───────────────────── Shared mock seed (top-level export) ───────────────────── +/** + * Mock NEFT / RTGS history. Exported so the entry form (and any per-sender history + * dialogs) can render from the same dataset without each component owning its own + * copy. Replace with an API call when the back-end is wired up. + */ +export function seedNeftRtgsTransactions(): NeftRtgsTxn[] { + const today = new Date(); + const dt = (offset: number, h = 10, m = 0): string => { + const d = new Date(today); + d.setDate(d.getDate() - offset); + const pad = (n: number) => String(n).padStart(2, '0'); + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(h) + ':' + pad(m) + ':00'; + }; + const vd = (offset: number): string => dt(offset).slice(0, 10); + + return [ + { + batchId: 'H2H-20260326-01', + instructionId: 'ERP77889922', + utrNumber: 'KKBKR5202608511', + remitterAcc: '50200012345678', + remitterBranch: 'Mumbai — BKC', + remitterUser: 'Anita Desai', + remitterChannel: 'BRANCH', + beneAcc: '91000222333444', + beneIfsc: 'HDFC0001234', + beneName: 'Global Logistics Ltd', + beneBank: 'HDFC Bank', + paymentType: 'RTGS', + amount: 550000, + valueDate: vd(0), + purposeCode: 'P0102', + leiCode: '549300ABCDEF1234', + processingState: 'SETTLED', + sentTimestamp: dt(0, 14, 5) + }, + { + batchId: 'H2H-20260326-01', + instructionId: 'ERP77889923', + utrNumber: 'HDFCR5202608712', + remitterAcc: '50200012345678', + remitterBranch: 'Mumbai — BKC', + remitterUser: 'Anita Desai', + remitterChannel: 'BRANCH', + beneAcc: '345678901234', + beneIfsc: 'ICIC0000567', + beneName: 'Bluestone Trading Co.', + beneBank: 'ICICI Bank', + paymentType: 'NEFT', + amount: 78500, + valueDate: vd(0), + purposeCode: 'P0802', + processingState: 'SENT', + sentTimestamp: dt(0, 14, 6) + }, + { + batchId: 'H2H-20260325-04', + instructionId: 'ERP77889811', + utrNumber: 'SBINR5202608902', + remitterAcc: '50200099887766', + remitterBranch: 'Bengaluru — MG Road', + remitterUser: 'Karthik Rao', + remitterChannel: 'MOBILE', + beneAcc: '912345678901', + beneIfsc: 'SBIN0011223', + beneName: 'Anita Verma', + beneBank: 'State Bank of India', + paymentType: 'NEFT', + amount: 15000, + valueDate: vd(1), + purposeCode: 'P1301', + processingState: 'SETTLED', + sentTimestamp: dt(1, 11, 22) + }, + { + batchId: 'H2H-20260325-04', + instructionId: 'ERP77889812', + utrNumber: 'AXISR5202608811', + remitterAcc: '50200099887766', + remitterBranch: 'Bengaluru — MG Road', + remitterUser: 'Karthik Rao', + remitterChannel: 'MOBILE', + beneAcc: '6789012345', + beneIfsc: 'UTIB0000456', + beneName: 'Rakesh Industries', + beneBank: 'Axis Bank', + paymentType: 'RTGS', + amount: 1250000, + valueDate: vd(1), + purposeCode: 'P0801', + leiCode: '549300XYZAB7890', + processingState: 'RETURNED', + returnCode: 'R01', + returnReason: 'Insufficient Funds', + sentTimestamp: dt(1, 11, 25) + }, + { + batchId: 'H2H-20260324-02', + instructionId: 'ERP77889701', + utrNumber: 'PUNBR5202608619', + remitterAcc: '50100221122334', + remitterBranch: 'Delhi — Connaught', + remitterUser: 'Sushma Iyer', + remitterChannel: 'BRANCH', + beneAcc: '50100998877665', + beneIfsc: 'PUNB0123400', + beneName: 'Madhavi Singh', + beneBank: 'Punjab National Bank', + paymentType: 'NEFT', + amount: 42000, + valueDate: vd(2), + purposeCode: 'P1006', + processingState: 'SETTLED', + sentTimestamp: dt(2, 9, 35) + }, + { + batchId: 'H2H-20260324-02', + instructionId: 'ERP77889702', + utrNumber: 'YESBR5202608520', + remitterAcc: '50100221122334', + remitterBranch: 'Delhi — Connaught', + remitterUser: 'Sushma Iyer', + remitterChannel: 'BRANCH', + beneAcc: '11223344556677', + beneIfsc: 'YESB0000123', + beneName: 'Sigma Constructions', + beneBank: 'YES Bank', + paymentType: 'RTGS', + amount: 825000, + valueDate: vd(2), + purposeCode: 'P0102', + processingState: 'PENDING', + sentTimestamp: dt(2, 9, 38) + }, + { + batchId: 'H2H-20260323-07', + instructionId: 'ERP77889601', + utrNumber: 'CNRBR5202608417', + remitterAcc: '50100887766554', + remitterBranch: 'Chennai — T. Nagar', + remitterUser: 'Ramesh Krishnan', + remitterChannel: 'MOBILE', + beneAcc: '99887766554433', + beneIfsc: 'CNRB0001890', + beneName: 'Padma Rao', + beneBank: 'Canara Bank', + paymentType: 'NEFT', + amount: 9800, + valueDate: vd(3), + purposeCode: 'P1302', + processingState: 'SETTLED', + sentTimestamp: dt(3, 16, 15) + }, + { + batchId: 'H2H-20260323-07', + instructionId: 'ERP77889602', + utrNumber: 'BARBR5202608318', + remitterAcc: '50100887766554', + remitterBranch: 'Chennai — T. Nagar', + remitterUser: 'Ramesh Krishnan', + remitterChannel: 'BRANCH', + beneAcc: '50100123456987', + beneIfsc: 'BARB0VJVATA', + beneName: 'Vijaya Traders', + beneBank: 'Bank of Baroda', + paymentType: 'NEFT', + amount: 125000, + valueDate: vd(3), + purposeCode: 'P0802', + processingState: 'VALIDATED', + sentTimestamp: dt(3, 16, 20) + }, + { + batchId: 'H2H-20260322-03', + instructionId: 'ERP77889501', + utrNumber: 'IDFBR5202608215', + remitterAcc: '50200445566778', + remitterBranch: 'Pune — Koregaon Park', + remitterUser: 'Vikram Joshi', + remitterChannel: 'BRANCH', + beneAcc: '70010023456789', + beneIfsc: 'IDFB0040101', + beneName: 'Pinnacle Realty Pvt Ltd', + beneBank: 'IDFC FIRST Bank', + paymentType: 'RTGS', + amount: 3450000, + valueDate: vd(4), + purposeCode: 'P0202', + leiCode: '549300QRSTUV5678', + processingState: 'SETTLED', + sentTimestamp: dt(4, 12, 45) + }, + { + batchId: 'H2H-20260322-03', + instructionId: 'ERP77889502', + utrNumber: 'HDFCR5202608116', + remitterAcc: '50200445566778', + remitterBranch: 'Pune — Koregaon Park', + remitterUser: 'Vikram Joshi', + remitterChannel: 'MOBILE', + beneAcc: '50100654321789', + beneIfsc: 'HDFC0009876', + beneName: 'Neha Kapoor', + beneBank: 'HDFC Bank', + paymentType: 'NEFT', + amount: 25000, + valueDate: vd(4), + purposeCode: 'P1301', + processingState: 'SETTLED', + sentTimestamp: dt(4, 12, 50) + }, + { + batchId: 'H2H-20260321-05', + instructionId: 'ERP77889411', + utrNumber: 'KKBKR5202608013', + remitterAcc: '50200778899001', + remitterBranch: 'Hyderabad — Banjara', + remitterUser: 'Lakshmi Naidu', + remitterChannel: 'BRANCH', + beneAcc: '12345678900011', + beneIfsc: 'KKBK0001899', + beneName: 'Aarav Enterprises', + beneBank: 'Kotak Mahindra Bank', + paymentType: 'NEFT', + amount: 67000, + valueDate: vd(5), + purposeCode: 'P0802', + processingState: 'RETURNED', + returnCode: 'R03', + returnReason: 'Account Closed', + sentTimestamp: dt(5, 10, 5) + }, + { + batchId: 'H2H-20260321-05', + instructionId: 'ERP77889412', + utrNumber: 'SBINR5202607914', + remitterAcc: '50200778899001', + remitterBranch: 'Hyderabad — Banjara', + remitterUser: 'Lakshmi Naidu', + remitterChannel: 'BRANCH', + beneAcc: '50100445566778', + beneIfsc: 'SBIN0008765', + beneName: 'Spectra Components', + beneBank: 'State Bank of India', + paymentType: 'RTGS', + amount: 980000, + valueDate: vd(5), + purposeCode: 'P0801', + leiCode: '549300LMNOPQ2468', + processingState: 'SETTLED', + sentTimestamp: dt(5, 10, 10) + }, + { + batchId: 'H2H-20260320-06', + instructionId: 'ERP77889301', + utrNumber: 'ICICR5202607812', + remitterAcc: '50100334455667', + remitterBranch: 'Kolkata — Park Street', + remitterUser: 'Arjun Banerjee', + remitterChannel: 'MOBILE', + beneAcc: '88990011223344', + beneIfsc: 'ICIC0001234', + beneName: 'Eastern Logistics', + beneBank: 'ICICI Bank', + paymentType: 'NEFT', + amount: 188000, + valueDate: vd(6), + purposeCode: 'P0802', + processingState: 'SETTLED', + sentTimestamp: dt(6, 14, 30) + }, + { + batchId: 'H2H-20260320-06', + instructionId: 'ERP77889302', + utrNumber: 'AXISR5202607711', + remitterAcc: '50100334455667', + remitterBranch: 'Kolkata — Park Street', + remitterUser: 'Arjun Banerjee', + remitterChannel: 'BRANCH', + beneAcc: '50200998877665', + beneIfsc: 'UTIB0001234', + beneName: 'Indrani Sen', + beneBank: 'Axis Bank', + paymentType: 'NEFT', + amount: 5500, + valueDate: vd(6), + purposeCode: 'P1302', + processingState: 'SETTLED', + sentTimestamp: dt(6, 14, 32) + } + ]; +} diff --git a/src/app/digital/sender-history-dialog/sender-history-dialog.component.html b/src/app/digital/sender-history-dialog/sender-history-dialog.component.html new file mode 100644 index 000000000..89ae644ba --- /dev/null +++ b/src/app/digital/sender-history-dialog/sender-history-dialog.component.html @@ -0,0 +1,117 @@ + + +
+ +
+
+ +
+

{{ data.senderName }} · NEFT / RTGS · Today

+
+ @if (data.senderAccountNo) { + {{ data.senderAccountNo }} · + } + {{ totalCount | number }} txns today · + ₹{{ totalAmount | number: '1.0-0' }} +
+
+
+ +
+ + +
+
+
{{ totalCount | number }}
+
Total
+
+
+
₹{{ totalAmount | number: '1.0-0' }}
+
Value
+
+
+
{{ settledCount | number }}
+
Settled ({{ successRate | number: '1.1-1' }}%)
+
+
+
{{ pendingCount | number }}
+
In-Flight
+
+
+
{{ returnedCount | number }}
+
Returned
+
+
+ + +
+ @if (filteredTxns.length === 0) { +
+ +
+ No NEFT / RTGS transactions today for this sender. +

This view shows only today's outward transfers. Older history isn't included here.

+
+
+ } @else { +
+ + + + + + + + + + + + + + + @for (t of filteredTxns; track t.utrNumber) { + + + + + + + + + + + } + +
UTRBeneficiaryIFSCTypeAmountValue DateStateSent
{{ t.utrNumber }} +
{{ t.beneName }}
+
{{ t.beneAcc }} · {{ t.beneBank }}
+
{{ t.beneIfsc }} + + {{ t.paymentType }} + + + ₹{{ t.amount | number: '1.2-2' }} + {{ t.valueDate | date: 'd MMM y' }} + + {{ t.processingState }} + + @if (t.returnCode) { +
{{ t.returnCode }} · {{ t.returnReason }}
+ } +
{{ t.sentTimestamp }}
+
+ } +
+
diff --git a/src/app/digital/sender-history-dialog/sender-history-dialog.component.scss b/src/app/digital/sender-history-dialog/sender-history-dialog.component.scss new file mode 100644 index 000000000..de1bdcac6 --- /dev/null +++ b/src/app/digital/sender-history-dialog/sender-history-dialog.component.scss @@ -0,0 +1,315 @@ +/** + * 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/. + */ + +:host { + --shd-blue: #1d4ed8; + --shd-blue-d: #1e40af; + --shd-blue-l: #eff6ff; + --shd-teal: #0d9488; + --shd-teal-l: #ccfbf1; + --shd-green: #15803d; + --shd-green-l: #dcfce7; + --shd-amber: #d97706; + --shd-amber-l: #fef3c7; + --shd-red: #b91c1c; + --shd-red-l: #fee2e2; + --shd-purple: #6d28d9; + --shd-purple-l: #ede9fe; + --shd-g50: #f8fafc; + --shd-g100: #f1f5f9; + --shd-g200: #e2e8f0; + --shd-g300: #cbd5e1; + --shd-g500: #64748b; + --shd-g700: #334155; + --shd-g900: #0f172a; + + display: block; +} + +.shd-root { + background: #fff; + padding: 16px 20px 22px; + max-width: 940px; + width: 100%; + max-height: 84vh; + display: flex; + flex-direction: column; +} + +/* ───────── Header ───────── */ +.shd-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding-bottom: 12px; + margin-bottom: 14px; + border-bottom: 1px solid var(--shd-g200); +} + +.shd-head-l { + display: flex; + align-items: center; + gap: 12px; +} + +.shd-head-ic { + width: 40px; + height: 40px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: var(--shd-blue); + color: #fff; + font-size: 18px; +} + +.shd-head-t { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--shd-g900); + line-height: 1.25; + + small { + color: var(--shd-g500); + font-weight: 500; + font-size: 12.5px; + margin-left: 4px; + } +} + +.shd-head-s { + margin-top: 3px; + font-size: 12px; + color: var(--shd-g500); + + strong { + color: var(--shd-g900); + } +} + +.shd-x { + width: 32px; + height: 32px; + border-radius: 999px; + border: 1px solid var(--shd-g200); + background: #fff; + color: var(--shd-g500); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 13px; + flex-shrink: 0; + transition: + background 0.12s, + color 0.12s, + border-color 0.12s; + + &:hover { + background: var(--shd-red-l); + color: var(--shd-red); + border-color: #fecaca; + } +} + +/* ───────── KPI strip ───────── */ +.shd-kpis { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; + margin-bottom: 14px; + + @media (width <= 700px) { + grid-template-columns: repeat(2, 1fr); + } +} + +.shd-kpi { + padding: 10px 12px; + border-radius: 10px; + background: var(--shd-blue-l); + border: 1px solid #c7d2fe; + color: var(--shd-g900); + + &--blue { + background: var(--shd-blue-l); + border-color: #c7d2fe; + } + + &--teal { + background: var(--shd-teal-l); + border-color: #99f6e4; + } + + &--green { + background: var(--shd-green-l); + border-color: #bbf7d0; + } + + &--amber { + background: var(--shd-amber-l); + border-color: #fde68a; + } + + &--red { + background: var(--shd-red-l); + border-color: #fecaca; + } +} + +.shd-kpi-n { + font-size: 16px; + font-weight: 800; + color: var(--shd-g900); + font-variant-numeric: tabular-nums; + line-height: 1.15; +} + +.shd-kpi-l { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--shd-g500); + margin-top: 2px; +} + +/* ───────── Body / table ───────── */ +.shd-body { + flex: 1; + overflow: auto; +} + +.shd-empty { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 18px 16px; + border: 1px dashed var(--shd-g300); + border-radius: 10px; + background: var(--shd-g50); + color: var(--shd-g700); + + fa-icon { + color: var(--shd-blue); + font-size: 18px; + margin-top: 2px; + } + + p { + margin: 4px 0 0; + font-size: 12.5px; + color: var(--shd-g500); + } +} + +.shd-table-wrap { + overflow: auto; + border: 1px solid var(--shd-g200); + border-radius: 10px; +} + +.shd-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + padding: 10px 12px; + background: var(--shd-g50); + color: var(--shd-g500); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 10.5px; + white-space: nowrap; + border-bottom: 1px solid var(--shd-g200); + } + + td { + padding: 10px 12px; + border-bottom: 1px solid var(--shd-g100); + vertical-align: top; + color: var(--shd-g900); + } + + tr:last-child td { + border-bottom: 0; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + + .small { + font-size: 11.5px; + } + + .muted { + color: var(--shd-g500); + } +} + +/* ───────── Pills ───────── */ +.shd-pill { + display: inline-flex; + align-items: center; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + background: var(--shd-g100); + color: var(--shd-g700); + white-space: nowrap; + + &.mini { + padding: 1px 7px; + font-size: 10px; + } + + &--blue { + background: var(--shd-blue-l); + color: var(--shd-blue-d); + } + + &--purple { + background: var(--shd-purple-l); + color: var(--shd-purple); + } + + &--green { + background: var(--shd-green-l); + color: var(--shd-green); + } + + &--teal { + background: var(--shd-teal-l); + color: var(--shd-teal); + } + + &--amber { + background: var(--shd-amber-l); + color: var(--shd-amber); + } + + &--red { + background: var(--shd-red-l); + color: var(--shd-red); + } +} diff --git a/src/app/digital/sender-history-dialog/sender-history-dialog.component.ts b/src/app/digital/sender-history-dialog/sender-history-dialog.component.ts new file mode 100644 index 000000000..f35e4e83d --- /dev/null +++ b/src/app/digital/sender-history-dialog/sender-history-dialog.component.ts @@ -0,0 +1,113 @@ +/** + * 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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { NeftRtgsTxn, ProcessingState } from '../neft-rtgs-report/neft-rtgs-report.component'; + +export interface SenderHistoryDialogData { + /** Sender's name as shown on the sender chip. */ + senderName: string; + /** Picked sender account number (for exact-match filtering). */ + senderAccountNo: string; + /** Full transaction history to filter (already-seeded mock or live API rows). */ + txns: NeftRtgsTxn[]; +} + +@Component({ + selector: 'mifosx-sender-history-dialog', + templateUrl: './sender-history-dialog.component.html', + styleUrls: ['./sender-history-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FaIconComponent, + MatTooltip + ] +}) +export class SenderHistoryDialogComponent { + private dialogRef = inject(MatDialogRef); + data: SenderHistoryDialogData = inject(MAT_DIALOG_DATA); + + /** Today's date as YYYY-MM-DD — matches the `valueDate` shape in the txn seed. */ + private get todayKey(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + } + + /** + * Today's NEFT/RTGS rows for this sender — matched by exact A/C number when + * available, otherwise by case-insensitive holder name (since the demo seed + * and operator account list don't share account numbers). Older value dates + * are excluded; this view is intentionally a today-only snapshot. + */ + get filteredTxns(): NeftRtgsTxn[] { + const acc = (this.data.senderAccountNo || '').trim().toLowerCase(); + const name = (this.data.senderName || '').trim().toLowerCase(); + const today = this.todayKey; + return (this.data.txns || []).filter((t) => { + if (t.valueDate !== today) return false; + if (acc && t.remitterAcc.toLowerCase() === acc) return true; + if (name && t.remitterUser.toLowerCase() === name) return true; + return false; + }); + } + + // ─── KPI getters ─── + get totalCount(): number { + return this.filteredTxns.length; + } + get totalAmount(): number { + return this.filteredTxns.reduce((s, t) => s + t.amount, 0); + } + get settledCount(): number { + return this.filteredTxns.filter((t) => t.processingState === 'SETTLED').length; + } + get pendingCount(): number { + return this.filteredTxns.filter((t) => [ + 'UPLOADED', + 'VALIDATED', + 'SENT', + 'PENDING' + ].includes(t.processingState)).length; + } + get returnedCount(): number { + return this.filteredTxns.filter((t) => t.processingState === 'RETURNED').length; + } + get successRate(): number { + if (this.totalCount === 0) return 0; + return (this.settledCount / this.totalCount) * 100; + } + + stateColor(state: ProcessingState): string { + switch (state) { + case 'SETTLED': + return 'green'; + case 'RETURNED': + return 'red'; + case 'SENT': + return 'blue'; + case 'VALIDATED': + return 'teal'; + case 'UPLOADED': + return 'amber'; + case 'PENDING': + return 'amber'; + } + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/digital/upi-report/upi-report.component.html b/src/app/digital/upi-report/upi-report.component.html new file mode 100644 index 000000000..2cdd8d03e --- /dev/null +++ b/src/app/digital/upi-report/upi-report.component.html @@ -0,0 +1,425 @@ + + +
+ +
+
+ +
+

UPI — Report & Reconciliation

+
+ Digital · Unified Payments Interface · Showing + {{ totalCount | number }} transactions worth + ₹{{ totalAmount | number: '1.0-0' }} +
+
+
+
+ +
+
+ + +
+
+
+
+
{{ totalCount | number }}
+
Total Transactions
+
{{ p2pCount }} P2P · {{ p2mCount }} P2M
+
+
+
+
+
+
₹{{ totalAmount | number: '1.0-0' }}
+
Total Value
+
avg ₹{{ avgTicket | number: '1.0-0' }} / txn
+
+
+
+
+
+
{{ successCount | number }}
+
Successful
+
success {{ successRate | number: '1.1-1' }}%
+
+
+
+
+
+
{{ pendingCount | number }}
+
In-Flight
+
initiated / pending
+
+
+
+
+
+
{{ failedCount | number }}
+
Failed / Expired
+
check failure codes
+
+
+
+ + +
+ @for (t of tabs; track t.id) { + + } +
+ + + @if (activeTab === 'trends') { +
+
+ Daily Throughput (Last 7 Value Dates) + P2P + P2M +
+
+ @for (b of trendBuckets; track b.date) { +
+
+ {{ b.date | date: 'EEE' }} + {{ b.date | date: 'd MMM' }} +
+
+
+
+
+ P2P {{ b.p2p }} + P2M {{ b.p2m }} +
+
₹{{ b.amount | number: '1.0-0' }}
+
+ } +
+
+ } + + + @if (activeTab === 'bank') { +
+
+ Payee / Handler Bank Distribution +
+
+ + + + + + + + + + + + + @for (r of bankRows; track r.bank) { + + + + + + + + + } + +
BankCountAmountSuccessPendingFailed
+ {{ r.bank }} + {{ r.count | number }} + ₹{{ r.amount | number: '1.0-0' }} + + {{ r.success }} + + {{ r.pending }} + + {{ r.failed }} +
+
+
+ } + + + @if (activeTab === 'date') { +
+
+ Value-Date Aggregation +
+
+ + + + + + + + + + + + @for (r of dateRows; track r.date) { + + + + + + + + } + +
Value DateCountP2PP2MAmount
{{ r.date | date: 'EEE, d MMM y' }}{{ r.count | number }} + {{ r.p2p }} + + {{ r.p2m }} + + ₹{{ r.amount | number: '1.0-0' }} +
+
+
+ } + + + @if (activeTab === 'pending') { +
+
+
+ Processing-State Funnel +
+
+
+ Initiated / Pending + {{ pendingCount | number }} +
+
+ Successful + {{ successCount | number }} +
+
+ Failed / Expired / Declined + {{ failedCount | number }} +
+
+ + + +
+
+
+
+
+ Failure-Code Breakdown +
+ @if (failureCodeRows.length === 0) { +
No failed transactions in current window.
+ } @else { + + + + + + + + + + + @for (r of failureCodeRows; track r.code) { + + + + + + + } + +
CodeReasonCountAmount
+ {{ r.code }} + {{ r.reason }}{{ r.count }}₹{{ r.amount | number: '1.0-0' }}
+ } +
+
+ } + + + @if (activeTab === 'app') { +
+
+ UPI App Mix +
+
+ @for (r of appRows; track r.app) { +
+
+ +
+
+
{{ r.app }}
+
{{ r.count | number }} txns
+
₹{{ r.amount | number: '1.0-0' }}
+
+ +
+ +
+
+ } +
+
+ } + + + @if (activeTab === 'user') { +
+
+ Top Payers +
+
+ + + + + + + + + + + @for (r of userRows; track r.user + r.branch) { + + + + + + + } + +
PayerBranchTxnsAmount
+ {{ r.user }} + {{ r.branch }}{{ r.count | number }} + ₹{{ r.amount | number: '1.0-0' }} +
+
+
+ } + + +
+
+ Transactions +
+ + + + @if (search || typeFilter || stateFilter) { + + } +
+
+
+ + + + + + + + + + + + + + + + @for (t of filteredTxns; track t.rrn) { + + + + + + + + + + + + } + @if (filteredTxns.length === 0) { + + + + } + +
RRN / UPI RefPayerPayeeAppTypeAmountValue DateStateSent
+
{{ t.rrn }}
+
{{ t.upiRef }}
+
+
{{ t.payerName }}
+
{{ t.payerVpa }}
+
+
+ {{ t.payeeName }} + @if (t.payeeType === 'MERCHANT') { + Merchant + } +
+
{{ t.payeeVpa }} · {{ t.payeeBank }}
+
+ {{ t.payerApp }} + + + {{ t.txnType }} + + + ₹{{ t.amount | number: '1.2-2' }} + {{ t.valueDate | date: 'd MMM y' }} + + {{ t.state }} + + @if (t.failureCode) { +
{{ t.failureCode }} · {{ t.failureReason }}
+ } +
{{ t.sentTimestamp }}
No transactions match the current filters.
+
+
+
diff --git a/src/app/digital/upi-report/upi-report.component.ts b/src/app/digital/upi-report/upi-report.component.ts new file mode 100644 index 000000000..dc289c288 --- /dev/null +++ b/src/app/digital/upi-report/upi-report.component.ts @@ -0,0 +1,618 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Person-to-Person, Person-to-Merchant, or Collect-Request (payee-initiated). */ +export type UpiTxnType = 'P2P' | 'P2M' | 'COLLECT'; +export type UpiState = 'INITIATED' | 'PENDING' | 'SUCCESS' | 'FAILED' | 'EXPIRED' | 'DECLINED'; +export type UpiApp = 'BHIM' | 'PhonePe' | 'GPay' | 'Paytm' | 'Amazon Pay' | 'WhatsApp' | 'Other'; + +export interface UpiTxn { + rrn: string; + upiRef: string; + payerVpa: string; + payerName: string; + payerBranch: string; + payerApp: UpiApp; + payeeVpa: string; + payeeName: string; + payeeBank: string; + payeeType: 'PERSON' | 'MERCHANT'; + txnType: UpiTxnType; + amount: number; + valueDate: string; + state: UpiState; + failureCode?: string; + failureReason?: string; + sentTimestamp: string; +} + +type ReportTab = 'trends' | 'bank' | 'date' | 'pending' | 'app' | 'user'; + +interface BankRow { + bank: string; + count: number; + amount: number; + success: number; + pending: number; + failed: number; +} + +interface DateRow { + date: string; + count: number; + amount: number; + p2p: number; + p2m: number; +} + +interface UserRow { + user: string; + count: number; + amount: number; + branch: string; +} + +interface AppRow { + app: UpiApp; + count: number; + amount: number; + share: number; +} + +@Component({ + selector: 'mifosx-upi-report', + templateUrl: './upi-report.component.html', + // Reuse the NEFT/RTGS report SCSS so all three rail-reports share the same shell. + styleUrls: ['../neft-rtgs-report/neft-rtgs-report.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class UpiReportComponent implements OnInit { + activeTab: ReportTab = 'trends'; + + search = ''; + typeFilter: '' | UpiTxnType = ''; + stateFilter: '' | UpiState = ''; + + txns: UpiTxn[] = []; + + readonly tabs: { id: ReportTab; label: string; icon: string }[] = [ + { id: 'trends', label: 'Trends', icon: 'chart-line' }, + { id: 'bank', label: 'Handler-Bank', icon: 'university' }, + { id: 'date', label: 'Date-wise', icon: 'calendar-alt' }, + { id: 'pending', label: 'Success / Pending / Failed', icon: 'tasks' }, + { id: 'app', label: 'App-wise', icon: 'mobile-alt' }, + { id: 'user', label: 'User-wise', icon: 'user-tie' } + ]; + + ngOnInit(): void { + this.txns = seedUpiTransactions(); + } + + // ───────────────────── KPIs ───────────────────── + get totalCount(): number { + return this.txns.length; + } + get totalAmount(): number { + return this.txns.reduce((s, t) => s + t.amount, 0); + } + get successCount(): number { + return this.txns.filter((t) => t.state === 'SUCCESS').length; + } + get pendingCount(): number { + return this.txns.filter((t) => [ + 'INITIATED', + 'PENDING' + ].includes(t.state)).length; + } + get failedCount(): number { + return this.txns.filter((t) => [ + 'FAILED', + 'EXPIRED', + 'DECLINED' + ].includes(t.state)).length; + } + get p2pCount(): number { + return this.txns.filter((t) => t.txnType === 'P2P').length; + } + get p2mCount(): number { + return this.txns.filter((t) => t.txnType === 'P2M').length; + } + get successRate(): number { + if (this.totalCount === 0) return 0; + return (this.successCount / this.totalCount) * 100; + } + get avgTicket(): number { + if (this.totalCount === 0) return 0; + return this.totalAmount / this.totalCount; + } + + // ───────────────────── Filtered list ───────────────────── + get filteredTxns(): UpiTxn[] { + const q = this.search.trim().toLowerCase(); + return this.txns.filter((t) => { + if (this.typeFilter && t.txnType !== this.typeFilter) return false; + if (this.stateFilter && t.state !== this.stateFilter) return false; + if (!q) return true; + return ( + t.rrn.toLowerCase().includes(q) || + t.upiRef.toLowerCase().includes(q) || + t.payerVpa.toLowerCase().includes(q) || + t.payerName.toLowerCase().includes(q) || + t.payeeVpa.toLowerCase().includes(q) || + t.payeeName.toLowerCase().includes(q) || + t.payeeBank.toLowerCase().includes(q) + ); + }); + } + + // ───────────────────── Aggregations ───────────────────── + get bankRows(): BankRow[] { + const map = new Map(); + for (const t of this.txns) { + const key = t.payeeBank; + if (!map.has(key)) { + map.set(key, { bank: key, count: 0, amount: 0, success: 0, pending: 0, failed: 0 }); + } + const r = map.get(key)!; + r.count++; + r.amount += t.amount; + if (t.state === 'SUCCESS') r.success++; + else if (t.state === 'FAILED' || t.state === 'EXPIRED' || t.state === 'DECLINED') r.failed++; + else r.pending++; + } + return Array.from(map.values()).sort((a, b) => b.amount - a.amount); + } + + get dateRows(): DateRow[] { + const map = new Map(); + for (const t of this.txns) { + if (!map.has(t.valueDate)) { + map.set(t.valueDate, { date: t.valueDate, count: 0, amount: 0, p2p: 0, p2m: 0 }); + } + const r = map.get(t.valueDate)!; + r.count++; + r.amount += t.amount; + if (t.txnType === 'P2P') r.p2p++; + else if (t.txnType === 'P2M') r.p2m++; + } + return Array.from(map.values()).sort((a, b) => (a.date < b.date ? 1 : -1)); + } + + get userRows(): UserRow[] { + const map = new Map(); + for (const t of this.txns) { + const key = `${t.payerName}|${t.payerBranch}`; + if (!map.has(key)) { + map.set(key, { user: t.payerName, branch: t.payerBranch, count: 0, amount: 0 }); + } + const r = map.get(key)!; + r.count++; + r.amount += t.amount; + } + return Array.from(map.values()).sort((a, b) => b.amount - a.amount); + } + + get appRows(): AppRow[] { + const total = this.totalAmount || 1; + const map = new Map(); + for (const t of this.txns) { + if (!map.has(t.payerApp)) { + map.set(t.payerApp, { app: t.payerApp, count: 0, amount: 0, share: 0 }); + } + const r = map.get(t.payerApp)!; + r.count++; + r.amount += t.amount; + } + map.forEach((r) => (r.share = (r.amount / total) * 100)); + return Array.from(map.values()).sort((a, b) => b.amount - a.amount); + } + + get trendBuckets(): DateRow[] { + return this.dateRows.slice(0, 7).reverse(); + } + + get trendMaxAmount(): number { + const buckets = this.trendBuckets; + if (!buckets.length) return 1; + return Math.max(...buckets.map((b) => b.amount)); + } + + get failureCodeRows(): { code: string; reason: string; count: number; amount: number }[] { + const map = new Map(); + for (const t of this.txns) { + const failed = t.state === 'FAILED' || t.state === 'EXPIRED' || t.state === 'DECLINED'; + if (!failed || !t.failureCode) continue; + if (!map.has(t.failureCode)) { + map.set(t.failureCode, { + code: t.failureCode, + reason: t.failureReason || '', + count: 0, + amount: 0 + }); + } + const r = map.get(t.failureCode)!; + r.count++; + r.amount += t.amount; + } + return Array.from(map.values()).sort((a, b) => b.count - a.count); + } + + // ───────────────────── Actions ───────────────────── + showTab(tab: ReportTab): void { + this.activeTab = tab; + } + + exportCsv(): void { + const headers = [ + 'RRN', + 'UPI Ref', + 'Payer VPA', + 'Payer Name', + 'Payer App', + 'Payee VPA', + 'Payee Name', + 'Payee Bank', + 'Payee Type', + 'Txn Type', + 'Amount', + 'Value Date', + 'State', + 'Failure Code', + 'Sent Timestamp' + ]; + const rows: string[][] = [headers]; + for (const t of this.filteredTxns) { + rows.push([ + t.rrn, + t.upiRef, + t.payerVpa, + t.payerName, + t.payerApp, + t.payeeVpa, + t.payeeName, + t.payeeBank, + t.payeeType, + t.txnType, + t.amount.toFixed(2), + t.valueDate, + t.state, + t.failureCode || '', + t.sentTimestamp + ]); + } + const escape = (v: string): string => { + const s = (v || '').replace(/"/g, '""'); + return /[,"\n]/.test(s) ? `"${s}"` : s; + }; + const csv = rows.map((r) => r.map(escape).join(',')).join('\n') + '\n'; + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `UPI_Report_${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + } + + clearFilters(): void { + this.search = ''; + this.typeFilter = ''; + this.stateFilter = ''; + } + + // ───────────────────── Helpers ───────────────────── + stateColor(state: UpiState): string { + switch (state) { + case 'SUCCESS': + return 'green'; + case 'FAILED': + case 'EXPIRED': + case 'DECLINED': + return 'red'; + case 'INITIATED': + return 'teal'; + case 'PENDING': + return 'amber'; + } + } + + trendBarHeight(amount: number): number { + return Math.max(6, Math.round((amount / this.trendMaxAmount) * 100)); + } +} + +// ───────────────────── Shared mock seed (top-level export) ───────────────────── +/** + * Mock UPI history. Exported so any per-sender history dialogs (or future + * dashboards) can render from the same dataset. Replace with an API call. + */ +export function seedUpiTransactions(): UpiTxn[] { + const today = new Date(); + const dt = (offset: number, h = 10, m = 0): string => { + const d = new Date(today); + d.setDate(d.getDate() - offset); + const pad = (n: number) => String(n).padStart(2, '0'); + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(h) + ':' + pad(m) + ':00'; + }; + const vd = (offset: number): string => dt(offset).slice(0, 10); + + return [ + { + rrn: 'UPI52608001', + upiRef: 'YBL5202608111', + payerVpa: 'anita.desai@ybl', + payerName: 'Anita Desai', + payerBranch: 'Mumbai — BKC', + payerApp: 'PhonePe', + payeeVpa: 'amazon@apb', + payeeName: 'Amazon Pay India', + payeeBank: 'Axis Bank', + payeeType: 'MERCHANT', + txnType: 'P2M', + amount: 1899, + valueDate: vd(0), + state: 'SUCCESS', + sentTimestamp: dt(0, 9, 14) + }, + { + rrn: 'UPI52608002', + upiRef: 'OKAXIS520260822', + payerVpa: 'anita.desai@ybl', + payerName: 'Anita Desai', + payerBranch: 'Mumbai — BKC', + payerApp: 'PhonePe', + payeeVpa: 'rohan.m@oksbi', + payeeName: 'Rohan S. Mehta', + payeeBank: 'State Bank of India', + payeeType: 'PERSON', + txnType: 'P2P', + amount: 2500, + valueDate: vd(0), + state: 'SUCCESS', + sentTimestamp: dt(0, 10, 38) + }, + { + rrn: 'UPI52608003', + upiRef: 'OKHDFC520260833', + payerVpa: 'karthik@okhdfcbank', + payerName: 'Karthik Rao', + payerBranch: 'Bengaluru — MG Road', + payerApp: 'GPay', + payeeVpa: 'swiggy@hdfcbank', + payeeName: 'Swiggy', + payeeBank: 'HDFC Bank', + payeeType: 'MERCHANT', + txnType: 'P2M', + amount: 645, + valueDate: vd(0), + state: 'SUCCESS', + sentTimestamp: dt(0, 13, 22) + }, + { + rrn: 'UPI52608004', + upiRef: 'BHIM5202608044', + payerVpa: 'sushma.i@upi', + payerName: 'Sushma Iyer', + payerBranch: 'Delhi — Connaught', + payerApp: 'BHIM', + payeeVpa: 'uber@okhdfcbank', + payeeName: 'Uber India', + payeeBank: 'HDFC Bank', + payeeType: 'MERCHANT', + txnType: 'P2M', + amount: 248, + valueDate: vd(1), + state: 'FAILED', + failureCode: 'U28', + failureReason: 'Beneficiary Account is Frozen', + sentTimestamp: dt(1, 18, 5) + }, + { + rrn: 'UPI52608005', + upiRef: 'OKICICI520260855', + payerVpa: 'ramesh.k@oklicici', + payerName: 'Ramesh Krishnan', + payerBranch: 'Chennai — T. Nagar', + payerApp: 'Paytm', + payeeVpa: 'padma.r@upi', + payeeName: 'Padma Rao', + payeeBank: 'Canara Bank', + payeeType: 'PERSON', + txnType: 'P2P', + amount: 1200, + valueDate: vd(1), + state: 'SUCCESS', + sentTimestamp: dt(1, 11, 27) + }, + { + rrn: 'UPI52608006', + upiRef: 'OKAXIS520260866', + payerVpa: 'vikram.j@axl', + payerName: 'Vikram Joshi', + payerBranch: 'Pune — Koregaon Park', + payerApp: 'GPay', + payeeVpa: 'flipkart@apb', + payeeName: 'Flipkart', + payeeBank: 'Axis Bank', + payeeType: 'MERCHANT', + txnType: 'P2M', + amount: 4799, + valueDate: vd(2), + state: 'SUCCESS', + sentTimestamp: dt(2, 16, 48) + }, + { + rrn: 'UPI52608007', + upiRef: 'BHIM5202608077', + payerVpa: 'lakshmi.n@upi', + payerName: 'Lakshmi Naidu', + payerBranch: 'Hyderabad — Banjara', + payerApp: 'BHIM', + payeeVpa: 'aarav.ent@hdfcbank', + payeeName: 'Aarav Enterprises', + payeeBank: 'Kotak Mahindra Bank', + payeeType: 'MERCHANT', + txnType: 'P2M', + amount: 11500, + valueDate: vd(2), + state: 'PENDING', + sentTimestamp: dt(2, 9, 11) + }, + { + rrn: 'UPI52608008', + upiRef: 'OKWA5202608088', + payerVpa: 'arjun.b@oklicici', + payerName: 'Arjun Banerjee', + payerBranch: 'Kolkata — Park Street', + payerApp: 'WhatsApp', + payeeVpa: 'indrani.sen@axl', + payeeName: 'Indrani Sen', + payeeBank: 'Axis Bank', + payeeType: 'PERSON', + txnType: 'P2P', + amount: 800, + valueDate: vd(3), + state: 'SUCCESS', + sentTimestamp: dt(3, 14, 56) + }, + { + rrn: 'UPI52608009', + upiRef: 'OKAMP520260899', + payerVpa: 'anita.desai@apl', + payerName: 'Anita Desai', + payerBranch: 'Mumbai — BKC', + payerApp: 'Amazon Pay', + payeeVpa: 'amazon@apb', + payeeName: 'Amazon Pay India', + payeeBank: 'Axis Bank', + payeeType: 'MERCHANT', + txnType: 'P2M', + amount: 2349, + valueDate: vd(3), + state: 'DECLINED', + failureCode: 'U30', + failureReason: 'Daily Limit Exceeded', + sentTimestamp: dt(3, 21, 32) + }, + { + rrn: 'UPI52608010', + upiRef: 'OKPAYTM5202608010', + payerVpa: 'karthik@paytm', + payerName: 'Karthik Rao', + payerBranch: 'Bengaluru — MG Road', + payerApp: 'Paytm', + payeeVpa: 'zomato@paytm', + payeeName: 'Zomato', + payeeBank: 'Paytm Payments Bank', + payeeType: 'MERCHANT', + txnType: 'P2M', + amount: 420, + valueDate: vd(4), + state: 'SUCCESS', + sentTimestamp: dt(4, 20, 5) + }, + { + rrn: 'UPI52608011', + upiRef: 'OKICICI520260811', + payerVpa: 'sushma.i@upi', + payerName: 'Sushma Iyer', + payerBranch: 'Delhi — Connaught', + payerApp: 'BHIM', + payeeVpa: 'madhavi.s@upi', + payeeName: 'Madhavi Singh', + payeeBank: 'Punjab National Bank', + payeeType: 'PERSON', + txnType: 'P2P', + amount: 6500, + valueDate: vd(4), + state: 'SUCCESS', + sentTimestamp: dt(4, 12, 14) + }, + { + rrn: 'UPI52608012', + upiRef: 'OKHDFC520260812', + payerVpa: 'ramesh.k@okhdfcbank', + payerName: 'Ramesh Krishnan', + payerBranch: 'Chennai — T. Nagar', + payerApp: 'GPay', + payeeVpa: 'bbasket@axl', + payeeName: 'Big Basket', + payeeBank: 'Axis Bank', + payeeType: 'MERCHANT', + txnType: 'P2M', + amount: 1380, + valueDate: vd(5), + state: 'SUCCESS', + sentTimestamp: dt(5, 8, 49) + }, + { + rrn: 'UPI52608013', + upiRef: 'OKAXIS520260813', + payerVpa: 'lakshmi.n@upi', + payerName: 'Lakshmi Naidu', + payerBranch: 'Hyderabad — Banjara', + payerApp: 'PhonePe', + payeeVpa: 'spectra.cc@axl', + payeeName: 'Spectra Components', + payeeBank: 'State Bank of India', + payeeType: 'MERCHANT', + txnType: 'P2M', + amount: 28999, + valueDate: vd(5), + state: 'EXPIRED', + failureCode: 'U23', + failureReason: 'Collect Request Expired', + sentTimestamp: dt(5, 15, 18) + }, + { + rrn: 'UPI52608014', + upiRef: 'OKICICI520260814', + payerVpa: 'arjun.b@oklicici', + payerName: 'Arjun Banerjee', + payerBranch: 'Kolkata — Park Street', + payerApp: 'GPay', + payeeVpa: 'aircel@okhdfcbank', + payeeName: 'Airtel Recharge', + payeeBank: 'HDFC Bank', + payeeType: 'MERCHANT', + txnType: 'P2M', + amount: 349, + valueDate: vd(6), + state: 'SUCCESS', + sentTimestamp: dt(6, 10, 2) + }, + { + rrn: 'UPI52608015', + upiRef: 'OKAXIS520260815', + payerVpa: 'vikram.j@axl', + payerName: 'Vikram Joshi', + payerBranch: 'Pune — Koregaon Park', + payerApp: 'WhatsApp', + payeeVpa: 'neha.k@upi', + payeeName: 'Neha Kapoor', + payeeBank: 'HDFC Bank', + payeeType: 'PERSON', + txnType: 'P2P', + amount: 1750, + valueDate: vd(6), + state: 'SUCCESS', + sentTimestamp: dt(6, 17, 41) + } + ]; +} diff --git a/src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.html b/src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.html new file mode 100644 index 000000000..aaff1cec8 --- /dev/null +++ b/src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.html @@ -0,0 +1,152 @@ + + +
+
+
+ +
+

Balance Certificate

+
Generate an "as-on-date" balance certificate for any deposit / current account.
+
+
+
+ + +
+
+ +
+
{{ filteredRecords.length | number }}
+
Accounts
+
+
+
+ +
+
₹{{ totalBalance() | number: '1.0-0' }}
+
Balance (as on {{ asOnDate | date: 'd MMM y' }})
+
+
+
+ +
+
{{ asOnDate | date: 'd MMM y' }}
+
Snapshot Date
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + @if (search || accountTypeFilter) { + + } +
+ + +
+
+ + + + + + + + + + + + + + + @for (r of filteredRecords; track r.accountNo) { + + + + + + + + + + + } + @if (filteredRecords.length === 0) { + + + + } + +
Customer · A/CTypeBranchOpenedAvailable Bal.Accrued Int.Ledger Bal.Actions
+
{{ r.customerName }}
+
{{ r.accountNo }} · {{ r.customerId }} · PAN {{ r.customerPan }}
+ @if (r.jointHolders) { +
Jointly with {{ r.jointHolders }}
+ } +
+ {{ accountTypeLabel(r.accountType) }} +
{{ r.productName }}
+
{{ r.branch }}{{ r.openedOn | date: 'd MMM y' }} + ₹{{ r.availableBalance | number: '1.2-2' }} + + @if (r.accruedInterest > 0) { + ₹{{ r.accruedInterest | number: '1.2-2' }} + } @else { + — + } + ₹{{ r.ledgerBalance | number: '1.2-2' }} +
+ + +
+
+ + No accounts match the current filters. +
+
+
+
diff --git a/src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.ts b/src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.ts new file mode 100644 index 000000000..ccfd806e5 --- /dev/null +++ b/src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.ts @@ -0,0 +1,234 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { + CertificatePreviewDialogComponent, + CertificatePreviewDialogData +} from '../certificate-preview-dialog/certificate-preview-dialog.component'; +import { downloadBalanceCertificatePdf } from '../certificate-pdf'; + +export type BalanceAccountType = 'SAVINGS' | 'CURRENT' | 'FIXED_DEPOSIT' | 'RD' | 'NRE' | 'NRO'; + +export interface BalanceCertificateRecord { + accountNo: string; + customerId: string; + customerName: string; + customerPan: string; + customerAddress: string; + branch: string; + accountType: BalanceAccountType; + productName: string; + openedOn: string; + /** Balance available as on the snapshot date. */ + availableBalance: number; + /** Total ledger balance (incl. accrued interest, hold amounts). */ + ledgerBalance: number; + /** Accrued but uncredited interest. */ + accruedInterest: number; + /** Interest rate p.a. (for FDs / RD / interest-bearing accounts). */ + interestRate?: number; + /** Joint-holder names if any. */ + jointHolders?: string; +} + +@Component({ + selector: 'mifosx-balance-certificate', + templateUrl: './balance-certificate.component.html', + styleUrls: ['../certificates.shared.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class BalanceCertificateComponent implements OnInit { + private dialog = inject(MatDialog); + private snack = inject(MatSnackBar); + + // ── Filters ── + search = ''; + accountTypeFilter: '' | BalanceAccountType = ''; + /** Snapshot date for the balance "as on". */ + asOnDate = todayIso(); + + records: BalanceCertificateRecord[] = []; + + readonly accountTypeOptions: { value: BalanceAccountType; label: string }[] = [ + { value: 'SAVINGS', label: 'Savings' }, + { value: 'CURRENT', label: 'Current' }, + { value: 'FIXED_DEPOSIT', label: 'Fixed Deposit' }, + { value: 'RD', label: 'Recurring Deposit' }, + { value: 'NRE', label: 'NRE Savings' }, + { value: 'NRO', label: 'NRO Savings' } + ]; + + ngOnInit(): void { + this.records = seedBalanceCertificates(); + } + + get filteredRecords(): BalanceCertificateRecord[] { + const q = this.search.trim().toLowerCase(); + return this.records.filter((r) => { + if (this.accountTypeFilter && r.accountType !== this.accountTypeFilter) return false; + if (!q) return true; + return ( + r.accountNo.toLowerCase().includes(q) || + r.customerName.toLowerCase().includes(q) || + r.customerId.toLowerCase().includes(q) || + r.customerPan.toLowerCase().includes(q) + ); + }); + } + + accountTypeLabel(t: BalanceAccountType): string { + return this.accountTypeOptions.find((o) => o.value === t)?.label || t; + } + + totalBalance(): number { + return this.filteredRecords.reduce((s, r) => s + r.availableBalance, 0); + } + + clearFilters(): void { + this.search = ''; + this.accountTypeFilter = ''; + this.asOnDate = todayIso(); + } + + viewCertificate(r: BalanceCertificateRecord): void { + const data: CertificatePreviewDialogData = { + kind: 'balance', + balance: { record: r, asOnDate: this.asOnDate } + }; + this.dialog.open(CertificatePreviewDialogComponent, { + width: '880px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false, + panelClass: 'cert-preview-dialog', + data + }); + } + + downloadCertificate(r: BalanceCertificateRecord): void { + downloadBalanceCertificatePdf(r, this.asOnDate); + this.snack.open(`Balance Certificate (${r.accountNo}) downloaded.`, 'OK', { duration: 2400 }); + } +} + +function todayIso(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +// ───────────────────── Mock seed (top-level export) ───────────────────── +export function seedBalanceCertificates(): BalanceCertificateRecord[] { + return [ + { + accountNo: 'SB10029384', + customerId: 'CUST112233', + customerName: 'Rohan S. Mehta', + customerPan: 'ABCPM1234E', + customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069', + branch: 'Mumbai — Andheri (E)', + accountType: 'SAVINGS', + productName: 'Savings — Premium', + openedOn: '2018-04-22', + availableBalance: 482500, + ledgerBalance: 482500, + accruedInterest: 1820, + interestRate: 3.5, + jointHolders: 'Priya Mehta' + }, + { + accountNo: 'CA20034112', + customerId: 'CUST334455', + customerName: 'Anita Verma', + customerPan: 'EFGHV5678A', + customerAddress: 'B-12, Connaught Pl, New Delhi 110001', + branch: 'New Delhi — Connaught Place', + accountType: 'CURRENT', + productName: 'Current — Business', + openedOn: '2019-08-11', + availableBalance: 1245300, + ledgerBalance: 1245300, + accruedInterest: 0 + }, + { + accountNo: 'FD50200077', + customerId: 'CUST556677', + customerName: 'Karthik Rao', + customerPan: 'PQRSR2233Z', + customerAddress: '24, MG Road, Bengaluru 560001', + branch: 'Bengaluru — MG Road', + accountType: 'FIXED_DEPOSIT', + productName: 'FD — Senior 18 months', + openedOn: '2024-02-15', + availableBalance: 500000, + ledgerBalance: 526500, + accruedInterest: 26500, + interestRate: 7.25 + }, + { + accountNo: 'RD60012345', + customerId: 'CUST667788', + customerName: 'Lakshmi Naidu', + customerPan: 'XYZAN7788Q', + customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034', + branch: 'Hyderabad — Banjara Hills', + accountType: 'RD', + productName: 'Recurring Deposit — 24m', + openedOn: '2023-08-18', + availableBalance: 180000, + ledgerBalance: 188750, + accruedInterest: 8750, + interestRate: 6.85 + }, + { + accountNo: 'NRE70010023', + customerId: 'CUST889900', + customerName: 'Vikram Joshi', + customerPan: 'LMNOJ9988X', + customerAddress: 'Office 3B, Koregaon Park Road, Pune 411001', + branch: 'Pune — Koregaon Park', + accountType: 'NRE', + productName: 'NRE Savings — Premium', + openedOn: '2020-11-05', + availableBalance: 3850000, + ledgerBalance: 3850000, + accruedInterest: 12400, + interestRate: 3.5 + }, + { + accountNo: 'SB88990022', + customerId: 'CUST990011', + customerName: 'Arjun Banerjee', + customerPan: 'TUVWB4455F', + customerAddress: '42, Park Street, Kolkata 700016', + branch: 'Kolkata — Park Street', + accountType: 'SAVINGS', + productName: 'Savings — Regular', + openedOn: '2021-09-08', + availableBalance: 92300, + ledgerBalance: 92300, + accruedInterest: 450, + interestRate: 3.0 + } + ]; +} diff --git a/src/app/letter-mgmt/certificates/certificate-pdf.ts b/src/app/letter-mgmt/certificates/certificate-pdf.ts new file mode 100644 index 000000000..cbdcba191 --- /dev/null +++ b/src/app/letter-mgmt/certificates/certificate-pdf.ts @@ -0,0 +1,1058 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Shared jsPDF builders for the certificate pages — Loan Interest + Share. + * Output mirrors the on-screen preview: serif body, coloured accent strip, + * framed inner border, summary table with accent-coloured total row, and a + * circular "common seal" stamp in the sign-off block. + */ + +import { jsPDF } from 'jspdf'; + +import { FyInterestBreakup, LoanInterestRecord } from './loan-interest/loan-interest.component'; +import { ShareCertificateRecord } from './share-certificate/share-certificate.component'; +import { BalanceCertificateRecord } from './balance-certificate/balance-certificate.component'; +import { Form60Record } from './form-60/form-60.component'; +import { Form15Kind, Form15ghRecord } from './form-15gh/form-15gh.component'; +import { HypothecationRecord } from './hypothecation/hypothecation.component'; +import { NocRecord } from './noc/noc.component'; + +const BANK_NAME = 'Nexora Co-operative Bank Ltd.'; +const BANK_ADDRESS = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051'; +const BANK_REG = 'RBI Lic. No. NX-2018/MH/482'; +const BANK_CONTACT = 'info@nexorabank.in · 022 6789 0000'; + +const C_TEXT: [number, number, number] = [ + 15, + 23, + 42 +]; +const C_MUTED: [number, number, number] = [ + 100, + 116, + 139 +]; +const C_BORDER: [number, number, number] = [ + 226, + 232, + 240 +]; +const C_LABEL_BG: [number, number, number] = [ + 248, + 250, + 252 +]; + +const ACCENT_BLUE: [number, number, number] = [ + 30, + 64, + 175 +]; +const ACCENT_AMBER: [number, number, number] = [ + 217, + 119, + 6 +]; +const ACCENT_TEAL: [number, number, number] = [ + 13, + 148, + 136 +]; +const ACCENT_RED: [number, number, number] = [ + 185, + 28, + 28 +]; +const ACCENT_GREEN: [number, number, number] = [ + 21, + 128, + 61 +]; + +function tint(rgb: [number, number, number]): [number, number, number] { + return [ + Math.round(255 - (255 - rgb[0]) * 0.12), + Math.round(255 - (255 - rgb[1]) * 0.12), + Math.round(255 - (255 - rgb[2]) * 0.12) + ]; +} + +function formatLongDate(iso: string): string { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' }); +} + +function today(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +interface PdfCtx { + doc: jsPDF; + pageW: number; + pageH: number; + margin: number; + contentW: number; + accent: [number, number, number]; + accentTint: [number, number, number]; +} + +function setText(c: PdfCtx, rgb: [number, number, number]): void { + c.doc.setTextColor(rgb[0], rgb[1], rgb[2]); +} +function setFill(c: PdfCtx, rgb: [number, number, number]): void { + c.doc.setFillColor(rgb[0], rgb[1], rgb[2]); +} +function setDraw(c: PdfCtx, rgb: [number, number, number]): void { + c.doc.setDrawColor(rgb[0], rgb[1], rgb[2]); +} + +/** Common letterhead + title pill + meta row + decorative inner-frame border. */ +function paintLetterhead( + c: PdfCtx, + titleText: string, + refLabel: string, + refValue: string, + rightLabel: string, + rightValue: string +): number { + // Accent top stripe + setFill(c, c.accent); + c.doc.rect(0, 0, c.pageW, 4, 'F'); + + // Decorative inner border (mirrors the page-frame in the preview dialog) + setDraw(c, C_BORDER); + c.doc.setLineWidth(0.3); + c.doc.rect(c.margin - 4, 14, c.pageW - (c.margin - 4) * 2, c.pageH - 14 - 16); + + // Letterhead text + let y = 24; + c.doc.setFont('times', 'bold'); + c.doc.setFontSize(19); + setText(c, c.accent); + c.doc.text(BANK_NAME, c.pageW / 2, y, { align: 'center' }); + y += 5.5; + c.doc.setFont('helvetica', 'normal'); + c.doc.setFontSize(9); + setText(c, C_MUTED); + c.doc.text(BANK_ADDRESS, c.pageW / 2, y, { align: 'center' }); + y += 4; + c.doc.text(`${BANK_REG} · ${BANK_CONTACT}`, c.pageW / 2, y, { align: 'center' }); + y += 5; + setDraw(c, C_BORDER); + c.doc.setLineWidth(0.3); + c.doc.line(c.margin, y, c.pageW - c.margin, y); + y += 7; + + // Title pill (rounded rect, centered) + c.doc.setFont('helvetica', 'bold'); + c.doc.setFontSize(11.5); + const pillTxt = titleText.toUpperCase(); + const pillW = c.doc.getTextWidth(pillTxt) + 14; + const pillH = 8; + const pillX = (c.pageW - pillW) / 2; + setFill(c, c.accentTint); + c.doc.roundedRect(pillX, y - 5, pillW, pillH, pillH / 2, pillH / 2, 'F'); + setText(c, c.accent); + c.doc.text(pillTxt, c.pageW / 2, y + 0.5, { align: 'center' }); + y += 11; + + // Meta strip + const colW = c.contentW / 3; + c.doc.setFont('helvetica', 'bold'); + c.doc.setFontSize(8); + setText(c, C_MUTED); + c.doc.text('CERTIFICATE REFERENCE', c.margin, y); + c.doc.text('DATE OF ISSUE', c.margin + colW, y); + c.doc.text(rightLabel.toUpperCase(), c.margin + colW * 2, y); + y += 5; + setText(c, C_TEXT); + c.doc.setFont('courier', 'bold'); + c.doc.setFontSize(10.5); + c.doc.text(refValue, c.margin, y + 0.5); + c.doc.setFont('helvetica', 'bold'); + c.doc.setFontSize(10.5); + c.doc.text(formatLongDate(today()), c.margin + colW, y + 0.5); + + // Right column gets a tinted pill (FY or Folio) + const rPillTxt = rightValue; + c.doc.setFont('helvetica', 'bold'); + c.doc.setFontSize(9); + const rPillW = c.doc.getTextWidth(rPillTxt) + 10; + const rPillH = 6; + const rPillX = c.margin + colW * 2; + const rPillY = y - 4; + setFill(c, c.accentTint); + c.doc.roundedRect(rPillX, rPillY, rPillW, rPillH, rPillH / 2, rPillH / 2, 'F'); + setText(c, c.accent); + c.doc.text(rPillTxt, rPillX + rPillW / 2, rPillY + rPillH / 2 + 1.3, { align: 'center' }); + + y += 7; + setDraw(c, C_BORDER); + c.doc.setLineDashPattern( + [ + 0.8, + 0.8 + ], + 0 + ); + c.doc.line(c.margin, y, c.pageW - c.margin, y); + c.doc.setLineDashPattern([], 0); + return y + 6; +} + +/** Two-column addressee block: small "To," label on the left, customer info on the right. */ +function paintAddressee( + c: PdfCtx, + y: number, + toLabel: string, + name: string, + customerId: string, + pan: string, + address: string +): number { + c.doc.setFont('helvetica', 'bold'); + c.doc.setFontSize(11); + setText(c, C_TEXT); + c.doc.text(toLabel, c.margin, y); + + const x = c.margin + 22; + c.doc.setFont('helvetica', 'bold'); + c.doc.setFontSize(11); + c.doc.text(name, x, y); + y += 5; + + c.doc.setFont('helvetica', 'normal'); + c.doc.setFontSize(10); + setText(c, C_TEXT); + c.doc.text(`Customer ID: ${customerId} · PAN: ${pan}`, x, y); + y += 4.5; + + const addrLines = c.doc.splitTextToSize(address, c.contentW - 22); + c.doc.text(addrLines, x, y); + y += addrLines.length * 4.5 + 4; + return y; +} + +/** Body paragraph helper (Times, justified). */ +function paintPara(c: PdfCtx, y: number, text: string): number { + c.doc.setFont('times', 'normal'); + c.doc.setFontSize(11); + setText(c, C_TEXT); + const lines = c.doc.splitTextToSize(text, c.contentW); + c.doc.text(lines, c.margin, y, { align: 'justify', maxWidth: c.contentW }); + return y + lines.length * 5 + 4; +} + +/** 2x4 summary grid with accent-coloured total row. Rows are an array of + * [[labelL, valueL, labelR, valueR], ...] tuples plus an optional total. */ +function paintSummary( + c: PdfCtx, + y: number, + rows: [string, string, string, string][], + total: { label: string; value: string } +): number { + const rowH = 8; + const colHalf = c.contentW / 2; + const labelW = 44; + const valueW = colHalf - labelW; + const drawCell = (x: number, w: number, txt: string, isLabel: boolean): void => { + if (isLabel) { + setFill(c, C_LABEL_BG); + c.doc.rect(x, y, w, rowH, 'F'); + c.doc.setFont('helvetica', 'bold'); + c.doc.setFontSize(7.5); + setText(c, C_MUTED); + c.doc.text(txt, x + 2.5, y + rowH / 2 + 1.3); + } else { + c.doc.setFont('helvetica', 'normal'); + c.doc.setFontSize(10); + setText(c, C_TEXT); + c.doc.text(txt, x + 2.5, y + rowH / 2 + 1.3); + } + setDraw(c, C_BORDER); + c.doc.setLineWidth(0.2); + c.doc.rect(x, y, w, rowH); + }; + for (const r of rows) { + drawCell(c.margin, labelW, r[0], true); + drawCell(c.margin + labelW, valueW, r[1], false); + drawCell(c.margin + colHalf, labelW, r[2], true); + drawCell(c.margin + colHalf + labelW, valueW, r[3], false); + y += rowH; + } + // Total row + setFill(c, c.accent); + c.doc.rect(c.margin, y, c.contentW, rowH + 1, 'F'); + c.doc.setFont('helvetica', 'bold'); + c.doc.setFontSize(10); + setText( + c, + [ + 255, + 255, + 255 + ] + ); + c.doc.text(total.label, c.margin + 3, y + rowH / 2 + 2); + c.doc.text(total.value, c.margin + c.contentW - 3, y + rowH / 2 + 2, { align: 'right' }); + return y + rowH + 6; +} + +/** Sign-off + decorative circular seal on the right. */ +function paintSignoff(c: PdfCtx, y: number, branch: string): number { + if (y > c.pageH - c.margin - 50) { + c.doc.addPage(); + setFill(c, c.accent); + c.doc.rect(0, 0, c.pageW, 4, 'F'); + y = c.margin; + } + // Signature line on the left + setDraw(c, C_MUTED); + c.doc.setLineWidth(0.3); + c.doc.line(c.margin, y + 14, c.margin + 70, y + 14); + c.doc.setFont('helvetica', 'bold'); + c.doc.setFontSize(10); + setText(c, C_TEXT); + c.doc.text('Authorised Signatory', c.margin, y + 18); + c.doc.setFont('helvetica', 'normal'); + c.doc.setFontSize(8.5); + setText(c, C_MUTED); + c.doc.text(branch, c.margin, y + 22.5); + + // Decorative seal on the right (concentric circles) + const cx = c.pageW - c.margin - 16; + const cy = y + 10; + setDraw(c, c.accent); + c.doc.setLineWidth(0.6); + c.doc.circle(cx, cy, 14); + c.doc.setLineWidth(0.3); + c.doc.setLineDashPattern( + [ + 1, + 1 + ], + 0 + ); + c.doc.circle(cx, cy, 11); + c.doc.setLineDashPattern([], 0); + c.doc.setFont('helvetica', 'bold'); + c.doc.setFontSize(7); + setText(c, c.accent); + c.doc.text('NEXORA', cx, cy - 1, { align: 'center' }); + c.doc.setFontSize(5); + setText(c, C_MUTED); + c.doc.text('CO-OPERATIVE', cx, cy + 3, { align: 'center' }); + c.doc.text('BANK', cx, cy + 5.5, { align: 'center' }); + + return y + 28; +} + +/** Footer line at the bottom of the cert. */ +function paintFooter(c: PdfCtx, y: number): void { + setDraw(c, C_BORDER); + c.doc.setLineDashPattern( + [ + 0.8, + 0.8 + ], + 0 + ); + c.doc.line(c.margin, y, c.pageW - c.margin, y); + c.doc.setLineDashPattern([], 0); + y += 4.5; + c.doc.setFont('helvetica', 'normal'); + c.doc.setFontSize(8); + setText(c, C_MUTED); + c.doc.text( + `Issued by ${BANK_NAME} on ${formatLongDate(today())}. Computer generated — does not require physical signature for record purposes.`, + c.pageW / 2, + y, + { align: 'center' } + ); +} + +function newCtx(accent: [number, number, number]): PdfCtx { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + return { + doc, + pageW: doc.internal.pageSize.getWidth(), + pageH: doc.internal.pageSize.getHeight(), + margin: 20, + contentW: doc.internal.pageSize.getWidth() - 40, + accent, + accentTint: tint(accent) + }; +} + +// ───────────────────── Loan Interest Certificate ───────────────────── +export function buildLoanInterestPdf(record: LoanInterestRecord, fy: FyInterestBreakup): jsPDF { + const c = newCtx(ACCENT_BLUE); + + let y = paintLetterhead( + c, + 'Interest-Paid Certificate', + 'CERTIFICATE REFERENCE', + `INT-${record.accountNo}-${fy.fy.slice(3)}`, + 'Financial Year', + fy.fy + ); + + y = paintAddressee(c, y, 'To,', record.customerName, record.customerId, record.customerPan, record.customerAddress); + + y = paintPara( + c, + y, + `This is to certify that ${record.customerName} (Customer ID ${record.customerId}) is availing the following loan facility from our bank, and the interest paid by the above customer during the financial year ${fy.fy} is as detailed below.` + ); + + y = paintSummary( + c, + y, + [ + [ + 'LOAN ACCOUNT NO.', + record.accountNo, + 'BRANCH', + record.branch + ], + [ + 'LOAN TYPE', + record.loanType, + 'DISBURSED ON', + formatLongDate(record.disbursementDate) + ], + [ + 'SANCTIONED AMOUNT', + `Rs. ${record.sanctionedAmount.toLocaleString('en-IN')}`, + 'INTEREST RATE', + `${record.interestRate}% p.a.` + ], + [ + 'OPENING BALANCE', + `Rs. ${fy.openingBalance.toLocaleString('en-IN')}`, + 'CLOSING BALANCE', + `Rs. ${fy.closingBalance.toLocaleString('en-IN')}` + ], + [ + 'PRINCIPAL REPAID', + `Rs. ${fy.principalPaid.toLocaleString('en-IN')}`, + 'INTEREST PAID', + `Rs. ${fy.interestPaid.toLocaleString('en-IN')}` + ] + ], + { + label: `TOTAL INTEREST PAID DURING ${fy.fy}`, + value: `Rs. ${fy.interestPaid.toLocaleString('en-IN')}` + } + ); + + y = paintPara( + c, + y, + 'This certificate is issued at the request of the borrower for income-tax / financial record purposes and reflects the interest credited to the captioned loan account during the said financial year as per our books.' + ); + + y = paintSignoff(c, y + 6, record.branch); + paintFooter(c, y + 4); + return c.doc; +} + +export function downloadLoanInterestPdf(record: LoanInterestRecord, fy: FyInterestBreakup): void { + const doc = buildLoanInterestPdf(record, fy); + doc.save(`Interest-Certificate-${record.accountNo}-${fy.fy.replace(/\s+/g, '')}.pdf`); +} + +// ───────────────────── Share Certificate ───────────────────── +export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF { + const c = newCtx(ACCENT_AMBER); + + const classLabel = r.shareClass === 'PREFERENCE' ? 'Preference' : 'Ordinary'; + + let y = paintLetterhead(c, 'Share Certificate', 'CERTIFICATE REFERENCE', r.certificateNo, 'Folio No.', r.folioNo); + + y = paintAddressee(c, y, 'Issued To,', r.memberName, r.memberId, r.memberPan, r.memberAddress); + + // Centered, dramatic body paragraph (mirrors the highlighted "centered" style in the preview) + if (y > c.pageH - c.margin - 80) { + c.doc.addPage(); + setFill(c, c.accent); + c.doc.rect(0, 0, c.pageW, 4, 'F'); + y = c.margin; + } + const ctaText = `This is to certify that ${r.memberName} is the registered holder of ${r.shareCount.toLocaleString('en-IN')} (${classLabel}) shares of Rs. ${r.faceValue.toLocaleString('en-IN')} each, fully paid up, in ${BANK_NAME}, subject to the bye-laws of the bank.`; + const ctaLines = c.doc.splitTextToSize(ctaText, c.contentW - 10); + const ctaH = ctaLines.length * 5 + 6; + setFill(c, c.accentTint); + c.doc.rect(c.margin, y, c.contentW, ctaH, 'F'); + setFill(c, c.accent); + c.doc.rect(c.margin, y, 1.8, ctaH, 'F'); + c.doc.setFont('times', 'bold'); + c.doc.setFontSize(11); + setText(c, C_TEXT); + c.doc.text(ctaLines, c.margin + 6, y + 5, { align: 'justify', maxWidth: c.contentW - 10 }); + y += ctaH + 6; + + y = paintSummary( + c, + y, + [ + [ + 'CERTIFICATE NO.', + r.certificateNo, + 'FOLIO NO.', + r.folioNo + ], + [ + 'BRANCH', + r.branch, + 'MEMBER SINCE', + formatLongDate(r.joiningDate) + ], + [ + 'SHARE CLASS', + classLabel, + 'DATE OF ISSUE', + formatLongDate(r.issueDate) + ], + [ + 'SHARE NUMBERS', + `${r.shareNoFrom.toLocaleString('en-IN')} – ${r.shareNoTo.toLocaleString('en-IN')}`, + 'DIVIDEND PAID', + `Rs. ${r.dividendYtd.toLocaleString('en-IN')}` + ] + ], + { + label: 'TOTAL FACE VALUE', + value: `Rs. ${(r.shareCount * r.faceValue).toLocaleString('en-IN')}` + } + ); + + y = paintPara( + c, + y, + `Given under the common seal of the bank this ${new Date().toLocaleDateString('en-IN', { + day: 'numeric', + month: 'long', + year: 'numeric' + })}.` + ); + + y = paintSignoff(c, y + 6, r.branch); + paintFooter(c, y + 4); + return c.doc; +} + +export function downloadShareCertificatePdf(r: ShareCertificateRecord): void { + const doc = buildShareCertificatePdf(r); + doc.save(`Share-Certificate-${r.certificateNo}.pdf`); +} + +// ───────────────────── Balance Certificate ───────────────────── +export function buildBalanceCertificatePdf(r: BalanceCertificateRecord, asOnDate: string): jsPDF { + const c = newCtx(ACCENT_BLUE); + + const typeLabel = ((): string => { + switch (r.accountType) { + case 'SAVINGS': + return 'Savings'; + case 'CURRENT': + return 'Current'; + case 'FIXED_DEPOSIT': + return 'Fixed Deposit'; + case 'RD': + return 'Recurring Deposit'; + case 'NRE': + return 'NRE Savings'; + case 'NRO': + return 'NRO Savings'; + } + })(); + + let y = paintLetterhead( + c, + 'Balance Certificate', + 'CERTIFICATE REFERENCE', + `BAL-${r.accountNo}-${asOnDate.replace(/-/g, '')}`, + 'As-on Date', + formatLongDate(asOnDate) + ); + + y = paintAddressee(c, y, 'To,', r.customerName, r.customerId, r.customerPan, r.customerAddress); + + y = paintPara( + c, + y, + `This is to certify that ${r.customerName} (Customer ID ${r.customerId}) maintains the following account with our bank, and the balance available in the said account as on ${formatLongDate(asOnDate)} is as detailed below.` + ); + + y = paintSummary( + c, + y, + [ + [ + 'ACCOUNT NO.', + r.accountNo, + 'BRANCH', + r.branch + ], + [ + 'ACCOUNT TYPE', + typeLabel, + 'PRODUCT', + r.productName + ], + [ + 'ACCOUNT OPENED', + formatLongDate(r.openedOn), + 'INTEREST RATE', + r.interestRate ? `${r.interestRate}% p.a.` : '—' + ], + [ + 'AVAILABLE BALANCE', + `Rs. ${r.availableBalance.toLocaleString('en-IN')}`, + 'ACCRUED INTEREST', + `Rs. ${r.accruedInterest.toLocaleString('en-IN')}` + ] + ], + { + label: `TOTAL LEDGER BALANCE AS ON ${formatLongDate(asOnDate)}`, + value: `Rs. ${r.ledgerBalance.toLocaleString('en-IN')}` + } + ); + + if (r.jointHolders) { + y = paintPara(c, y, `Joint holder(s): ${r.jointHolders}.`); + } + + y = paintPara( + c, + y, + 'This certificate is issued at the request of the customer for record purposes and reflects the balance in the said account as per our books as on the date mentioned above.' + ); + + y = paintSignoff(c, y + 6, r.branch); + paintFooter(c, y + 4); + return c.doc; +} + +export function downloadBalanceCertificatePdf(r: BalanceCertificateRecord, asOnDate: string): void { + const doc = buildBalanceCertificatePdf(r, asOnDate); + doc.save(`Balance-Certificate-${r.accountNo}-${asOnDate}.pdf`); +} + +// ───────────────────── Form 60 ───────────────────── +const ID_PROOF_LABEL: Record = { + AADHAAR: 'Aadhaar Card', + VOTER_ID: 'Voter Identity Card', + DRIVING_LICENSE: 'Driving License', + PASSPORT: 'Passport', + RATION: 'Ration Card' +}; + +export function buildForm60Pdf(r: Form60Record): jsPDF { + const c = newCtx(ACCENT_AMBER); + + let y = paintLetterhead( + c, + 'Form 60 Declaration', + 'CERTIFICATE REFERENCE', + `F60-${r.customerId}`, + 'PAN Status', + 'NOT ALLOTTED' + ); + + y = paintPara( + c, + y, + 'Declaration under Section 114B of the Income-tax Rules, 1962, to be made by a person who does not have a Permanent Account Number (PAN) and who enters into any transaction specified in rule 114B.' + ); + + // 14-row form structure rendered as a 2-col grid via paintSummary + y = paintSummary( + c, + y, + [ + [ + '1. FULL NAME', + r.customerName, + "2. FATHER'S / SPOUSE'S NAME", + r.fatherName + ], + [ + '3. DATE OF BIRTH', + formatLongDate(r.dob), + '4. CUSTOMER ID', + r.customerId + ], + [ + '5. ADDRESS', + r.customerAddress.length > 60 ? r.customerAddress.slice(0, 57) + '…' : r.customerAddress, + '6. BRANCH', + r.branch + ], + [ + '7. ID PROOF', + ID_PROOF_LABEL[r.idProofType] || r.idProofType, + '8. ID DOC NO.', + r.idProofNumber + ], + [ + '9. TRANSACTION TYPE', + r.transactionType.length > 50 ? r.transactionType.slice(0, 47) + '…' : r.transactionType, + '10. TRANSACTION DATE', + r.transactionDate ? formatLongDate(r.transactionDate) : '—' + ], + [ + '11. TRANSACTION AMOUNT', + `Rs. ${r.transactionAmount.toLocaleString('en-IN')}`, + '12. AGRI. INCOME', + `Rs. ${r.agriIncome.toLocaleString('en-IN')}` + ] + ], + { + label: 'ESTIMATED TOTAL INCOME (CURRENT FY)', + value: `Rs. ${r.estimatedIncome.toLocaleString('en-IN')}` + } + ); + + y = paintPara( + c, + y, + `I, ${r.customerName}, do hereby declare that the particulars given above are true and correct to the best of my knowledge and belief. I further declare that I have neither been assessed nor am liable to be assessed under the Income-tax Act, 1961, and that I have applied / not applied for a Permanent Account Number with the Income-tax Department.` + ); + + y = paintSignoff(c, y + 6, r.branch); + paintFooter(c, y + 4); + return c.doc; +} + +export function downloadForm60Pdf(r: Form60Record): void { + const doc = buildForm60Pdf(r); + doc.save(`Form-60-${r.customerId}.pdf`); +} + +// ───────────────────── Form 15G / 15H ───────────────────── +export function buildForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): jsPDF { + const c = newCtx(ACCENT_TEAL); + + const todayDob = new Date(); + const birth = new Date(r.dob); + let age = todayDob.getFullYear() - birth.getFullYear(); + const m = todayDob.getMonth() - birth.getMonth(); + if (m < 0 || (m === 0 && todayDob.getDate() < birth.getDate())) age--; + + let y = paintLetterhead( + c, + `Form ${kind} Declaration`, + 'CERTIFICATE REFERENCE', + `${kind}-${r.customerId}-${r.fy.replace(/\s+/g, '')}`, + 'Form', + kind + ); + + y = paintPara( + c, + y, + `Declaration under Section ${ + kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)' + } of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${ + kind === '15H' + ? 'applicable to resident individuals aged 60 years or above' + : 'applicable to resident individuals (other than companies / firms) below 60 years' + }.` + ); + + y = paintSummary( + c, + y, + [ + [ + '1. NAME OF ASSESSEE', + r.customerName, + '2. PAN OF ASSESSEE', + r.customerPan + ], + [ + '3. STATUS', + 'Individual', + '4. AGE', + `${age} years` + ], + [ + '5. PREVIOUS YEAR', + r.fy, + '6. CUSTOMER ID', + r.customerId + ], + [ + '7. ADDRESS', + r.customerAddress.length > 60 ? r.customerAddress.slice(0, 57) + '…' : r.customerAddress, + '8. BRANCH', + r.branch + ], + [ + '9. DEPOSIT PRINCIPAL', + `Rs. ${r.depositPrincipal.toLocaleString('en-IN')}`, + '10. ESTIMATED INTEREST', + `Rs. ${r.estimatedInterest.toLocaleString('en-IN')}` + ], + [ + '11. EST. TOTAL INCOME', + `Rs. ${r.estimatedIncome.toLocaleString('en-IN')}`, + '12. PREVIOUSLY ASSESSED?', + r.assessed ? 'Yes' : 'No' + ] + ], + { + label: 'TAX PAYABLE ON ESTIMATED INCOME', + value: 'NIL' + } + ); + + y = paintPara( + c, + y, + `I, ${r.customerName}, do hereby declare that the particulars given above are true and correct, and that the tax on my estimated total income for the previous year ${r.fy} will be NIL. I therefore request that no tax be deducted at source on the interest paid to me by the bank on the captioned deposits during the said previous year.` + ); + + y = paintSignoff(c, y + 6, r.branch); + paintFooter(c, y + 4); + return c.doc; +} + +export function downloadForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): void { + const doc = buildForm15ghPdf(r, kind); + doc.save(`Form-${kind}-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`); +} + +// ───────────────────── Hypothecation Certificate ───────────────────── +const ASSET_LABEL: Record = { + VEHICLE: 'Vehicle', + PLANT_MACHINERY: 'Plant & Machinery', + STOCK_DEBTORS: 'Stock / Debtors', + GOLD: 'Gold / Bullion', + EQUIPMENT: 'Equipment' +}; + +export function buildHypothecationPdf(r: HypothecationRecord): jsPDF { + const c = newCtx(ACCENT_RED); + + let y = paintLetterhead( + c, + 'Hypothecation Certificate', + 'CERTIFICATE REFERENCE', + r.certificateNo, + 'Status', + r.status === 'NOC_ISSUED' ? 'NOC ISSUED' : r.status + ); + + y = paintAddressee(c, y, 'Issued To,', r.customerName, r.customerId, r.customerPan, r.customerAddress); + + y = paintPara( + c, + y, + `This is to certify that the asset described below stands hypothecated to ${BANK_NAME} as security for the captioned loan facility extended to ${r.customerName}, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged.` + ); + + const rows: [string, string, string, string][] = [ + [ + 'CERTIFICATE NO.', + r.certificateNo, + 'LOAN ACCOUNT NO.', + r.accountNo + ], + [ + 'LOAN TYPE', + r.loanType, + 'BRANCH', + r.branch + ], + [ + 'SANCTIONED AMOUNT', + `Rs. ${r.sanctionedAmount.toLocaleString('en-IN')}`, + 'DISBURSED ON', + formatLongDate(r.disbursementDate) + ], + [ + 'ASSET TYPE', + ASSET_LABEL[r.assetType] || r.assetType, + 'IDENTIFIER', + r.assetIdentifier + ], + [ + 'ASSET DESCRIPTION', + r.assetDescription.length > 38 ? r.assetDescription.slice(0, 35) + '…' : r.assetDescription, + 'STATUS', + r.status === 'NOC_ISSUED' ? 'NOC ISSUED' : r.status + ] + ]; + + if (r.insuredValue > 0) { + rows.push([ + 'INSURED VALUE', + `Rs. ${r.insuredValue.toLocaleString('en-IN')}`, + 'INSURANCE VALID TILL', + formatLongDate(r.insuranceValidTill) + ]); + } + + y = paintSummary(c, y, rows, { + label: 'OUTSTANDING PRINCIPAL', + value: `Rs. ${r.outstandingPrincipal.toLocaleString('en-IN')}` + }); + + y = paintPara( + c, + y, + 'The lien on the said asset shall stand released only upon the bank issuing a No-Objection Certificate (NOC) after the entire outstanding under the captioned facility is paid in full, together with all interest, charges, and incidental costs.' + ); + + y = paintSignoff(c, y + 6, r.branch); + paintFooter(c, y + 4); + return c.doc; +} + +export function downloadHypothecationPdf(r: HypothecationRecord): void { + const doc = buildHypothecationPdf(r); + doc.save(`Hypothecation-${r.certificateNo}.pdf`); +} + +// ───────────────────── No-Objection Certificate (NOC) ───────────────────── +const NOC_LOAN_TYPE_LABEL: Record = { + AUTO: 'Auto Loan', + HOUSING: 'Housing Loan', + PERSONAL: 'Personal Loan', + EDUCATION: 'Education Loan', + GOLD: 'Gold Loan', + BUSINESS: 'Business Loan', + CC: 'Cash Credit', + OD: 'Overdraft' +}; + +const NOC_ASSET_LABEL: Record = { + VEHICLE: 'Vehicle', + PROPERTY: 'Property', + GOLD: 'Gold / Bullion', + EQUIPMENT: 'Equipment', + STOCK_DEBTORS: 'Stock / Debtors', + NONE: 'Unsecured' +}; + +export function buildNocPdf(r: NocRecord): jsPDF { + const c = newCtx(ACCENT_GREEN); + + const statusLabel = r.status === 'READY_TO_ISSUE' ? 'READY TO ISSUE' : r.status; + + let y = paintLetterhead(c, 'No-Objection Certificate', 'NOC REFERENCE', r.nocNo, 'Status', statusLabel); + + y = paintAddressee(c, y, 'Issued To,', r.customerName, r.customerId, r.customerPan, r.customerAddress); + + y = paintPara( + c, + y, + `This is to certify that the captioned loan account in the name of ${r.customerName} has been duly closed, and the bank has NO OBJECTION to the release of the lien hereinafter recorded over the asset described below. All dues under the said facility, including principal, interest, and incidental charges, have been received by the bank as on the closure date.` + ); + + const rows: [string, string, string, string][] = [ + [ + 'NOC NO.', + r.nocNo, + 'LOAN ACCOUNT NO.', + r.accountNo + ], + [ + 'LOAN TYPE', + NOC_LOAN_TYPE_LABEL[r.loanType] || r.loanType, + 'BRANCH', + r.branch + ], + [ + 'SANCTIONED AMOUNT', + `Rs. ${r.sanctionedAmount.toLocaleString('en-IN')}`, + 'DISBURSED ON', + formatLongDate(r.disbursementDate) + ] + ]; + + if (r.closureDate) { + rows.push([ + 'CLOSURE DATE', + formatLongDate(r.closureDate), + 'TOTAL PAID', + `Rs. ${r.totalPaid.toLocaleString('en-IN')}` + ]); + } + + if (r.assetType !== 'NONE') { + rows.push([ + 'ASSET TYPE', + NOC_ASSET_LABEL[r.assetType] || r.assetType, + 'IDENTIFIER', + r.assetIdentifier + ]); + rows.push([ + 'ASSET DESCRIPTION', + r.assetDescription.length > 38 ? r.assetDescription.slice(0, 35) + '…' : r.assetDescription, + 'FORWARD TO', + r.forwardTo + ? r.forwardTo.length > 38 + ? r.forwardTo.slice(0, 35) + '…' + : r.forwardTo + : '—' + ]); + } + + y = paintSummary(c, y, rows, { + label: r.assetType === 'NONE' ? 'ACCOUNT CLOSED — NO DUES OUTSTANDING' : 'LIEN RELEASED — NO OBJECTION TO TRANSFER', + value: 'NIL' + }); + + // Asset-type-specific closing paragraph + let closing: string; + switch (r.assetType) { + case 'VEHICLE': + closing = + 'The Regional Transport Authority is hereby requested to delete the hypothecation endorsement of the bank from the registration certificate of the captioned vehicle.'; + break; + case 'PROPERTY': + closing = + "The Sub-Registrar's office is hereby requested to delete the equitable / registered mortgage of the bank from the property records of the captioned property."; + break; + case 'GOLD': + closing = + 'The pledged gold ornaments described above may be released to the customer / authorised representative after due verification at the branch counter.'; + break; + case 'NONE': + closing = + 'This certificate is issued at the request of the customer for record purposes and confirms that no dues are outstanding under the captioned facility.'; + break; + default: + closing = + "The bank's lien over the captioned asset stands released with effect from the closure date, and the customer is free to deal with the said asset."; + break; + } + y = paintPara(c, y, closing); + + if (r.status === 'ISSUED' && r.issuedOn) { + y = paintPara(c, y, `Issued on: ${formatLongDate(r.issuedOn)}${r.issuedBy ? ' · By: ' + r.issuedBy : ''}.`); + } + + y = paintSignoff(c, y + 6, r.branch); + paintFooter(c, y + 4); + return c.doc; +} + +export function downloadNocPdf(r: NocRecord): void { + const doc = buildNocPdf(r); + doc.save(`NOC-${r.nocNo}.pdf`); +} diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html new file mode 100644 index 000000000..dc4970a06 --- /dev/null +++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html @@ -0,0 +1,703 @@ + + +
+ +
+
+ +
+
{{ title }}
+
{{ refLine }}
+
+
+
+ + + +
+
+ + +
+ +
+
{{ bankName }}
+
{{ bankAddress }}
+
{{ bankRegNo }} · {{ bankContact }}
+
+ + +
+ {{ title }} +
+ + +
+
+
Certificate Reference
+
+ @if (isInterest && interest) { + INT-{{ interest.record.accountNo }}-{{ interest.fy.fy | slice: 3 }} + } @else if (isShare && share) { + {{ share.certificateNo }} + } +
+
+
+
Date of Issue
+
{{ issuedOn | date: 'd MMMM y' }}
+
+
+
+ @if (isInterest) { + Financial Year + } @else if (isShare) { + Folio No. + } @else if (isBalance) { + As-on Date + } @else if (isForm60) { + Trigger + } @else if (isForm15gh) { + Form + } @else if (isHypothecation) { + Status + } @else if (isNoc) { + Status + } +
+
+ @if (isInterest && interest) { + {{ interest.fy.fy }} + } @else if (isShare && share) { + {{ share.folioNo }} + } @else if (isBalance && balance) { + {{ balance.asOnDate | date: 'd MMM y' }} + } @else if (isForm60 && form60) { + No PAN — Form 60 + } @else if (isForm15gh && form15gh) { + Form {{ form15gh.kind }} + } @else if (isHypothecation && hypothecation) { + {{ hypothecation.status }} + } @else if (isNoc && noc) { + + {{ noc.status === 'READY_TO_ISSUE' ? 'Ready to Issue' : noc.status }} + + } +
+
+
+ + + @if (isInterest && interest) { +
+
To,
+
+
+ {{ interest.record.customerName }} +
+
+ Customer ID: {{ interest.record.customerId }} · PAN: + {{ interest.record.customerPan }} +
+
{{ interest.record.customerAddress }}
+
+
+ +

+ This is to certify that {{ interest.record.customerName }} (Customer ID + {{ interest.record.customerId }}) is availing the following loan facility from our bank, and the interest paid by the above customer during the + financial year {{ interest.fy.fy }} is as detailed below. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Loan Account No. + {{ interest.record.accountNo }} + Branch{{ interest.record.branch }}
Loan Type{{ interest.record.loanType }}Disbursed On{{ interest.record.disbursementDate | date: 'd MMM y' }}
Sanctioned Amount₹{{ interest.record.sanctionedAmount | number: '1.2-2' }}Interest Rate{{ interest.record.interestRate }}% p.a.
Opening Balance ({{ interest.fy.fy | slice: 0 : 8 }})₹{{ interest.fy.openingBalance | number: '1.2-2' }}Closing Balance₹{{ interest.fy.closingBalance | number: '1.2-2' }}
Principal Repaid₹{{ interest.fy.principalPaid | number: '1.2-2' }}Interest Paid + ₹{{ interest.fy.interestPaid | number: '1.2-2' }} +
TOTAL INTEREST PAID DURING {{ interest.fy.fy }} + ₹{{ interest.fy.interestPaid | number: '1.2-2' }} +
+ +

+ This certificate is issued at the request of the borrower for income-tax / financial record purposes and + reflects the interest credited to the captioned loan account during the said financial year as per our books. +

+ } + + + @if (isShare && share) { +
+
Issued To,
+
+
+ {{ share.memberName }} +
+
+ Member ID: {{ share.memberId }} · PAN: + {{ share.memberPan }} +
+
{{ share.memberAddress }}
+
+
+ +

+ This is to certify that {{ share.memberName }} is the registered holder of + {{ share.shareCount | number }} ({{ classLabelText() }}) shares of + ₹{{ share.faceValue | number: '1.0-0' }} each, fully paid up, in {{ bankName }}, subject to the bye-laws of the bank. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Certificate No. + {{ share.certificateNo }} + Folio No.{{ share.folioNo }}
Branch{{ share.branch }}Member Since{{ share.joiningDate | date: 'd MMM y' }}
Share Class{{ classLabelText() }}Date of Issue{{ share.issueDate | date: 'd MMM y' }}
Share Numbers{{ share.shareNoFrom | number }} – {{ share.shareNoTo | number }}Dividend Paid (Cumulative)₹{{ share.dividendYtd | number: '1.0-0' }}
TOTAL FACE VALUE + ₹{{ share.shareCount * share.faceValue | number: '1.2-2' }} +
+ +

+ Given under the common seal of the bank this {{ issuedOn | date: 'd' }} day of {{ issuedOn | date: 'MMMM y' }}. +

+ } + + + @if (isBalance && balance) { +
+
To Whom It May Concern,
+
+
+ {{ balance.record.customerName }} +
+
+ Customer ID: {{ balance.record.customerId }} · PAN: + {{ balance.record.customerPan }} +
+
{{ balance.record.customerAddress }}
+ @if (balance.record.jointHolders) { +
Jointly with: {{ balance.record.jointHolders }}
+ } +
+
+ +

+ This is to certify that {{ balance.record.customerName }} (Customer ID + {{ balance.record.customerId }}) maintains the following account with our bank, and the balance available in the said account as on + {{ balance.asOnDate | date: 'd MMMM y' }} is as detailed below. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account No. + {{ balance.record.accountNo }} + Branch{{ balance.record.branch }}
Account Type{{ accountTypeLabelText() }}Product{{ balance.record.productName }}
Account Opened{{ balance.record.openedOn | date: 'd MMM y' }}Interest Rate + @if (balance.record.interestRate) { + {{ balance.record.interestRate }}% p.a. + } @else { + — + } +
Available Balance₹{{ balance.record.availableBalance | number: '1.2-2' }}Accrued Interest₹{{ balance.record.accruedInterest | number: '1.2-2' }}
TOTAL LEDGER BALANCE AS ON {{ balance.asOnDate | date: 'd MMM y' }} + ₹{{ balance.record.ledgerBalance | number: '1.2-2' }} +
+ +

+ This certificate is issued at the request of the customer for record purposes and reflects the balance available + in the said account as per our books as on the date mentioned above. +

+ } + + + @if (isForm60 && form60) { +

+ Declaration under Section 114B of the Income-tax Rules, 1962, to be made by a person who does + not have a Permanent Account Number (PAN) and who enters into any transaction specified in rule 114B. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1. Full Name (in Block Letters) + {{ form60.customerName }} +
2. Father's / Spouse's Name{{ form60.fatherName }}
3. Date of Birth{{ form60.dob | date: 'd MMM y' }}4. Customer ID{{ form60.customerId }}
5. Address{{ form60.customerAddress }}
6. ID Proof Submitted{{ idProofLabelText() }}7. ID Document No.{{ form60.idProofNumber }}
8. Nature of Transaction{{ form60.transactionType }}
9. Transaction Amount₹{{ form60.transactionAmount | number: '1.2-2' }}10. Transaction Date{{ form60.transactionDate | date: 'd MMM y' }}
11. Estimated Total Income (Current FY)₹{{ form60.estimatedIncome | number: '1.0-0' }}12. Agricultural Income₹{{ form60.agriIncome | number: '1.0-0' }}
PAN STATUS + NOT ALLOTTED — Form 60 declared +
+ +

+ I, {{ form60.customerName }}, do hereby declare that the particulars given above are true and correct to the best of my knowledge and + belief. I further declare that I have applied / not applied for a Permanent Account Number with the Income-tax + department, and that I have neither been assessed nor am liable to be assessed under the Income-tax Act, 1961. +

+ } + + + @if (isForm15gh && form15gh) { +

+ Declaration under Section {{ form15gh.kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)' }} + of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — + {{ + form15gh.kind === '15H' + ? 'applicable to resident individuals aged 60 years or above' + : 'applicable to resident individuals (other than companies / firms) below 60 years' + }}. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1. Name of Assessee + {{ form15gh.record.customerName }} +
2. PAN of Assessee{{ form15gh.record.customerPan }}3. StatusIndividual
4. Previous Year{{ form15gh.record.fy }}5. Age (years){{ form15gh.age }}
6. Address{{ form15gh.record.customerAddress }}
7. Branch{{ form15gh.record.branch }}8. Customer ID{{ form15gh.record.customerId }}
9. Deposit Principal₹{{ form15gh.record.depositPrincipal | number: '1.0-0' }}10. Estimated Interest ({{ form15gh.record.fy }}) + ₹{{ form15gh.record.estimatedInterest | number: '1.0-0' }} +
11. Estimated Total Income₹{{ form15gh.record.estimatedIncome | number: '1.0-0' }}12. Previously Assessed?{{ form15gh.record.assessed ? 'Yes' : 'No' }}
TAX PAYABLE ON ESTIMATED INCOMENIL
+ +

+ I, {{ form15gh.record.customerName }}, do hereby declare that the particulars given above are true and correct, and that the tax on my estimated + total income for the previous year {{ form15gh.record.fy }} will be NIL. I therefore request that no tax be + deducted at source on the interest paid to me by the bank on the captioned deposits during the said previous + year. +

+ } + + + @if (isHypothecation && hypothecation) { +
+
Issued To,
+
+
+ {{ hypothecation.customerName }} +
+
+ Customer ID: {{ hypothecation.customerId }} · PAN: + {{ hypothecation.customerPan }} +
+
{{ hypothecation.customerAddress }}
+
+
+ +

+ This is to certify that the asset described below stands hypothecated to + {{ bankName }} as security for the captioned loan facility extended to + {{ hypothecation.customerName }}, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @if (hypothecation.insuredValue > 0) { + + + + + + + } + + + + + +
Certificate No. + {{ hypothecation.certificateNo }} + Loan Account No.{{ hypothecation.accountNo }}
Loan Type{{ hypothecation.loanType }}Branch{{ hypothecation.branch }}
Sanctioned Amount₹{{ hypothecation.sanctionedAmount | number: '1.0-0' }}Disbursed On{{ hypothecation.disbursementDate | date: 'd MMM y' }}
Asset Type{{ assetLabelText() }}Identifier{{ hypothecation.assetIdentifier }}
Asset Description{{ hypothecation.assetDescription }}
Insured Value₹{{ hypothecation.insuredValue | number: '1.0-0' }}Insurance Valid Till{{ hypothecation.insuranceValidTill | date: 'd MMM y' }}
OUTSTANDING PRINCIPAL + ₹{{ hypothecation.outstandingPrincipal | number: '1.2-2' }} +
+ +

+ The lien on the said asset shall stand released only upon the bank issuing a No-Objection Certificate (NOC) + after the entire outstanding under the captioned facility is paid in full, together with all interest, charges, + and incidental costs. +

+ } + + + @if (isNoc && noc) { +
+
Issued To,
+
+
+ {{ noc.customerName }} +
+
+ Customer ID: {{ noc.customerId }} · PAN: + {{ noc.customerPan }} +
+
{{ noc.customerAddress }}
+
+
+ +

+ This is to certify that the captioned loan account in the name of + {{ noc.customerName }} + has been + @if (noc.status === 'ISSUED') { + duly closed and the bank + } @else { + closed and the bank + } + has NO OBJECTION to the release of the lien hereinafter recorded over the asset described + below. All dues under the said facility, including principal, interest, and incidental charges, have been + received by the bank as on the closure date. +

+ + + + + + + + + + + + + + + + + + + + + + @if (noc.closureDate) { + + + + + + + } + @if (noc.assetType !== 'NONE') { + + + + + + + + + + + } + @if (noc.forwardTo) { + + + + + } + + + + + +
NOC No. + {{ noc.nocNo }} + Loan Account No.{{ noc.accountNo }}
Loan Type{{ nocLoanTypeLabel() }}Branch{{ noc.branch }}
Sanctioned Amount₹{{ noc.sanctionedAmount | number: '1.0-0' }}Disbursed On{{ noc.disbursementDate | date: 'd MMM y' }}
Account Closed On + {{ noc.closureDate | date: 'd MMM y' }} + Total Paid₹{{ noc.totalPaid | number: '1.2-2' }}
Asset Type{{ nocAssetLabel() }}Identifier{{ noc.assetIdentifier }}
Asset Description{{ noc.assetDescription }}
Forward To{{ noc.forwardTo }}
+ @if (noc.assetType === 'NONE') { + ACCOUNT CLOSED — NO DUES OUTSTANDING + } @else { + LIEN RELEASED — NO OBJECTION TO TRANSFER + } + NIL
+ +

+ @if (noc.assetType === 'VEHICLE') { + The Regional Transport Authority is hereby requested to delete the hypothecation endorsement of the bank from + the registration certificate of the captioned vehicle. + } @else if (noc.assetType === 'PROPERTY') { + The Sub-Registrar's office is hereby requested to delete the equitable / registered mortgage of the bank from + the property records of the captioned property. + } @else if (noc.assetType === 'GOLD') { + The pledged gold ornaments described above may be released to the customer / authorised representative after + due verification at the branch counter. + } @else if (noc.assetType !== 'NONE') { + The bank's lien over the captioned asset stands released with effect from the closure date, and the customer + is free to deal with the said asset. + } @else { + This certificate is issued at the request of the customer for record purposes and confirms that no dues are + outstanding under the captioned facility. + } +

+ + @if (noc.status === 'ISSUED' && noc.issuedOn) { +

+ Issued on: {{ noc.issuedOn | date: 'd MMM y' }} + @if (noc.issuedBy) { + · By: {{ noc.issuedBy }} + } +

+ } + } + + +
+
+
+
Authorised Signatory
+ @if (isInterest && interest) { +
{{ interest.record.branch }}
+ } + @if (isShare && share) { +
{{ share.branch }}
+ } + @if (isBalance && balance) { +
{{ balance.record.branch }}
+ } + @if (isForm60 && form60) { +
{{ form60.branch }}
+ } + @if (isForm15gh && form15gh) { +
{{ form15gh.record.branch }}
+ } + @if (isHypothecation && hypothecation) { +
{{ hypothecation.branch }}
+ } + @if (isNoc && noc) { +
{{ noc.branch }}
+ } +
+
+
+
+
NEXORA
+
+
CO-OPERATIVE BANK
+
+
+
+
+ + + +
+
diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss new file mode 100644 index 000000000..f099d25a1 --- /dev/null +++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss @@ -0,0 +1,607 @@ +/** + * 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/. + */ + +:host { + --cpd-blue: #1d4ed8; + --cpd-blue-d: #1e40af; + --cpd-blue-l: #eff6ff; + --cpd-blue-tint: #dbeafe; + --cpd-amber: #d97706; + --cpd-amber-l: #fef3c7; + --cpd-amber-tint: #fde68a; + --cpd-red: #b91c1c; + --cpd-red-l: #fee2e2; + --cpd-red-tint: #fecaca; + --cpd-green: #15803d; + --cpd-green-l: #dcfce7; + --cpd-purple: #6d28d9; + --cpd-g50: #f8fafc; + --cpd-g100: #f1f5f9; + --cpd-g200: #e2e8f0; + --cpd-g300: #cbd5e1; + --cpd-g500: #64748b; + --cpd-g700: #334155; + --cpd-g900: #0f172a; + + display: block; +} + +.cpd-root { + background: var(--cpd-g100); + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ───────── Toolbar (hidden on print) ───────── */ +.cpd-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 12px 16px; + background: #fff; + border-bottom: 1px solid var(--cpd-g200); + flex-shrink: 0; +} + +.cpd-toolbar-l { + display: flex; + align-items: center; + gap: 12px; +} + +.cpd-toolbar-ic { + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--cpd-blue-d); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 15px; +} + +.cpd-toolbar-t { + font-size: 14px; + font-weight: 800; + color: var(--cpd-g900); +} + +.cpd-toolbar-s { + font-size: 11.5px; + color: var(--cpd-g500); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.cpd-toolbar-r { + display: inline-flex; + gap: 8px; +} + +.cpd-tb-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border: 1px solid var(--cpd-g200); + background: #fff; + color: var(--cpd-g700); + font-size: 12.5px; + font-weight: 700; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + + fa-icon { + font-size: 12px; + color: var(--cpd-blue); + } + + &:hover { + background: var(--cpd-blue-l); + border-color: var(--cpd-blue-tint); + color: var(--cpd-blue-d); + } + + &.cpd-tb-x { + width: 34px; + padding: 0; + justify-content: center; + + &:hover { + background: var(--cpd-red-l); + color: var(--cpd-red); + border-color: var(--cpd-red-tint); + + fa-icon { + color: var(--cpd-red); + } + } + } +} + +/* ───────── Certificate page (A4 feel) ───────── */ +.cpd-page { + background: #fff; + border: 1px solid var(--cpd-g200); + padding: 38px 48px 24px; + margin: 18px auto; + width: min(720px, 94%); + border-top: 6px solid var(--cpd-blue-d); + font-family: Georgia, 'Times New Roman', Times, serif; + color: var(--cpd-g900); + line-height: 1.6; + overflow: auto; + position: relative; + + &.cpd-page--share, + &.cpd-page--amber { + border-top-color: var(--cpd-amber); + } + + &.cpd-page--teal { + border-top-color: #0d9488; + } + + &.cpd-page--red { + border-top-color: var(--cpd-red); + } + + &.cpd-page--green { + border-top-color: var(--cpd-green); + } + + /* Subtle decorative border inside the page edge to give the cert a "framed" feel. */ + &::before { + content: ''; + position: absolute; + inset: 18px; + border: 1px solid var(--cpd-g200); + pointer-events: none; + border-radius: 4px; + } + + &.cpd-page--share::before, + &.cpd-page--amber::before { + border-color: var(--cpd-amber-tint); + } + + &.cpd-page--teal::before { + border-color: #99f6e4; + } + + &.cpd-page--red::before { + border-color: var(--cpd-red-tint); + } + + &.cpd-page--green::before { + border-color: #bbf7d0; + } +} + +/* ───────── Letterhead ───────── */ +.cpd-letterhead { + text-align: center; + padding-bottom: 12px; + margin-bottom: 14px; + border-bottom: 1px solid var(--cpd-g200); +} + +.cpd-bank-name { + font-size: 19px; + font-weight: 800; + letter-spacing: 0.02em; + color: var(--cpd-blue-d); + + .cpd-page--share &, + .cpd-page--amber & { + color: var(--cpd-amber); + } + + .cpd-page--teal & { + color: #0d9488; + } + + .cpd-page--red & { + color: var(--cpd-red); + } + + .cpd-page--green & { + color: var(--cpd-green); + } +} + +.cpd-bank-sub { + font-size: 11.5px; + color: var(--cpd-g500); + margin-top: 2px; + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +/* ───────── Title strip ───────── */ +.cpd-title-strip { + text-align: center; + margin: 6px 0 14px; + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +.cpd-title-l { + display: inline-block; + padding: 5px 18px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 800; + letter-spacing: 0.15em; + text-transform: uppercase; + background: var(--cpd-blue-l); + color: var(--cpd-blue-d); + border: 1px solid var(--cpd-blue-tint); + + .cpd-page--share &, + .cpd-page--amber & { + background: var(--cpd-amber-l); + color: var(--cpd-amber); + border-color: var(--cpd-amber-tint); + } + + .cpd-page--teal & { + background: #ccfbf1; + color: #0d9488; + border-color: #99f6e4; + } + + .cpd-page--red & { + background: var(--cpd-red-l); + color: var(--cpd-red); + border-color: var(--cpd-red-tint); + } + + .cpd-page--green & { + background: var(--cpd-green-l); + color: var(--cpd-green); + border-color: #bbf7d0; + } +} + +.cpd-stage-pill--teal { + background: #ccfbf1; + color: #0d9488; +} + +.cpd-stage-pill--red { + background: var(--cpd-red-l); + color: var(--cpd-red); +} + +.cpd-stage-pill--green { + background: var(--cpd-green-l); + color: var(--cpd-green); +} + +/* ───────── Meta row ───────── */ +.cpd-meta-row { + display: flex; + justify-content: space-between; + gap: 16px; + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 12px; + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px dashed var(--cpd-g200); + + > div { + display: flex; + flex-direction: column; + gap: 2px; + } +} + +.cpd-meta-l { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--cpd-g500); +} + +.cpd-meta-v { + font-size: 12.5px; + color: var(--cpd-g900); + font-weight: 700; +} + +.cpd-stage-pill { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + background: var(--cpd-blue-l); + color: var(--cpd-blue-d); + + &--amber { + background: var(--cpd-amber-l); + color: var(--cpd-amber); + } +} + +/* ───────── Addressee + body ───────── */ +.cpd-block { + display: flex; + gap: 8px; + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 13px; + margin-bottom: 14px; +} + +.cpd-block-l { + font-weight: 700; + color: var(--cpd-g700); + width: 70px; + flex-shrink: 0; +} + +.cpd-block-v { + flex: 1; + color: var(--cpd-g900); + line-height: 1.5; +} + +.cpd-para { + margin: 12px 0; + font-size: 13px; + text-align: justify; + color: var(--cpd-g900); + + &--centered { + text-align: center; + font-size: 13.5px; + padding: 6px 12px; + background: var(--cpd-amber-l); + border-left: 4px solid var(--cpd-amber); + border-radius: 0 6px 6px 0; + } +} + +.cpd-amount { + color: var(--cpd-blue-d); + font-variant-numeric: tabular-nums; +} + +/* ───────── Summary table ───────── */ +.cpd-summary { + width: 100%; + margin: 14px 0; + border-collapse: collapse; + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 12px; + + td { + padding: 8px 10px; + border: 1px solid var(--cpd-g200); + vertical-align: top; + color: var(--cpd-g900); + } + + td:nth-child(odd) { + background: var(--cpd-g50); + color: var(--cpd-g500); + font-weight: 700; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.04em; + width: 22%; + } + + .mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + + .cpd-summary-total td { + background: var(--cpd-blue-l); + color: var(--cpd-blue-d); + font-weight: 800; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + + &:last-child { + text-align: right; + font-variant-numeric: tabular-nums; + } + } + + .cpd-page--share & .cpd-summary-total td { + background: var(--cpd-amber-l); + color: var(--cpd-amber); + } +} + +/* ───────── Sign-off + bank seal ───────── */ +.cpd-signoff { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 24px; + margin-top: 26px; +} + +.cpd-sign-block { + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +.cpd-sig-line { + width: 220px; + border-top: 1px solid var(--cpd-g500); + margin-bottom: 5px; +} + +.cpd-sig-l { + font-size: 12px; + font-weight: 700; + color: var(--cpd-g900); +} + +.cpd-sig-sub { + font-size: 11px; + color: var(--cpd-g500); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +/* Decorative round "common seal" for the share certificate (also looks good on + the interest certificate). Pure CSS, prints fine. */ +.cpd-seal-ring { + width: 96px; + height: 96px; + border-radius: 50%; + border: 2px solid var(--cpd-amber); + display: flex; + align-items: center; + justify-content: center; + position: relative; + background: #fff; + + .cpd-page--interest & { + border-color: var(--cpd-blue); + } + + &::after { + content: ''; + position: absolute; + inset: 5px; + border-radius: 50%; + border: 1px dashed var(--cpd-amber); + + .cpd-page--interest & { + border-color: var(--cpd-blue); + } + } +} + +.cpd-seal-inner { + text-align: center; + position: relative; + z-index: 1; +} + +.cpd-seal-bank { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.18em; + color: var(--cpd-amber); + + .cpd-page--interest & { + color: var(--cpd-blue); + } +} + +.cpd-seal-line { + width: 36px; + border-top: 1px solid currentcolor; + margin: 3px auto; + color: var(--cpd-amber); + + .cpd-page--interest & { + color: var(--cpd-blue); + } +} + +.cpd-seal-sub { + font-size: 7.5px; + font-weight: 800; + letter-spacing: 0.12em; + color: var(--cpd-g500); +} + +/* ───────── Footer ───────── */ +.cpd-footer { + margin-top: 24px; + padding-top: 10px; + border-top: 1px dashed var(--cpd-g200); + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 10.5px; + color: var(--cpd-g500); + text-align: center; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +/* ───────── Print — only the page, no toolbar / dialog chrome ───────── */ +@media print { + :host { + background: #fff; + } + + .no-print { + display: none !important; + } + + .cpd-root { + background: #fff; + max-height: none; + overflow: visible; + height: auto; + } + + .cpd-page { + border: 0; + margin: 0; + padding: 24px 32px; + width: 100%; + box-shadow: none; + border-top: 4px solid var(--cpd-blue-d); + + &.cpd-page--share { + border-top-color: var(--cpd-amber); + } + } +} diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.ts b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.ts new file mode 100644 index 000000000..43bd6ce5d --- /dev/null +++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.ts @@ -0,0 +1,322 @@ +/** + * 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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { FyInterestBreakup, LoanInterestRecord } from '../loan-interest/loan-interest.component'; +import { ShareCertificateRecord } from '../share-certificate/share-certificate.component'; +import { BalanceCertificateRecord } from '../balance-certificate/balance-certificate.component'; +import { Form60Record } from '../form-60/form-60.component'; +import { Form15Kind, Form15ghRecord } from '../form-15gh/form-15gh.component'; +import { HypothecationRecord } from '../hypothecation/hypothecation.component'; +import { NocRecord } from '../noc/noc.component'; +import { + downloadBalanceCertificatePdf, + downloadForm15ghPdf, + downloadForm60Pdf, + downloadHypothecationPdf, + downloadLoanInterestPdf, + downloadNocPdf, + downloadShareCertificatePdf +} from '../certificate-pdf'; + +export interface InterestPreviewPayload { + record: LoanInterestRecord; + fy: FyInterestBreakup; +} + +export interface BalancePreviewPayload { + record: BalanceCertificateRecord; + asOnDate: string; +} + +export interface Form15ghPreviewPayload { + record: Form15ghRecord; + kind: Form15Kind; + age: number; +} + +export type CertificateKind = 'interest' | 'share' | 'balance' | 'form60' | 'form15gh' | 'hypothecation' | 'noc'; + +export interface CertificatePreviewDialogData { + kind: CertificateKind; + loanInterest?: InterestPreviewPayload; + shareCertificate?: ShareCertificateRecord; + balance?: BalancePreviewPayload; + form60?: Form60Record; + form15gh?: Form15ghPreviewPayload; + hypothecation?: HypothecationRecord; + noc?: NocRecord; +} + +@Component({ + selector: 'mifosx-certificate-preview-dialog', + templateUrl: './certificate-preview-dialog.component.html', + styleUrls: ['./certificate-preview-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FaIconComponent, + MatTooltip + ] +}) +export class CertificatePreviewDialogComponent { + private dialogRef = inject(MatDialogRef); + data: CertificatePreviewDialogData = inject(MAT_DIALOG_DATA); + + readonly bankName = 'Nexora Co-operative Bank Ltd.'; + readonly bankAddress = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051'; + readonly bankRegNo = 'RBI Lic. No. NX-2018/MH/482'; + readonly bankContact = 'info@nexorabank.in · 022 6789 0000'; + + get isInterest(): boolean { + return this.data.kind === 'interest'; + } + get isShare(): boolean { + return this.data.kind === 'share'; + } + get isBalance(): boolean { + return this.data.kind === 'balance'; + } + get isForm60(): boolean { + return this.data.kind === 'form60'; + } + get isForm15gh(): boolean { + return this.data.kind === 'form15gh'; + } + get isHypothecation(): boolean { + return this.data.kind === 'hypothecation'; + } + get isNoc(): boolean { + return this.data.kind === 'noc'; + } + + get interest(): InterestPreviewPayload | undefined { + return this.data.loanInterest; + } + get share(): ShareCertificateRecord | undefined { + return this.data.shareCertificate; + } + get balance(): BalancePreviewPayload | undefined { + return this.data.balance; + } + get form60(): Form60Record | undefined { + return this.data.form60; + } + get form15gh(): Form15ghPreviewPayload | undefined { + return this.data.form15gh; + } + get hypothecation(): HypothecationRecord | undefined { + return this.data.hypothecation; + } + get noc(): NocRecord | undefined { + return this.data.noc; + } + + /** Tone class on `.cpd-page` — drives the accent stripe + seal + pill colour. */ + get pageToneClass(): string { + switch (this.data.kind) { + case 'share': + case 'form60': + return 'cpd-page--amber'; + case 'form15gh': + return 'cpd-page--teal'; + case 'hypothecation': + return 'cpd-page--red'; + case 'noc': + return 'cpd-page--green'; + case 'balance': + case 'interest': + default: + return ''; + } + } + + get title(): string { + switch (this.data.kind) { + case 'interest': + return 'Interest-Paid Certificate'; + case 'share': + return 'Share Certificate'; + case 'balance': + return 'Balance Certificate'; + case 'form60': + return 'Form 60 Declaration'; + case 'form15gh': + return this.form15gh?.kind === '15H' ? 'Form 15H Declaration' : 'Form 15G Declaration'; + case 'hypothecation': + return 'Hypothecation Certificate'; + case 'noc': + return 'No-Objection Certificate'; + } + } + + get refLine(): string { + if (this.isInterest && this.interest) { + return `${this.interest.record.accountNo} · ${this.interest.fy.fy}`; + } + if (this.isShare && this.share) { + return `${this.share.certificateNo} · Folio ${this.share.folioNo}`; + } + if (this.isBalance && this.balance) { + return `${this.balance.record.accountNo} · as on ${this.balance.asOnDate}`; + } + if (this.isForm60 && this.form60) { + return `${this.form60.customerId} · ${this.form60.transactionType}`; + } + if (this.isForm15gh && this.form15gh) { + return `${this.form15gh.record.customerId} · ${this.form15gh.kind} · ${this.form15gh.record.fy}`; + } + if (this.isHypothecation && this.hypothecation) { + return `${this.hypothecation.certificateNo} · ${this.hypothecation.accountNo}`; + } + if (this.isNoc && this.noc) { + return `${this.noc.nocNo} · ${this.noc.accountNo}`; + } + return ''; + } + + get issuedOn(): string { + return new Date().toISOString().slice(0, 10); + } + + close(): void { + this.dialogRef.close(); + } + + printCertificate(): void { + window.print(); + } + + downloadPdf(): void { + if (this.isInterest && this.interest) { + downloadLoanInterestPdf(this.interest.record, this.interest.fy); + } else if (this.isShare && this.share) { + downloadShareCertificatePdf(this.share); + } else if (this.isBalance && this.balance) { + downloadBalanceCertificatePdf(this.balance.record, this.balance.asOnDate); + } else if (this.isForm60 && this.form60) { + downloadForm60Pdf(this.form60); + } else if (this.isForm15gh && this.form15gh) { + downloadForm15ghPdf(this.form15gh.record, this.form15gh.kind); + } else if (this.isHypothecation && this.hypothecation) { + downloadHypothecationPdf(this.hypothecation); + } else if (this.isNoc && this.noc) { + downloadNocPdf(this.noc); + } + } + + nocLoanTypeLabel(): string { + const t = this.noc?.loanType; + switch (t) { + case 'AUTO': + return 'Auto Loan'; + case 'HOUSING': + return 'Housing Loan'; + case 'PERSONAL': + return 'Personal Loan'; + case 'EDUCATION': + return 'Education Loan'; + case 'GOLD': + return 'Gold Loan'; + case 'BUSINESS': + return 'Business Loan'; + case 'CC': + return 'Cash Credit'; + case 'OD': + return 'Overdraft'; + default: + return ''; + } + } + + nocAssetLabel(): string { + const t = this.noc?.assetType; + switch (t) { + case 'VEHICLE': + return 'Vehicle'; + case 'PROPERTY': + return 'Property'; + case 'GOLD': + return 'Gold / Bullion'; + case 'EQUIPMENT': + return 'Equipment'; + case 'STOCK_DEBTORS': + return 'Stock / Debtors'; + case 'NONE': + return 'Unsecured'; + default: + return ''; + } + } + + classLabelText(): string { + return this.share?.shareClass === 'PREFERENCE' ? 'Preference' : 'Ordinary'; + } + + accountTypeLabelText(): string { + const t = this.balance?.record.accountType; + switch (t) { + case 'SAVINGS': + return 'Savings'; + case 'CURRENT': + return 'Current'; + case 'FIXED_DEPOSIT': + return 'Fixed Deposit'; + case 'RD': + return 'Recurring Deposit'; + case 'NRE': + return 'NRE Savings'; + case 'NRO': + return 'NRO Savings'; + default: + return ''; + } + } + + idProofLabelText(): string { + const t = this.form60?.idProofType; + switch (t) { + case 'AADHAAR': + return 'Aadhaar Card'; + case 'VOTER_ID': + return 'Voter Identity Card'; + case 'DRIVING_LICENSE': + return 'Driving License'; + case 'PASSPORT': + return 'Passport'; + case 'RATION': + return 'Ration Card'; + default: + return ''; + } + } + + assetLabelText(): string { + const t = this.hypothecation?.assetType; + switch (t) { + case 'VEHICLE': + return 'Vehicle'; + case 'PLANT_MACHINERY': + return 'Plant & Machinery'; + case 'STOCK_DEBTORS': + return 'Stock / Debtors'; + case 'GOLD': + return 'Gold / Bullion'; + case 'EQUIPMENT': + return 'Equipment'; + default: + return ''; + } + } +} diff --git a/src/app/letter-mgmt/certificates/certificates.shared.scss b/src/app/letter-mgmt/certificates/certificates.shared.scss new file mode 100644 index 000000000..c93a0bdc7 --- /dev/null +++ b/src/app/letter-mgmt/certificates/certificates.shared.scss @@ -0,0 +1,470 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Shared list-page styling for the Letter-Mgmt certificate pages + * (Loan Interest, Share Certificate, etc.). Mirrors the visual language of + * the notice list (header + filters + table) so the module feels cohesive. + */ + +:host { + --cert-blue: #1d4ed8; + --cert-blue-d: #1e40af; + --cert-blue-l: #eff6ff; + --cert-blue-tint: #dbeafe; + --cert-teal: #0d9488; + --cert-teal-l: #ccfbf1; + --cert-green: #15803d; + --cert-green-l: #dcfce7; + --cert-amber: #d97706; + --cert-amber-l: #fef3c7; + --cert-red: #b91c1c; + --cert-red-l: #fee2e2; + --cert-red-tint: #fecaca; + --cert-purple: #6d28d9; + --cert-purple-l: #ede9fe; + --cert-g50: #f8fafc; + --cert-g100: #f1f5f9; + --cert-g200: #e2e8f0; + --cert-g300: #cbd5e1; + --cert-g500: #64748b; + --cert-g700: #334155; + --cert-g900: #0f172a; + + display: block; +} + +.cert-root { + padding: 20px 24px 32px; + max-width: 1340px; + margin: 0 auto; +} + +/* ───────── Header banner ───────── */ +.cert-head { + display: flex; + align-items: center; + gap: 16px; + padding: 18px 22px; + border-radius: 14px; + background: linear-gradient(135deg, var(--cert-blue-l) 0%, #fff 65%); + border: 1px solid var(--cert-blue-tint); + margin-bottom: 16px; +} + +.cert-head-l { + display: flex; + align-items: center; + gap: 14px; +} + +.cert-head-ic { + width: 48px; + height: 48px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + background: var(--cert-blue-d); + color: #fff; + font-size: 20px; +} + +.cert-head-t { + margin: 0 0 3px; + font-size: 17px; + font-weight: 800; + color: var(--cert-g900); +} + +.cert-head-s { + font-size: 12.5px; + color: var(--cert-g500); + line-height: 1.5; +} + +/* ───────── KPI strip ───────── */ +.cert-kpis { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 14px; + + @media (width <= 900px) { + grid-template-columns: repeat(2, 1fr); + } +} + +.cert-kpi { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 12px; + background: #fff; + border: 1px solid var(--cert-g200); + + &--blue { + background: var(--cert-blue-l); + border-color: var(--cert-blue-tint); + } + + &--green { + background: var(--cert-green-l); + border-color: #bbf7d0; + } + + &--teal { + background: var(--cert-teal-l); + border-color: #99f6e4; + } + + &--amber { + background: var(--cert-amber-l); + border-color: #fde68a; + } +} + +.cert-kpi-ic { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: rgb(255 255 255 / 70%); + font-size: 16px; + + .cert-kpi--blue & { + color: var(--cert-blue); + } + + .cert-kpi--green & { + color: var(--cert-green); + } + + .cert-kpi--teal & { + color: var(--cert-teal); + } + + .cert-kpi--amber & { + color: var(--cert-amber); + } +} + +.cert-kpi-n { + font-size: 17px; + font-weight: 800; + color: var(--cert-g900); + font-variant-numeric: tabular-nums; + line-height: 1.15; +} + +.cert-kpi-l { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--cert-g500); + margin-top: 2px; +} + +/* ───────── Filters ───────── */ +.cert-filters { + display: flex; + align-items: flex-end; + gap: 12px; + padding: 14px; + background: #fff; + border: 1px solid var(--cert-g200); + border-radius: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.cert-filter { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1 1 0; + min-width: 220px; + position: relative; + + label { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--cert-g500); + } + + input, + select { + box-sizing: border-box; + width: 100%; + height: 36px; + padding: 0 12px; + border: 1px solid var(--cert-g200); + border-radius: 8px; + background: #fff; + font-size: 13px; + color: var(--cert-g900); + outline: none; + transition: border-color 0.15s ease; + appearance: none; + + &:focus { + border-color: var(--cert-blue); + } + } + + select { + padding-right: 34px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%2364748b' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='1 2 6 7 11 2'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 12px 8px; + cursor: pointer; + } +} + +.cert-input-ic { + position: relative; + width: 100%; + + fa-icon { + position: absolute; + left: 11px; + top: 50%; + transform: translateY(-50%); + color: var(--cert-g500); + font-size: 12px; + pointer-events: none; + z-index: 1; + } + + input { + padding-left: 32px; + } +} + +.cert-clear { + height: 36px; + padding: 0 14px; + border: 1px solid var(--cert-g200); + border-radius: 8px; + background: #fff; + color: var(--cert-g700); + font-size: 12px; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + + &:hover { + background: var(--cert-red-l); + color: var(--cert-red); + border-color: var(--cert-red-tint); + } +} + +/* ───────── Table ───────── */ +.cert-card { + background: #fff; + border: 1px solid var(--cert-g200); + border-radius: 12px; + overflow: hidden; +} + +.cert-table-wrap { + overflow-x: auto; +} + +.cert-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + padding: 12px; + background: var(--cert-g50); + color: var(--cert-g500); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 10.5px; + border-bottom: 1px solid var(--cert-g200); + white-space: nowrap; + } + + td { + padding: 12px; + border-bottom: 1px solid var(--cert-g100); + vertical-align: top; + color: var(--cert-g900); + } + + tr:last-child td { + border-bottom: 0; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + + .small { + font-size: 11.5px; + } + + .muted { + color: var(--cert-g500); + } + + .col-actions { + width: 88px; + text-align: right; + white-space: nowrap; + } +} + +.cert-cust-name { + font-weight: 700; + color: var(--cert-g900); +} + +.cert-interest { + color: var(--cert-green); + font-weight: 800; +} + +/* Pills */ +.cert-pill { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + background: var(--cert-g100); + color: var(--cert-g700); + white-space: nowrap; + + &--type { + background: var(--cert-purple-l); + color: var(--cert-purple); + } + + &--green { + background: var(--cert-green-l); + color: var(--cert-green); + } + + &--blue { + background: var(--cert-blue-l); + color: var(--cert-blue-d); + } + + &--amber { + background: var(--cert-amber-l); + color: var(--cert-amber); + } +} + +.cert-rate-pill { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: var(--cert-blue-l); + color: var(--cert-blue-d); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Row action icon buttons */ +.cert-row-actions { + display: inline-flex; + gap: 4px; +} + +.cert-icon-btn { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--cert-g200); + background: #fff; + color: var(--cert-g500); + border-radius: 8px; + cursor: pointer; + font-size: 11.5px; + transition: + background 0.15s, + color 0.15s, + border-color 0.15s; + + &:hover:not(:disabled) { + background: var(--cert-blue-l); + color: var(--cert-blue-d); + border-color: var(--cert-blue-tint); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + &.primary { + background: var(--cert-blue-d); + color: #fff; + border-color: var(--cert-blue-d); + + &:hover:not(:disabled) { + background: var(--cert-blue); + border-color: var(--cert-blue); + } + } +} + +.cert-empty { + text-align: center; + padding: 28px 16px; + color: var(--cert-g500); + font-size: 13px; + + fa-icon { + margin-right: 6px; + color: var(--cert-blue); + } +} + +/* Share-certificate specific cells (used by share-certificate.component.html) */ +.cert-shares { + font-weight: 800; + color: var(--cert-blue-d); + font-variant-numeric: tabular-nums; +} + +.cert-folio { + font-weight: 700; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: var(--cert-g900); +} diff --git a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html new file mode 100644 index 000000000..be1dfe5de --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html @@ -0,0 +1,171 @@ + + +
+
+
+ +
+

Form 15G & 15H

+
+ TDS-waiver declarations for FD / RD interest income — + 15G for individuals below 60 years, 15H for senior citizens (60+). +
+
+
+
+ + +
+
+ +
+
{{ count15G() | number }}
+
Form 15G (under 60)
+
+
+
+ +
+
{{ count15H() | number }}
+
Form 15H (60+)
+
+
+
+ +
+
{{ countFiled() | number }}
+
Filed this FY
+
+
+
+ +
+
₹{{ totalInterest() | number: '1.0-0' }}
+
Projected Interest
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + @if (search || kindFilter || statusFilter) { + + } +
+ + +
+
+ + + + + + + + + + + + + + + @for (r of filteredRecords; track r.customerId) { + + + + + + + + + + + } + @if (filteredRecords.length === 0) { + + + + } + +
Customer · PANFormAgeBranchDeposit PrincipalEst. Interest ({{ records[0]?.fy }})StatusActions
+
{{ r.customerName }}
+
{{ r.customerId }} · PAN {{ r.customerPan }}
+
DOB {{ r.dob | date: 'd MMM y' }}
+
+ + Form {{ formKind(r) }} + + {{ age(r.dob) }} yrs{{ r.branch }}₹{{ r.depositPrincipal | number: '1.0-0' }} + ₹{{ r.estimatedInterest | number: '1.0-0' }} + + @if (r.status === 'FILED') { + Filed + @if (r.filedOn) { +
{{ r.filedOn | date: 'd MMM y' }}
+ } + } @else { + Pending + } +
+
+ + +
+
+ + No declarations match the current filters. +
+
+
+
diff --git a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts new file mode 100644 index 000000000..ac158ee14 --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts @@ -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/. + */ + +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { + CertificatePreviewDialogComponent, + CertificatePreviewDialogData +} from '../certificate-preview-dialog/certificate-preview-dialog.component'; +import { downloadForm15ghPdf } from '../certificate-pdf'; + +export type Form15Kind = '15G' | '15H'; +export type Form15Status = 'FILED' | 'PENDING'; + +export interface Form15ghRecord { + customerId: string; + customerName: string; + customerPan: string; + customerAddress: string; + dob: string; + branch: string; + /** Aggregate interest the bank will pay this FY. */ + estimatedInterest: number; + /** Customer's total estimated income for the FY. */ + estimatedIncome: number; + /** Sum of FD / RD principals tied to the declaration. */ + depositPrincipal: number; + fy: string; + status: Form15Status; + filedOn?: string; + /** "Yes / No" for: has any income tax been assessed for the customer + under the IT Act in the past six years? */ + assessed: boolean; +} + +@Component({ + selector: 'mifosx-form-15gh', + templateUrl: './form-15gh.component.html', + styleUrls: ['../certificates.shared.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class Form15ghComponent implements OnInit { + private dialog = inject(MatDialog); + private snack = inject(MatSnackBar); + + // ── Filters ── + search = ''; + /** Which form. Empty = both. */ + kindFilter: '' | Form15Kind = ''; + statusFilter: '' | Form15Status = ''; + + records: Form15ghRecord[] = []; + + ngOnInit(): void { + this.records = seedForm15gh(); + } + + /** Compute age in years from DoB. */ + age(dob: string): number { + if (!dob) return 0; + const today = new Date(); + const birth = new Date(dob); + let age = today.getFullYear() - birth.getFullYear(); + const m = today.getMonth() - birth.getMonth(); + if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--; + return age; + } + + /** 15H once 60+, 15G otherwise. */ + formKind(r: Form15ghRecord): Form15Kind { + return this.age(r.dob) >= 60 ? '15H' : '15G'; + } + + get filteredRecords(): Form15ghRecord[] { + const q = this.search.trim().toLowerCase(); + return this.records.filter((r) => { + if (this.kindFilter && this.formKind(r) !== this.kindFilter) return false; + if (this.statusFilter && r.status !== this.statusFilter) return false; + if (!q) return true; + return ( + r.customerName.toLowerCase().includes(q) || + r.customerId.toLowerCase().includes(q) || + r.customerPan.toLowerCase().includes(q) + ); + }); + } + + count15G(): number { + return this.records.filter((r) => this.formKind(r) === '15G').length; + } + + count15H(): number { + return this.records.filter((r) => this.formKind(r) === '15H').length; + } + + countFiled(): number { + return this.records.filter((r) => r.status === 'FILED').length; + } + + totalInterest(): number { + return this.filteredRecords.reduce((s, r) => s + r.estimatedInterest, 0); + } + + clearFilters(): void { + this.search = ''; + this.kindFilter = ''; + this.statusFilter = ''; + } + + viewCertificate(r: Form15ghRecord): void { + const data: CertificatePreviewDialogData = { + kind: 'form15gh', + form15gh: { record: r, kind: this.formKind(r), age: this.age(r.dob) } + }; + this.dialog.open(CertificatePreviewDialogComponent, { + width: '880px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false, + panelClass: 'cert-preview-dialog', + data + }); + } + + downloadCertificate(r: Form15ghRecord): void { + downloadForm15ghPdf(r, this.formKind(r)); + this.snack.open(`Form ${this.formKind(r)} for ${r.customerName} downloaded.`, 'OK', { duration: 2400 }); + } +} + +// ───────────────────── Mock seed (top-level export) ───────────────────── +export function seedForm15gh(): Form15ghRecord[] { + return [ + { + customerId: 'CUST112233', + customerName: 'Rohan S. Mehta', + customerPan: 'ABCPM1234E', + customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069', + dob: '1985-02-14', + branch: 'Mumbai — Andheri (E)', + estimatedInterest: 38500, + estimatedIncome: 850000, + depositPrincipal: 500000, + fy: 'FY 2025-26', + status: 'FILED', + filedOn: '2025-04-10', + assessed: true + }, + { + customerId: 'CUST556677', + customerName: 'Karthik Rao', + customerPan: 'PQRSR2233Z', + customerAddress: '24, MG Road, Bengaluru 560001', + dob: '1972-09-04', + branch: 'Bengaluru — MG Road', + estimatedInterest: 52800, + estimatedIncome: 320000, + depositPrincipal: 700000, + fy: 'FY 2025-26', + status: 'PENDING', + assessed: false + }, + { + customerId: 'CUST667788', + customerName: 'Lakshmi Naidu', + customerPan: 'XYZAN7788Q', + customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034', + dob: '1958-06-22', + branch: 'Hyderabad — Banjara Hills', + estimatedInterest: 88200, + estimatedIncome: 410000, + depositPrincipal: 1200000, + fy: 'FY 2025-26', + status: 'FILED', + filedOn: '2025-04-15', + assessed: true + }, + { + customerId: 'CUST334455', + customerName: 'Anita Verma', + customerPan: 'EFGHV5678A', + customerAddress: 'B-12, Connaught Pl, New Delhi 110001', + dob: '1962-11-30', + branch: 'New Delhi — Connaught Place', + estimatedInterest: 64500, + estimatedIncome: 280000, + depositPrincipal: 900000, + fy: 'FY 2025-26', + status: 'FILED', + filedOn: '2025-04-05', + assessed: false + }, + { + customerId: 'CUST990011', + customerName: 'Arjun Banerjee', + customerPan: 'TUVWB4455F', + customerAddress: '42, Park Street, Kolkata 700016', + dob: '1991-07-19', + branch: 'Kolkata — Park Street', + estimatedInterest: 14200, + estimatedIncome: 620000, + depositPrincipal: 200000, + fy: 'FY 2025-26', + status: 'PENDING', + assessed: true + }, + { + customerId: 'CUST330044', + customerName: 'Madhavi Singh', + customerPan: 'TUVWS3344K', + customerAddress: 'D-5, Greater Kailash, New Delhi 110048', + dob: '1955-03-08', + branch: 'New Delhi — Connaught Place', + estimatedInterest: 145000, + estimatedIncome: 320000, + depositPrincipal: 2000000, + fy: 'FY 2025-26', + status: 'FILED', + filedOn: '2025-04-02', + assessed: false + } + ]; +} diff --git a/src/app/letter-mgmt/certificates/form-60/form-60.component.html b/src/app/letter-mgmt/certificates/form-60/form-60.component.html new file mode 100644 index 000000000..c22787b84 --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-60/form-60.component.html @@ -0,0 +1,178 @@ + + +
+
+
+ +
+

Form 60 Declaration

+
+ Income Tax Rule 114B — required for any customer transacting above the threshold who does + not have a PAN on file. +
+
+
+
+ + +
+
+ +
+
{{ totalNoPan() | number }}
+
Customers without PAN
+
+
+
+ +
+
{{ totalFiled() | number }}
+
Form 60 Filed
+
+
+
+ +
+
{{ totalPending() | number }}
+
Filing Pending
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + @if (search || idProofFilter || panFilter !== 'MISSING') { + + } +
+ + +
+
+ + + + + + + + + + + + + + + @for (r of filteredRecords; track r.customerId) { + + + + + + + + + + + } + @if (filteredRecords.length === 0) { + + + + } + +
Customer · IDID ProofBranchTrigger TransactionAmountPAN StatusForm 60Actions
+
{{ r.customerName }}
+
{{ r.customerId }} · DOB {{ r.dob | date: 'd MMM y' }}
+
S/o, D/o, W/o: {{ r.fatherName }}
+
+ {{ idProofLabel(r.idProofType) }} +
{{ r.idProofNumber }}
+
{{ r.branch }} + {{ r.transactionType }} + @if (r.transactionDate) { +
{{ r.transactionDate | date: 'd MMM y' }}
+ } +
+ @if (r.transactionAmount > 0) { + ₹{{ r.transactionAmount | number: '1.0-0' }} + } @else { + — + } + + @if (r.hasPan) { + PAN on file + } @else { + + No PAN + + } + + @if (r.hasPan) { + N/A + } @else if (r.filedOn) { + Filed · {{ r.filedOn | date: 'd MMM' }} + } @else { + Pending + } + +
+ + +
+
+ + No customers match the current filters. +
+
+
+
diff --git a/src/app/letter-mgmt/certificates/form-60/form-60.component.ts b/src/app/letter-mgmt/certificates/form-60/form-60.component.ts new file mode 100644 index 000000000..335cdc762 --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-60/form-60.component.ts @@ -0,0 +1,236 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { + CertificatePreviewDialogComponent, + CertificatePreviewDialogData +} from '../certificate-preview-dialog/certificate-preview-dialog.component'; +import { downloadForm60Pdf } from '../certificate-pdf'; + +export type Form60IdProofType = 'AADHAAR' | 'VOTER_ID' | 'DRIVING_LICENSE' | 'PASSPORT' | 'RATION'; + +export interface Form60Record { + customerId: string; + customerName: string; + customerAddress: string; + fatherName: string; + dob: string; + branch: string; + /** True only when there's no PAN on file — Form 60 is required. */ + hasPan: boolean; + /** ID document used in lieu of PAN. */ + idProofType: Form60IdProofType; + idProofNumber: string; + /** Transaction reason that triggered Form 60 (cash >50k etc.). */ + transactionType: string; + transactionAmount: number; + transactionDate: string; + estimatedIncome: number; + agriIncome: number; + filedOn?: string; +} + +@Component({ + selector: 'mifosx-form-60', + templateUrl: './form-60.component.html', + styleUrls: ['../certificates.shared.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class Form60Component implements OnInit { + private dialog = inject(MatDialog); + private snack = inject(MatSnackBar); + + // ── Filters ── + search = ''; + idProofFilter: '' | Form60IdProofType = ''; + /** Default: only show customers without PAN (since Form 60 only applies to them). */ + panFilter: '' | 'MISSING' | 'ON_FILE' = 'MISSING'; + + records: Form60Record[] = []; + + readonly idProofOptions: { value: Form60IdProofType; label: string }[] = [ + { value: 'AADHAAR', label: 'Aadhaar Card' }, + { value: 'VOTER_ID', label: 'Voter ID' }, + { value: 'DRIVING_LICENSE', label: 'Driving License' }, + { value: 'PASSPORT', label: 'Passport' }, + { value: 'RATION', label: 'Ration Card' } + ]; + + ngOnInit(): void { + this.records = seedForm60(); + } + + get filteredRecords(): Form60Record[] { + const q = this.search.trim().toLowerCase(); + return this.records.filter((r) => { + if (this.panFilter === 'MISSING' && r.hasPan) return false; + if (this.panFilter === 'ON_FILE' && !r.hasPan) return false; + if (this.idProofFilter && r.idProofType !== this.idProofFilter) return false; + if (!q) return true; + return ( + r.customerName.toLowerCase().includes(q) || + r.customerId.toLowerCase().includes(q) || + r.idProofNumber.toLowerCase().includes(q) + ); + }); + } + + idProofLabel(t: Form60IdProofType): string { + return this.idProofOptions.find((o) => o.value === t)?.label || t; + } + + totalNoPan(): number { + return this.records.filter((r) => !r.hasPan).length; + } + + totalFiled(): number { + return this.records.filter((r) => !r.hasPan && !!r.filedOn).length; + } + + totalPending(): number { + return this.records.filter((r) => !r.hasPan && !r.filedOn).length; + } + + clearFilters(): void { + this.search = ''; + this.idProofFilter = ''; + this.panFilter = 'MISSING'; + } + + viewCertificate(r: Form60Record): void { + if (r.hasPan) { + this.snack.open(`${r.customerName} already has a PAN on file — Form 60 is not applicable.`, 'OK', { + duration: 3200 + }); + return; + } + const data: CertificatePreviewDialogData = { kind: 'form60', form60: r }; + this.dialog.open(CertificatePreviewDialogComponent, { + width: '880px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false, + panelClass: 'cert-preview-dialog', + data + }); + } + + downloadCertificate(r: Form60Record): void { + if (r.hasPan) { + this.snack.open(`${r.customerName} already has a PAN on file — Form 60 is not applicable.`, 'OK', { + duration: 3200 + }); + return; + } + downloadForm60Pdf(r); + this.snack.open(`Form 60 for ${r.customerName} downloaded.`, 'OK', { duration: 2400 }); + } +} + +// ───────────────────── Mock seed (top-level export) ───────────────────── +export function seedForm60(): Form60Record[] { + return [ + { + customerId: 'CUST220033', + customerName: 'Bluestone Trading Co. (Prop. Harish Iyer)', + customerAddress: '12, Brigade Road, Bengaluru 560025', + fatherName: 'K. R. Iyer', + dob: '1972-03-18', + branch: 'Bengaluru — MG Road', + hasPan: false, + idProofType: 'AADHAAR', + idProofNumber: 'XXXX-XXXX-7891', + transactionType: 'Cash deposit > ₹50,000', + transactionAmount: 75000, + transactionDate: '2025-05-10', + estimatedIncome: 850000, + agriIncome: 0, + filedOn: '2025-05-10' + }, + { + customerId: 'CUST120544', + customerName: 'Sundar Pillai', + customerAddress: '14, Pondy Bazaar, T. Nagar, Chennai 600017', + fatherName: 'M. Pillai', + dob: '1968-11-04', + branch: 'Chennai — T. Nagar', + hasPan: false, + idProofType: 'VOTER_ID', + idProofNumber: 'TNV1234567', + transactionType: 'NEFT credit', + transactionAmount: 250000, + transactionDate: '2025-04-22', + estimatedIncome: 320000, + agriIncome: 180000 + }, + { + customerId: 'CUST998812', + customerName: 'Kamla Devi', + customerAddress: 'Plot 22, Sec 14, Rohini, New Delhi 110085', + fatherName: 'Late Hari Lal', + dob: '1955-06-12', + branch: 'New Delhi — Connaught Place', + hasPan: false, + idProofType: 'AADHAAR', + idProofNumber: 'XXXX-XXXX-4421', + transactionType: 'FD booking > ₹50,000', + transactionAmount: 200000, + transactionDate: '2025-05-02', + estimatedIncome: 145000, + agriIncome: 0, + filedOn: '2025-05-02' + }, + { + customerId: 'CUST551177', + customerName: 'Ramesh Yadav', + customerAddress: 'Hno 122, Saket Nagar, Indore 452018', + fatherName: 'Banwari Yadav', + dob: '1980-08-25', + branch: 'Pune — Koregaon Park', + hasPan: false, + idProofType: 'DRIVING_LICENSE', + idProofNumber: 'MP09 20180012345', + transactionType: 'Cash deposit > ₹50,000', + transactionAmount: 85000, + transactionDate: '2025-05-15', + estimatedIncome: 410000, + agriIncome: 0 + }, + { + customerId: 'CUST112233', + customerName: 'Rohan S. Mehta', + customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069', + fatherName: 'Suresh Mehta', + dob: '1985-02-14', + branch: 'Mumbai — Andheri (E)', + hasPan: true, + idProofType: 'AADHAAR', + idProofNumber: 'XXXX-XXXX-9090', + transactionType: 'PAN on file — Form 60 not applicable', + transactionAmount: 0, + transactionDate: '', + estimatedIncome: 0, + agriIncome: 0 + } + ]; +} diff --git a/src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.html b/src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.html new file mode 100644 index 000000000..32516934b --- /dev/null +++ b/src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.html @@ -0,0 +1,169 @@ + + +
+
+
+ +
+

Hypothecation Certificate

+
+ Lien certificate for any loan where movable assets are hypothecated to the bank — vehicles, plant & + machinery, stock, gold, equipment. +
+
+
+
+ + +
+
+ +
+
{{ countActive() | number }}
+
Active Hypothecations
+
+
+
+ +
+
{{ countNoc() | number }}
+
NOCs Issued
+
+
+
+ +
+
₹{{ totalOutstanding() | number: '1.0-0' }}
+
Outstanding Principal
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + @if (search || assetFilter || statusFilter !== 'ACTIVE') { + + } +
+ + +
+
+ + + + + + + + + + + + + + + + @for (r of filteredRecords; track r.certificateNo) { + + + + + + + + + + + + } + @if (filteredRecords.length === 0) { + + + + } + +
Customer · Loan A/CCertificate No.AssetIdentifierSanctionedOutstandingInsuranceStatusActions
+
{{ r.customerName }}
+
{{ r.accountNo }} · {{ r.customerId }} · PAN {{ r.customerPan }}
+
{{ r.loanType }}
+
{{ r.certificateNo }} + {{ assetLabel(r.assetType) }} +
{{ r.assetDescription }}
+
{{ r.assetIdentifier }}₹{{ r.sanctionedAmount | number: '1.0-0' }} + ₹{{ r.outstandingPrincipal | number: '1.0-0' }} + + @if (r.insuredValue > 0) { + ₹{{ r.insuredValue | number: '1.0-0' }} +
valid till {{ r.insuranceValidTill | date: 'd MMM y' }}
+ } @else { + — + } +
+ @if (r.status === 'ACTIVE') { + Active + } @else if (r.status === 'NOC_ISSUED') { + NOC Issued + } @else { + Closed + } + +
+ + +
+
+ + No hypothecation records match the current filters. +
+
+
+
diff --git a/src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.ts b/src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.ts new file mode 100644 index 000000000..28f664fcf --- /dev/null +++ b/src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.ts @@ -0,0 +1,242 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { + CertificatePreviewDialogComponent, + CertificatePreviewDialogData +} from '../certificate-preview-dialog/certificate-preview-dialog.component'; +import { downloadHypothecationPdf } from '../certificate-pdf'; + +export type HypoAssetType = 'VEHICLE' | 'PLANT_MACHINERY' | 'STOCK_DEBTORS' | 'GOLD' | 'EQUIPMENT'; +export type HypoStatus = 'ACTIVE' | 'CLOSED' | 'NOC_ISSUED'; + +export interface HypothecationRecord { + certificateNo: string; + accountNo: string; + customerId: string; + customerName: string; + customerPan: string; + customerAddress: string; + branch: string; + loanType: string; + sanctionedAmount: number; + outstandingPrincipal: number; + disbursementDate: string; + /** Asset hypothecated to the bank. */ + assetType: HypoAssetType; + /** Free-text asset description (Maruti Swift VDi · 2022 · etc.). */ + assetDescription: string; + /** Vehicle registration, machine serial, etc. */ + assetIdentifier: string; + /** Insured up to ₹ (asset insurance for replacement). */ + insuredValue: number; + insuranceValidTill: string; + status: HypoStatus; +} + +@Component({ + selector: 'mifosx-hypothecation', + templateUrl: './hypothecation.component.html', + styleUrls: ['../certificates.shared.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class HypothecationCertificateComponent implements OnInit { + private dialog = inject(MatDialog); + private snack = inject(MatSnackBar); + + search = ''; + assetFilter: '' | HypoAssetType = ''; + statusFilter: '' | HypoStatus = 'ACTIVE'; + + records: HypothecationRecord[] = []; + + readonly assetOptions: { value: HypoAssetType; label: string }[] = [ + { value: 'VEHICLE', label: 'Vehicle' }, + { value: 'PLANT_MACHINERY', label: 'Plant & Machinery' }, + { value: 'STOCK_DEBTORS', label: 'Stock / Debtors' }, + { value: 'GOLD', label: 'Gold / Bullion' }, + { value: 'EQUIPMENT', label: 'Equipment' } + ]; + + ngOnInit(): void { + this.records = seedHypothecations(); + } + + get filteredRecords(): HypothecationRecord[] { + const q = this.search.trim().toLowerCase(); + return this.records.filter((r) => { + if (this.assetFilter && r.assetType !== this.assetFilter) return false; + if (this.statusFilter && r.status !== this.statusFilter) return false; + if (!q) return true; + return ( + r.certificateNo.toLowerCase().includes(q) || + r.accountNo.toLowerCase().includes(q) || + r.customerName.toLowerCase().includes(q) || + r.assetIdentifier.toLowerCase().includes(q) || + r.assetDescription.toLowerCase().includes(q) + ); + }); + } + + assetLabel(t: HypoAssetType): string { + return this.assetOptions.find((o) => o.value === t)?.label || t; + } + + countActive(): number { + return this.records.filter((r) => r.status === 'ACTIVE').length; + } + + countNoc(): number { + return this.records.filter((r) => r.status === 'NOC_ISSUED').length; + } + + totalOutstanding(): number { + return this.filteredRecords.reduce((s, r) => s + r.outstandingPrincipal, 0); + } + + clearFilters(): void { + this.search = ''; + this.assetFilter = ''; + this.statusFilter = 'ACTIVE'; + } + + viewCertificate(r: HypothecationRecord): void { + const data: CertificatePreviewDialogData = { kind: 'hypothecation', hypothecation: r }; + this.dialog.open(CertificatePreviewDialogComponent, { + width: '880px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false, + panelClass: 'cert-preview-dialog', + data + }); + } + + downloadCertificate(r: HypothecationRecord): void { + downloadHypothecationPdf(r); + this.snack.open(`Hypothecation Certificate ${r.certificateNo} downloaded.`, 'OK', { + duration: 2400 + }); + } +} + +// ───────────────────── Mock seed (top-level export) ───────────────────── +export function seedHypothecations(): HypothecationRecord[] { + return [ + { + certificateNo: 'HYP-2024-001023', + accountNo: 'LN50200077', + customerId: 'CUST556677', + customerName: 'Karthik Rao', + customerPan: 'PQRSR2233Z', + customerAddress: '24, MG Road, Bengaluru 560001', + branch: 'Bengaluru — MG Road', + loanType: 'Auto Loan', + sanctionedAmount: 850000, + outstandingPrincipal: 480000, + disbursementDate: '2023-01-20', + assetType: 'VEHICLE', + assetDescription: 'Maruti Suzuki Swift VXi · Manual · Petrol', + assetIdentifier: 'KA-01-MZ-4421', + insuredValue: 950000, + insuranceValidTill: '2026-01-19', + status: 'ACTIVE' + }, + { + certificateNo: 'HYP-2024-001024', + accountNo: 'LN70010023', + customerId: 'CUST889900', + customerName: 'Vikram Joshi', + customerPan: 'LMNOJ9988X', + customerAddress: 'Office 3B, Koregaon Park Road, Pune 411001', + branch: 'Pune — Koregaon Park', + loanType: 'Business Loan', + sanctionedAmount: 5500000, + outstandingPrincipal: 3950000, + disbursementDate: '2020-11-05', + assetType: 'PLANT_MACHINERY', + assetDescription: 'CNC milling machine · Haas VF-2SS · 2020', + assetIdentifier: 'SN-VF2SS-2020-114782', + insuredValue: 7200000, + insuranceValidTill: '2026-03-30', + status: 'ACTIVE' + }, + { + certificateNo: 'HYP-2024-001025', + accountNo: 'LN50200099', + customerId: 'CUST667788', + customerName: 'Lakshmi Naidu', + customerPan: 'XYZAN7788Q', + customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034', + branch: 'Hyderabad — Banjara Hills', + loanType: 'Gold Loan', + sanctionedAmount: 450000, + outstandingPrincipal: 180000, + disbursementDate: '2023-08-18', + assetType: 'GOLD', + assetDescription: '22 KT Gold Jewellery · Total 92.5 grams', + assetIdentifier: 'GL-PKT-7891 · Locker S-22', + insuredValue: 580000, + insuranceValidTill: '2026-08-17', + status: 'ACTIVE' + }, + { + certificateNo: 'HYP-2024-001026', + accountNo: 'LN20034112', + customerId: 'CUST334455', + customerName: 'Anita Verma', + customerPan: 'EFGHV5678A', + customerAddress: 'B-12, Connaught Pl, New Delhi 110001', + branch: 'New Delhi — Connaught Place', + loanType: 'Personal Loan', + sanctionedAmount: 600000, + outstandingPrincipal: 0, + disbursementDate: '2022-09-10', + assetType: 'STOCK_DEBTORS', + assetDescription: 'Trade receivables — invoices > 60 days', + assetIdentifier: 'POOL-AV-2022-001', + insuredValue: 0, + insuranceValidTill: '', + status: 'NOC_ISSUED' + }, + { + certificateNo: 'HYP-2024-001027', + accountNo: 'LN50100445', + customerId: 'CUST110022', + customerName: 'Spectra Components Pvt Ltd', + customerPan: 'AABCS1122F', + customerAddress: 'Plot 9, MIDC, Andheri (E), Mumbai 400093', + branch: 'Mumbai — Andheri (E)', + loanType: 'Cash Credit', + sanctionedAmount: 2500000, + outstandingPrincipal: 1820000, + disbursementDate: '2021-07-12', + assetType: 'STOCK_DEBTORS', + assetDescription: 'Stock-in-trade + book debts (paripassu)', + assetIdentifier: 'POOL-SCPL-2021-A', + insuredValue: 4500000, + insuranceValidTill: '2026-06-30', + status: 'ACTIVE' + } + ]; +} diff --git a/src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.html b/src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.html new file mode 100644 index 000000000..664320b0e --- /dev/null +++ b/src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.html @@ -0,0 +1,155 @@ + + +
+ +
+
+ +
+

Loan Interest Certificate

+
+ Generate and download interest-paid certificates per loan account, per financial year. +
+
+
+
+ + +
+
+ +
+
{{ filteredLoans.length | number }}
+
Loan Accounts
+
+
+
+ +
+
₹{{ totalInterestForFy() | number: '1.0-0' }}
+
Interest Paid ({{ fyFilter }})
+
+
+
+ +
+
₹{{ totalPrincipalForFy() | number: '1.0-0' }}
+
Principal Paid ({{ fyFilter }})
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + @if (search || loanTypeFilter) { + + } +
+ + +
+
+ + + + + + + + + + + + + + + + @for (l of filteredLoans; track l.accountNo) { + + + + + + + + + + + + } + @if (filteredLoans.length === 0) { + + + + } + +
Customer · Loan A/CTypeBranchSanctionedOutstandingInterest ({{ fyFilter }})Principal ({{ fyFilter }})RateActions
+
{{ l.customerName }}
+
{{ l.accountNo }} · {{ l.customerId }} · PAN {{ l.customerPan }}
+
+ {{ loanTypeLabel(l.loanType) }} + {{ l.branch }}₹{{ l.sanctionedAmount | number: '1.0-0' }} + ₹{{ outstandingPrincipal(l) | number: '1.0-0' }} + + ₹{{ selectedFyData(l)?.interestPaid | number: '1.0-0' }} + ₹{{ selectedFyData(l)?.principalPaid | number: '1.0-0' }} + {{ l.interestRate }}% p.a. + +
+ + +
+
+ + No loan accounts match the current filters. +
+
+
+
diff --git a/src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.ts b/src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.ts new file mode 100644 index 000000000..d3af41347 --- /dev/null +++ b/src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.ts @@ -0,0 +1,336 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { + CertificatePreviewDialogComponent, + CertificatePreviewDialogData +} from '../certificate-preview-dialog/certificate-preview-dialog.component'; +import { downloadLoanInterestPdf } from '../certificate-pdf'; + +export type LoanProductType = 'HOUSING' | 'PERSONAL' | 'AUTO' | 'EDUCATION' | 'GOLD' | 'BUSINESS'; + +export interface FyInterestBreakup { + /** Financial year label e.g. "FY 2024-25". */ + fy: string; + openingBalance: number; + closingBalance: number; + principalPaid: number; + interestPaid: number; +} + +export interface LoanInterestRecord { + accountNo: string; + customerId: string; + customerName: string; + customerPan: string; + customerAddress: string; + branch: string; + loanType: LoanProductType; + sanctionedAmount: number; + disbursementDate: string; + /** Interest rate p.a. (e.g. 8.65) */ + interestRate: number; + fy: FyInterestBreakup[]; +} + +@Component({ + selector: 'mifosx-loan-interest-certificate', + templateUrl: './loan-interest.component.html', + styleUrls: ['../certificates.shared.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class LoanInterestCertificateComponent implements OnInit { + private dialog = inject(MatDialog); + private snack = inject(MatSnackBar); + + // ── Filters ── + search = ''; + fyFilter = ''; + loanTypeFilter: '' | LoanProductType = ''; + + loans: LoanInterestRecord[] = []; + + /** All financial years present across the dataset (newest first). */ + fyOptions: string[] = []; + + readonly loanTypeOptions: { value: LoanProductType; label: string }[] = [ + { value: 'HOUSING', label: 'Housing Loan' }, + { value: 'PERSONAL', label: 'Personal Loan' }, + { value: 'AUTO', label: 'Auto Loan' }, + { value: 'EDUCATION', label: 'Education Loan' }, + { value: 'GOLD', label: 'Gold Loan' }, + { value: 'BUSINESS', label: 'Business Loan' } + ]; + + ngOnInit(): void { + this.loans = seedLoanInterestRecords(); + this.fyOptions = Array.from(new Set(this.loans.flatMap((l) => l.fy.map((f) => f.fy)))) + .sort() + .reverse(); + // Default to most-recent FY + if (this.fyOptions.length) { + this.fyFilter = this.fyOptions[0]; + } + } + + // ── Derived list ── + get filteredLoans(): LoanInterestRecord[] { + const q = this.search.trim().toLowerCase(); + return this.loans.filter((l) => { + if (this.loanTypeFilter && l.loanType !== this.loanTypeFilter) return false; + if (!q) return true; + return ( + l.accountNo.toLowerCase().includes(q) || + l.customerName.toLowerCase().includes(q) || + l.customerId.toLowerCase().includes(q) || + l.customerPan.toLowerCase().includes(q) + ); + }); + } + + loanTypeLabel(t: LoanProductType): string { + return this.loanTypeOptions.find((o) => o.value === t)?.label || t; + } + + selectedFyData(l: LoanInterestRecord): FyInterestBreakup | undefined { + return l.fy.find((f) => f.fy === this.fyFilter) || l.fy[0]; + } + + totalInterestForFy(): number { + return this.filteredLoans.reduce((s, l) => s + (this.selectedFyData(l)?.interestPaid || 0), 0); + } + + totalPrincipalForFy(): number { + return this.filteredLoans.reduce((s, l) => s + (this.selectedFyData(l)?.principalPaid || 0), 0); + } + + outstandingPrincipal(l: LoanInterestRecord): number { + return this.selectedFyData(l)?.closingBalance ?? 0; + } + + // ── Actions ── + clearFilters(): void { + this.search = ''; + this.loanTypeFilter = ''; + if (this.fyOptions.length) this.fyFilter = this.fyOptions[0]; + } + + viewCertificate(l: LoanInterestRecord): void { + const fy = this.selectedFyData(l); + if (!fy) { + this.snack.open('No interest data on file for the selected financial year.', 'OK', { duration: 2500 }); + return; + } + const data: CertificatePreviewDialogData = { + kind: 'interest', + loanInterest: { record: l, fy } + }; + this.dialog.open(CertificatePreviewDialogComponent, { + width: '880px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false, + panelClass: 'cert-preview-dialog', + data + }); + } + + downloadCertificate(l: LoanInterestRecord): void { + const fy = this.selectedFyData(l); + if (!fy) { + this.snack.open('No interest data on file for the selected financial year.', 'OK', { duration: 2500 }); + return; + } + downloadLoanInterestPdf(l, fy); + this.snack.open(`Interest Certificate (${fy.fy}) downloaded.`, 'OK', { duration: 2400 }); + } +} + +// ───────────────────── Mock seed (top-level export) ───────────────────── +export function seedLoanInterestRecords(): LoanInterestRecord[] { + return [ + { + accountNo: 'LN10029384', + customerId: 'CUST112233', + customerName: 'Rohan S. Mehta', + customerPan: 'ABCPM1234E', + customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069', + branch: 'Mumbai — Andheri (E)', + loanType: 'HOUSING', + sanctionedAmount: 4500000, + disbursementDate: '2021-06-15', + interestRate: 8.65, + fy: [ + { + fy: 'FY 2024-25', + openingBalance: 3620000, + closingBalance: 3380000, + principalPaid: 240000, + interestPaid: 312500 + }, + { + fy: 'FY 2023-24', + openingBalance: 3840000, + closingBalance: 3620000, + principalPaid: 220000, + interestPaid: 328200 + }, + { + fy: 'FY 2022-23', + openingBalance: 4050000, + closingBalance: 3840000, + principalPaid: 210000, + interestPaid: 342800 + } + ] + }, + { + accountNo: 'LN20034112', + customerId: 'CUST334455', + customerName: 'Anita Verma', + customerPan: 'EFGHV5678A', + customerAddress: 'B-12, Connaught Pl, New Delhi 110001', + branch: 'New Delhi — Connaught Place', + loanType: 'PERSONAL', + sanctionedAmount: 600000, + disbursementDate: '2022-09-10', + interestRate: 12.5, + fy: [ + { + fy: 'FY 2024-25', + openingBalance: 380000, + closingBalance: 220000, + principalPaid: 160000, + interestPaid: 41200 + }, + { fy: 'FY 2023-24', openingBalance: 520000, closingBalance: 380000, principalPaid: 140000, interestPaid: 56800 } + ] + }, + { + accountNo: 'LN50200077', + customerId: 'CUST556677', + customerName: 'Karthik Rao', + customerPan: 'PQRSR2233Z', + customerAddress: '24, MG Road, Bengaluru 560001', + branch: 'Bengaluru — MG Road', + loanType: 'AUTO', + sanctionedAmount: 850000, + disbursementDate: '2023-01-20', + interestRate: 9.4, + fy: [ + { + fy: 'FY 2024-25', + openingBalance: 620000, + closingBalance: 480000, + principalPaid: 140000, + interestPaid: 58800 + }, + { fy: 'FY 2023-24', openingBalance: 780000, closingBalance: 620000, principalPaid: 160000, interestPaid: 71200 } + ] + }, + { + accountNo: 'LN70010023', + customerId: 'CUST889900', + customerName: 'Vikram Joshi', + customerPan: 'LMNOJ9988X', + customerAddress: 'Office 3B, Koregaon Park Road, Pune 411001', + branch: 'Pune — Koregaon Park', + loanType: 'BUSINESS', + sanctionedAmount: 5500000, + disbursementDate: '2020-11-05', + interestRate: 11.0, + fy: [ + { + fy: 'FY 2024-25', + openingBalance: 4200000, + closingBalance: 3950000, + principalPaid: 250000, + interestPaid: 464800 + }, + { + fy: 'FY 2023-24', + openingBalance: 4480000, + closingBalance: 4200000, + principalPaid: 280000, + interestPaid: 488600 + }, + { + fy: 'FY 2022-23', + openingBalance: 4760000, + closingBalance: 4480000, + principalPaid: 280000, + interestPaid: 522300 + } + ] + }, + { + accountNo: 'LN88990022', + customerId: 'CUST990011', + customerName: 'Arjun Banerjee', + customerPan: 'TUVWB4455F', + customerAddress: '42, Park Street, Kolkata 700016', + branch: 'Kolkata — Park Street', + loanType: 'EDUCATION', + sanctionedAmount: 1200000, + disbursementDate: '2022-04-22', + interestRate: 9.85, + fy: [ + { + fy: 'FY 2024-25', + openingBalance: 920000, + closingBalance: 790000, + principalPaid: 130000, + interestPaid: 93500 + }, + { + fy: 'FY 2023-24', + openingBalance: 1080000, + closingBalance: 920000, + principalPaid: 160000, + interestPaid: 102200 + } + ] + }, + { + accountNo: 'LN50200099', + customerId: 'CUST667788', + customerName: 'Lakshmi Naidu', + customerPan: 'XYZAN7788Q', + customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034', + branch: 'Hyderabad — Banjara Hills', + loanType: 'GOLD', + sanctionedAmount: 450000, + disbursementDate: '2023-08-18', + interestRate: 10.25, + fy: [ + { + fy: 'FY 2024-25', + openingBalance: 320000, + closingBalance: 180000, + principalPaid: 140000, + interestPaid: 28400 + } + ] + } + ]; +} diff --git a/src/app/letter-mgmt/certificates/noc/noc.component.html b/src/app/letter-mgmt/certificates/noc/noc.component.html new file mode 100644 index 000000000..d6883b408 --- /dev/null +++ b/src/app/letter-mgmt/certificates/noc/noc.component.html @@ -0,0 +1,179 @@ + + +
+
+
+ +
+

No-Objection Certificate (NOC)

+
+ Issue NOCs for closed loan accounts — releases the bank's lien on hypothecated assets (vehicles, property, + gold) or confirms unsecured-loan closure. +
+
+
+
+ + +
+
+ +
+
{{ countIssued() | number }}
+
NOCs Issued
+
+
+
+ +
+
{{ countReady() | number }}
+
Ready to Issue
+
+
+
+ +
+
{{ countPending() | number }}
+
Pending Closure
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + @if (search || loanTypeFilter || statusFilter) { + + } +
+ + +
+
+ + + + + + + + + + + + + + + @for (r of filteredRecords; track r.nocNo) { + + + + + + + + + + + } + @if (filteredRecords.length === 0) { + + + + } + +
Customer · Loan A/CNOC No.Loan TypeAsset / IdentifierTotal PaidClosureStatusActions
+
{{ r.customerName }}
+
{{ r.accountNo }} · {{ r.customerId }} · PAN {{ r.customerPan }}
+
{{ r.nocNo }} + {{ loanTypeLabel(r.loanType) }} +
Sanctioned ₹{{ r.sanctionedAmount | number: '1.0-0' }}
+
+ @if (r.assetType !== 'NONE') { +
{{ r.assetDescription }}
+
{{ r.assetIdentifier }}
+ } @else { + Unsecured · no asset + } +
+ ₹{{ r.totalPaid | number: '1.0-0' }} + + @if (r.closureDate) { + {{ r.closureDate | date: 'd MMM y' }} + } @else { + — + } + + @if (r.status === 'ISSUED') { + Issued + @if (r.issuedOn) { +
{{ r.issuedOn | date: 'd MMM y' }}
+ } + } @else if (r.status === 'READY_TO_ISSUE') { + Ready to issue + } @else { + + Pending closure + + } +
+
+ + +
+
+ + No NOCs match the current filters. +
+
+
+
diff --git a/src/app/letter-mgmt/certificates/noc/noc.component.ts b/src/app/letter-mgmt/certificates/noc/noc.component.ts new file mode 100644 index 000000000..7221e6d04 --- /dev/null +++ b/src/app/letter-mgmt/certificates/noc/noc.component.ts @@ -0,0 +1,306 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { + CertificatePreviewDialogComponent, + CertificatePreviewDialogData +} from '../certificate-preview-dialog/certificate-preview-dialog.component'; +import { downloadNocPdf } from '../certificate-pdf'; + +export type NocLoanType = 'AUTO' | 'HOUSING' | 'PERSONAL' | 'EDUCATION' | 'GOLD' | 'BUSINESS' | 'CC' | 'OD'; + +export type NocStatus = 'ISSUED' | 'PENDING' | 'READY_TO_ISSUE'; + +export type NocAssetType = 'VEHICLE' | 'PROPERTY' | 'GOLD' | 'EQUIPMENT' | 'STOCK_DEBTORS' | 'NONE'; + +export interface NocRecord { + nocNo: string; + accountNo: string; + customerId: string; + customerName: string; + customerPan: string; + customerAddress: string; + branch: string; + loanType: NocLoanType; + sanctionedAmount: number; + disbursementDate: string; + /** Date the loan was fully closed / settled. */ + closureDate: string; + totalPaid: number; + /** Asset whose lien is being released. */ + assetType: NocAssetType; + assetDescription: string; + assetIdentifier: string; + /** Where to forward the NOC (RTO Mumbai, Sub-Registrar etc.) */ + forwardTo?: string; + status: NocStatus; + issuedOn?: string; + issuedBy?: string; +} + +@Component({ + selector: 'mifosx-noc', + templateUrl: './noc.component.html', + styleUrls: ['../certificates.shared.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class NocCertificateComponent implements OnInit { + private dialog = inject(MatDialog); + private snack = inject(MatSnackBar); + + // ── Filters ── + search = ''; + loanTypeFilter: '' | NocLoanType = ''; + statusFilter: '' | NocStatus = ''; + + records: NocRecord[] = []; + + readonly loanTypeOptions: { value: NocLoanType; label: string }[] = [ + { value: 'AUTO', label: 'Auto Loan' }, + { value: 'HOUSING', label: 'Housing Loan' }, + { value: 'PERSONAL', label: 'Personal Loan' }, + { value: 'EDUCATION', label: 'Education Loan' }, + { value: 'GOLD', label: 'Gold Loan' }, + { value: 'BUSINESS', label: 'Business Loan' }, + { value: 'CC', label: 'Cash Credit (CC)' }, + { value: 'OD', label: 'Overdraft (OD)' } + ]; + + ngOnInit(): void { + this.records = seedNocRecords(); + } + + // ── Derived list ── + get filteredRecords(): NocRecord[] { + const q = this.search.trim().toLowerCase(); + return this.records.filter((r) => { + if (this.loanTypeFilter && r.loanType !== this.loanTypeFilter) return false; + if (this.statusFilter && r.status !== this.statusFilter) return false; + if (!q) return true; + return ( + r.nocNo.toLowerCase().includes(q) || + r.accountNo.toLowerCase().includes(q) || + r.customerName.toLowerCase().includes(q) || + r.customerId.toLowerCase().includes(q) || + r.assetIdentifier.toLowerCase().includes(q) + ); + }); + } + + loanTypeLabel(t: NocLoanType): string { + return this.loanTypeOptions.find((o) => o.value === t)?.label || t; + } + + countIssued(): number { + return this.records.filter((r) => r.status === 'ISSUED').length; + } + + countReady(): number { + return this.records.filter((r) => r.status === 'READY_TO_ISSUE').length; + } + + countPending(): number { + return this.records.filter((r) => r.status === 'PENDING').length; + } + + clearFilters(): void { + this.search = ''; + this.loanTypeFilter = ''; + this.statusFilter = ''; + } + + viewCertificate(r: NocRecord): void { + if (r.status === 'PENDING') { + this.snack.open( + `${r.customerName}'s loan ${r.accountNo} is still pending closure — NOC cannot be previewed yet.`, + 'OK', + { duration: 3500 } + ); + return; + } + const data: CertificatePreviewDialogData = { kind: 'noc', noc: r }; + this.dialog.open(CertificatePreviewDialogComponent, { + width: '880px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false, + panelClass: 'cert-preview-dialog', + data + }); + } + + downloadCertificate(r: NocRecord): void { + if (r.status === 'PENDING') { + this.snack.open( + `${r.customerName}'s loan ${r.accountNo} is still pending closure — NOC cannot be downloaded yet.`, + 'OK', + { duration: 3500 } + ); + return; + } + downloadNocPdf(r); + this.snack.open(`NOC ${r.nocNo} downloaded.`, 'OK', { duration: 2400 }); + } +} + +// ───────────────────── Mock seed (top-level export) ───────────────────── +export function seedNocRecords(): NocRecord[] { + return [ + { + nocNo: 'NOC-2024-005012', + accountNo: 'LN50200077', + customerId: 'CUST556677', + customerName: 'Karthik Rao', + customerPan: 'PQRSR2233Z', + customerAddress: '24, MG Road, Bengaluru 560001', + branch: 'Bengaluru — MG Road', + loanType: 'AUTO', + sanctionedAmount: 850000, + disbursementDate: '2023-01-20', + closureDate: '2026-01-15', + totalPaid: 988600, + assetType: 'VEHICLE', + assetDescription: 'Maruti Suzuki Swift VXi · Manual · Petrol', + assetIdentifier: 'KA-01-MZ-4421', + forwardTo: 'RTO Bengaluru, Koramangala', + status: 'ISSUED', + issuedOn: '2026-01-18', + issuedBy: 'Anita Desai' + }, + { + nocNo: 'NOC-2024-005028', + accountNo: 'LN20034112', + customerId: 'CUST334455', + customerName: 'Anita Verma', + customerPan: 'EFGHV5678A', + customerAddress: 'B-12, Connaught Pl, New Delhi 110001', + branch: 'New Delhi — Connaught Place', + loanType: 'PERSONAL', + sanctionedAmount: 600000, + disbursementDate: '2022-09-10', + closureDate: '2025-09-09', + totalPaid: 698000, + assetType: 'NONE', + assetDescription: 'Unsecured personal loan', + assetIdentifier: '—', + status: 'ISSUED', + issuedOn: '2025-09-12', + issuedBy: 'Sushma Iyer' + }, + { + nocNo: 'NOC-2024-005034', + accountNo: 'LN50200099', + customerId: 'CUST667788', + customerName: 'Lakshmi Naidu', + customerPan: 'XYZAN7788Q', + customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034', + branch: 'Hyderabad — Banjara Hills', + loanType: 'GOLD', + sanctionedAmount: 450000, + disbursementDate: '2023-08-18', + closureDate: '2026-02-28', + totalPaid: 502800, + assetType: 'GOLD', + assetDescription: '22 KT Gold Jewellery · Total 92.5 grams', + assetIdentifier: 'GL-PKT-7891 · Locker S-22', + forwardTo: 'Branch Locker — return of pledged ornaments', + status: 'READY_TO_ISSUE' + }, + { + nocNo: 'NOC-2024-005041', + accountNo: 'LN88990022', + customerId: 'CUST990011', + customerName: 'Arjun Banerjee', + customerPan: 'TUVWB4455F', + customerAddress: '42, Park Street, Kolkata 700016', + branch: 'Kolkata — Park Street', + loanType: 'EDUCATION', + sanctionedAmount: 1200000, + disbursementDate: '2022-04-22', + closureDate: '2026-03-10', + totalPaid: 1389500, + assetType: 'NONE', + assetDescription: 'Education loan (no asset hypothecated)', + assetIdentifier: '—', + status: 'READY_TO_ISSUE' + }, + { + nocNo: 'NOC-2024-005055', + accountNo: 'LN10029384', + customerId: 'CUST112233', + customerName: 'Rohan S. Mehta', + customerPan: 'ABCPM1234E', + customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069', + branch: 'Mumbai — Andheri (E)', + loanType: 'HOUSING', + sanctionedAmount: 4500000, + disbursementDate: '2021-06-15', + closureDate: '', + totalPaid: 1310000, + assetType: 'PROPERTY', + assetDescription: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069', + assetIdentifier: 'Reg No. AND/2021/00482', + forwardTo: 'Sub-Registrar, Andheri', + status: 'PENDING' + }, + { + nocNo: 'NOC-2024-005062', + accountNo: 'LN70010023', + customerId: 'CUST889900', + customerName: 'Vikram Joshi', + customerPan: 'LMNOJ9988X', + customerAddress: 'Office 3B, Koregaon Park Road, Pune 411001', + branch: 'Pune — Koregaon Park', + loanType: 'BUSINESS', + sanctionedAmount: 5500000, + disbursementDate: '2020-11-05', + closureDate: '', + totalPaid: 1820000, + assetType: 'EQUIPMENT', + assetDescription: 'CNC milling machine · Haas VF-2SS · 2020', + assetIdentifier: 'SN-VF2SS-2020-114782', + status: 'PENDING' + }, + { + nocNo: 'NOC-2024-005078', + accountNo: 'LN30049827', + customerId: 'CUST771199', + customerName: 'Suresh Pillai', + customerPan: 'RSTUP4400J', + customerAddress: '17, Pondy Bazaar, T. Nagar, Chennai 600017', + branch: 'Chennai — T. Nagar', + loanType: 'AUTO', + sanctionedAmount: 1250000, + disbursementDate: '2022-06-30', + closureDate: '2025-12-20', + totalPaid: 1480200, + assetType: 'VEHICLE', + assetDescription: 'Honda City ZX · CVT · Petrol', + assetIdentifier: 'TN-09-CB-8821', + forwardTo: 'RTO Chennai Central', + status: 'ISSUED', + issuedOn: '2025-12-24', + issuedBy: 'Ramesh Krishnan' + } + ]; +} diff --git a/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.html b/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.html new file mode 100644 index 000000000..a5e1818ae --- /dev/null +++ b/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.html @@ -0,0 +1,164 @@ + + +
+ +
+
+ +
+

Share Certificate

+
+ Issue, preview and download member share certificates with folio + share-number range. +
+
+
+
+ + +
+
+ +
+
{{ filteredRecords.length | number }}
+
Certificates
+
+
+
+ +
+
{{ totalShares() | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totalShareValue() | number: '1.0-0' }}
+
Total Face Value
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + @if (search || classFilter || branchFilter) { + + } +
+ + +
+
+ + + + + + + + + + + + + + + + + @for (r of filteredRecords; track r.certificateNo) { + + + + + + + + + + + + + } + @if (filteredRecords.length === 0) { + + + + } + +
Member · FolioCertificate No.ClassBranchSharesFace ValueTotalShare Nos.IssuedActions
+
{{ r.memberName }}
+
+ {{ r.memberId }} · Folio {{ r.folioNo }} · PAN {{ r.memberPan }} +
+
{{ r.certificateNo }} + + {{ classLabel(r.shareClass) }} + + {{ r.branch }} + {{ r.shareCount | number }} + ₹{{ r.faceValue | number: '1.0-0' }} + ₹{{ r.shareCount * r.faceValue | number: '1.0-0' }} + {{ r.shareNoFrom }}–{{ r.shareNoTo }}{{ r.issueDate | date: 'd MMM y' }} +
+ + +
+
+ + No share certificates match the current filters. +
+
+
+
diff --git a/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts b/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts new file mode 100644 index 000000000..bccc5c0fc --- /dev/null +++ b/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts @@ -0,0 +1,263 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { + CertificatePreviewDialogComponent, + CertificatePreviewDialogData +} from '../certificate-preview-dialog/certificate-preview-dialog.component'; +import { downloadShareCertificatePdf } from '../certificate-pdf'; + +export type ShareClass = 'ORDINARY' | 'PREFERENCE'; + +export interface ShareCertificateRecord { + certificateNo: string; + folioNo: string; + memberId: string; + memberName: string; + memberPan: string; + memberAddress: string; + branch: string; + joiningDate: string; + shareClass: ShareClass; + shareCount: number; + faceValue: number; + issueDate: string; + /** Distinct numeric range of share numbers held (e.g. 1001–1100). */ + shareNoFrom: number; + shareNoTo: number; + /** Total dividend received since joining. */ + dividendYtd: number; +} + +@Component({ + selector: 'mifosx-share-certificate', + templateUrl: './share-certificate.component.html', + styleUrls: ['../certificates.shared.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class ShareCertificateComponent implements OnInit { + private dialog = inject(MatDialog); + private snack = inject(MatSnackBar); + + // ── Filters ── + search = ''; + classFilter: '' | ShareClass = ''; + branchFilter = ''; + + records: ShareCertificateRecord[] = []; + + readonly classOptions: { value: ShareClass; label: string }[] = [ + { value: 'ORDINARY', label: 'Ordinary' }, + { value: 'PREFERENCE', label: 'Preference' } + ]; + + ngOnInit(): void { + this.records = seedShareCertificates(); + } + + // ── Derived list ── + get filteredRecords(): ShareCertificateRecord[] { + const q = this.search.trim().toLowerCase(); + const branch = this.branchFilter.trim().toLowerCase(); + return this.records.filter((r) => { + if (this.classFilter && r.shareClass !== this.classFilter) return false; + if (branch && !r.branch.toLowerCase().includes(branch)) return false; + if (!q) return true; + return ( + r.certificateNo.toLowerCase().includes(q) || + r.folioNo.toLowerCase().includes(q) || + r.memberId.toLowerCase().includes(q) || + r.memberName.toLowerCase().includes(q) || + r.memberPan.toLowerCase().includes(q) + ); + }); + } + + get branchOptions(): string[] { + return Array.from(new Set(this.records.map((r) => r.branch))).sort(); + } + + classLabel(c: ShareClass): string { + return this.classOptions.find((o) => o.value === c)?.label || c; + } + + totalShares(): number { + return this.filteredRecords.reduce((s, r) => s + r.shareCount, 0); + } + + totalShareValue(): number { + return this.filteredRecords.reduce((s, r) => s + r.shareCount * r.faceValue, 0); + } + + // ── Actions ── + clearFilters(): void { + this.search = ''; + this.classFilter = ''; + this.branchFilter = ''; + } + + viewCertificate(r: ShareCertificateRecord): void { + const data: CertificatePreviewDialogData = { + kind: 'share', + shareCertificate: r + }; + this.dialog.open(CertificatePreviewDialogComponent, { + width: '880px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false, + panelClass: 'cert-preview-dialog', + data + }); + } + + downloadCertificate(r: ShareCertificateRecord): void { + downloadShareCertificatePdf(r); + this.snack.open(`Share Certificate ${r.certificateNo} downloaded.`, 'OK', { duration: 2400 }); + } +} + +// ───────────────────── Mock seed (top-level export) ───────────────────── +export function seedShareCertificates(): ShareCertificateRecord[] { + return [ + { + certificateNo: 'SC-2024-000123', + folioNo: 'F-00482', + memberId: 'MBR000482', + memberName: 'Rohan S. Mehta', + memberPan: 'ABCPM1234E', + memberAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069', + branch: 'Mumbai — Andheri (E)', + joiningDate: '2018-04-22', + shareClass: 'ORDINARY', + shareCount: 100, + faceValue: 100, + issueDate: '2018-04-22', + shareNoFrom: 48201, + shareNoTo: 48300, + dividendYtd: 7200 + }, + { + certificateNo: 'SC-2024-000124', + folioNo: 'F-00713', + memberId: 'MBR000713', + memberName: 'Anita Verma', + memberPan: 'EFGHV5678A', + memberAddress: 'B-12, Connaught Pl, New Delhi 110001', + branch: 'New Delhi — Connaught Place', + joiningDate: '2019-08-11', + shareClass: 'ORDINARY', + shareCount: 50, + faceValue: 100, + issueDate: '2019-08-11', + shareNoFrom: 71301, + shareNoTo: 71350, + dividendYtd: 3000 + }, + { + certificateNo: 'SC-2024-000125', + folioNo: 'F-01102', + memberId: 'MBR001102', + memberName: 'Karthik Rao', + memberPan: 'PQRSR2233Z', + memberAddress: '24, MG Road, Bengaluru 560001', + branch: 'Bengaluru — MG Road', + joiningDate: '2020-02-04', + shareClass: 'ORDINARY', + shareCount: 200, + faceValue: 100, + issueDate: '2020-02-04', + shareNoFrom: 110201, + shareNoTo: 110400, + dividendYtd: 14400 + }, + { + certificateNo: 'SC-2024-000126', + folioNo: 'F-01277', + memberId: 'MBR001277', + memberName: 'Madhavi Singh', + memberPan: 'TUVWS3344K', + memberAddress: 'D-5, Greater Kailash, New Delhi 110048', + branch: 'New Delhi — Connaught Place', + joiningDate: '2017-11-19', + shareClass: 'PREFERENCE', + shareCount: 75, + faceValue: 1000, + issueDate: '2017-11-19', + shareNoFrom: 12721, + shareNoTo: 12795, + dividendYtd: 67500 + }, + { + certificateNo: 'SC-2024-000127', + folioNo: 'F-01498', + memberId: 'MBR001498', + memberName: 'Vikram Joshi', + memberPan: 'LMNOJ9988X', + memberAddress: 'Office 3B, Koregaon Park Road, Pune 411001', + branch: 'Pune — Koregaon Park', + joiningDate: '2016-06-30', + shareClass: 'PREFERENCE', + shareCount: 250, + faceValue: 1000, + issueDate: '2016-06-30', + shareNoFrom: 14981, + shareNoTo: 15230, + dividendYtd: 225000 + }, + { + certificateNo: 'SC-2024-000128', + folioNo: 'F-01902', + memberId: 'MBR001902', + memberName: 'Lakshmi Naidu', + memberPan: 'XYZAN7788Q', + memberAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034', + branch: 'Hyderabad — Banjara Hills', + joiningDate: '2021-03-15', + shareClass: 'ORDINARY', + shareCount: 30, + faceValue: 100, + issueDate: '2021-03-15', + shareNoFrom: 190201, + shareNoTo: 190230, + dividendYtd: 1800 + }, + { + certificateNo: 'SC-2024-000129', + folioNo: 'F-02011', + memberId: 'MBR002011', + memberName: 'Arjun Banerjee', + memberPan: 'TUVWB4455F', + memberAddress: '42, Park Street, Kolkata 700016', + branch: 'Kolkata — Park Street', + joiningDate: '2021-09-08', + shareClass: 'ORDINARY', + shareCount: 40, + faceValue: 100, + issueDate: '2021-09-08', + shareNoFrom: 201101, + shareNoTo: 201140, + dividendYtd: 2400 + } + ]; +} diff --git a/src/app/letter-mgmt/letter-mgmt-routing.module.ts b/src/app/letter-mgmt/letter-mgmt-routing.module.ts new file mode 100644 index 000000000..b3df465f6 --- /dev/null +++ b/src/app/letter-mgmt/letter-mgmt-routing.module.ts @@ -0,0 +1,71 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { Route } from '../core/route/route.service'; +import { NoticeComponent } from './notice/notice.component'; +import { LoanInterestCertificateComponent } from './certificates/loan-interest/loan-interest.component'; +import { ShareCertificateComponent } from './certificates/share-certificate/share-certificate.component'; +import { BalanceCertificateComponent } from './certificates/balance-certificate/balance-certificate.component'; +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'; + +const routes: Routes = [ + Route.withShell([ + { + path: 'letter-mgmt/notice', + component: NoticeComponent, + data: { title: 'Loan Overdue Notices', breadcrumb: 'Notice' } + }, + { + path: 'letter-mgmt/loan-interest-certificate', + component: LoanInterestCertificateComponent, + data: { title: 'Loan Interest Certificate', breadcrumb: 'Loan Interest Certificate' } + }, + { + path: 'letter-mgmt/share-certificate', + component: ShareCertificateComponent, + data: { title: 'Share Certificate', breadcrumb: 'Share Certificate' } + }, + { + path: 'letter-mgmt/balance-certificate', + component: BalanceCertificateComponent, + data: { title: 'Balance Certificate', breadcrumb: 'Balance Certificate' } + }, + { + path: 'letter-mgmt/form-60', + component: Form60Component, + data: { title: 'Form 60', breadcrumb: 'Form 60' } + }, + { + path: 'letter-mgmt/form-15gh', + component: Form15ghComponent, + data: { title: 'Form 15G & 15H', breadcrumb: 'Form 15G/H' } + }, + { + path: 'letter-mgmt/hypothecation-certificate', + component: HypothecationCertificateComponent, + data: { title: 'Hypothecation Certificate', breadcrumb: 'Hypothecation Certificate' } + }, + { + path: 'letter-mgmt/noc', + component: NocCertificateComponent, + data: { title: 'No-Objection Certificate (NOC)', breadcrumb: 'NOC' } + } + ]) +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class LetterMgmtRoutingModule {} diff --git a/src/app/letter-mgmt/letter-mgmt.module.ts b/src/app/letter-mgmt/letter-mgmt.module.ts new file mode 100644 index 000000000..7ffbc06e6 --- /dev/null +++ b/src/app/letter-mgmt/letter-mgmt.module.ts @@ -0,0 +1,46 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; +import { LetterMgmtRoutingModule } from './letter-mgmt-routing.module'; +import { NoticeComponent } from './notice/notice.component'; +import { NoticeHistoryDialogComponent } from './notice/notice-history-dialog/notice-history-dialog.component'; +import { NoticePreviewDialogComponent } from './notice/notice-preview-dialog/notice-preview-dialog.component'; +import { CallContactDialogComponent } from './notice/call-contact-dialog/call-contact-dialog.component'; +import { SpeedPostDialogComponent } from './notice/speed-post-dialog/speed-post-dialog.component'; +import { LoanInterestCertificateComponent } from './certificates/loan-interest/loan-interest.component'; +import { ShareCertificateComponent } from './certificates/share-certificate/share-certificate.component'; +import { BalanceCertificateComponent } from './certificates/balance-certificate/balance-certificate.component'; +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 { CertificatePreviewDialogComponent } from './certificates/certificate-preview-dialog/certificate-preview-dialog.component'; + +@NgModule({ + imports: [ + SharedModule, + LetterMgmtRoutingModule, + NoticeComponent, + NoticeHistoryDialogComponent, + NoticePreviewDialogComponent, + CallContactDialogComponent, + SpeedPostDialogComponent, + LoanInterestCertificateComponent, + ShareCertificateComponent, + BalanceCertificateComponent, + Form60Component, + Form15ghComponent, + HypothecationCertificateComponent, + NocCertificateComponent, + CertificatePreviewDialogComponent + ] +}) +export class LetterMgmtModule {} diff --git a/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.html b/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.html new file mode 100644 index 000000000..4ee908682 --- /dev/null +++ b/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.html @@ -0,0 +1,209 @@ + + +
+ +
+
+ +
+

Reach {{ data.loan.customerName }}

+
+ {{ data.loan.accountNo }} · {{ data.loan.customerId }} · + {{ data.loan.branch }} +
+
+
+ +
+ + +
+ @for (c of contacts; track c.kind) { +
+
+ + +
+ +
+ +
+
{{ c.label }}
+ @if (c.name) { +
{{ c.name }}
+ } +
+91 {{ formatMobile(c.mobile) }}
+ + @if (latestLog(c); as log) { +
+ + {{ outcomeLabel(log.outcome) }} + · {{ log.calledAt.slice(0, 16) }} + @if (log.promiseAmount && log.promiseDate) { + + PTP ₹{{ log.promiseAmount | number: '1.0-0' }} by {{ log.promiseDate | date: 'd MMM' }} + + } +
+ } +
+ +
+ + + + +
+
+ + + @if (isLogging(c)) { +
+
+ + Log call outcome + {{ c.name || c.label }} · +91 {{ formatMobile(c.mobile) }} +
+ +
Pick an outcome
+
+ @for (o of outcomeOptions; track o.value) { + + } +
+ + @if (isPromiseToPay) { +
+
+ + +
+
+ + +
+
+ } + +
+ + +
+ +
+ + +
+
+ } +
+ } + + @if (contacts.length === 0) { +
+ +
+ No contact numbers on file. +

Update the customer profile to add mobile, family or nominee contacts.

+
+
+ } + + + @if (allLogs.length > 0) { +
+
+ + Recent calls + {{ allLogs.length }} +
+
    + @for (log of allLogs; track log.id) { +
  • +
    + + + + {{ outcomeLabel(log.outcome) }} + + + {{ contactKindLabel(log.contactKind) }} + @if (log.contactName) { + · {{ log.contactName }} + } + + +91 {{ formatMobile(log.contactNumber) }} + {{ log.calledAt.slice(0, 16) }} · {{ log.calledBy }} +
    + @if (log.promiseAmount && log.promiseDate) { +
    + + Promise ₹{{ log.promiseAmount | number: '1.0-0' }} by + {{ log.promiseDate | date: 'd MMM y' }} +
    + } + @if (log.notes) { +
    "{{ log.notes }}"
    + } +
  • + } +
+
+ } +
+
diff --git a/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.scss b/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.scss new file mode 100644 index 000000000..777c6baf4 --- /dev/null +++ b/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.scss @@ -0,0 +1,765 @@ +/** + * 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/. + */ + +:host { + --ccd-blue: #1d4ed8; + --ccd-blue-d: #1e40af; + --ccd-blue-l: #eff6ff; + --ccd-blue-tint: #dbeafe; + --ccd-teal: #0d9488; + --ccd-teal-l: #ccfbf1; + --ccd-amber: #d97706; + --ccd-amber-l: #fef3c7; + --ccd-red: #b91c1c; + --ccd-red-l: #fee2e2; + --ccd-red-tint: #fecaca; + --ccd-green: #15803d; + --ccd-green-l: #dcfce7; + --ccd-purple: #6d28d9; + --ccd-purple-l: #ede9fe; + --ccd-g50: #f8fafc; + --ccd-g100: #f1f5f9; + --ccd-g200: #e2e8f0; + --ccd-g300: #cbd5e1; + --ccd-g500: #64748b; + --ccd-g700: #334155; + --ccd-g900: #0f172a; + + display: block; +} + +.ccd-root { + background: #fff; + padding: 14px 18px 18px; + width: 100%; + max-height: 86vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ───────── Header ───────── */ +.ccd-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding-bottom: 12px; + margin-bottom: 12px; + border-bottom: 1px solid var(--ccd-g200); +} + +.ccd-head-l { + display: flex; + align-items: center; + gap: 12px; +} + +.ccd-head-ic { + width: 38px; + height: 38px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: var(--ccd-green); + color: #fff; + font-size: 15px; +} + +.ccd-head-t { + margin: 0; + font-size: 16px; + font-weight: 800; + color: var(--ccd-g900); + line-height: 1.2; +} + +.ccd-head-s { + margin-top: 3px; + font-size: 11.5px; + color: var(--ccd-g500); +} + +.ccd-x { + width: 32px; + height: 32px; + border-radius: 999px; + border: 1px solid var(--ccd-g200); + background: #fff; + color: var(--ccd-g500); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 13px; + flex-shrink: 0; + transition: all 0.12s ease; + + &:hover { + background: var(--ccd-red-l); + color: var(--ccd-red); + border-color: var(--ccd-red-tint); + } +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +/* ───────── Body ───────── */ +.ccd-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; + padding-right: 2px; +} + +/* ───────── Contact row ───────── */ +.ccd-row { + position: relative; + display: grid; + grid-template-columns: 44px 1fr auto; + align-items: center; + gap: 12px; + padding: 12px 14px 12px 18px; + background: #fff; + border: 1px solid var(--ccd-g200); + border-radius: 12px; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + overflow: hidden; + + &:hover { + border-color: var(--ccd-blue-tint); + box-shadow: 0 4px 14px -8px rgb(15 23 42 / 18%); + } +} + +.ccd-row-strip { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + + .accent-blue & { + background: var(--ccd-blue); + } + + .accent-teal & { + background: var(--ccd-teal); + } + + .accent-amber & { + background: var(--ccd-amber); + } + + .accent-purple & { + background: var(--ccd-purple); + } +} + +.ccd-row-icon { + width: 38px; + height: 38px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; + + .accent-blue & { + background: var(--ccd-blue-l); + color: var(--ccd-blue-d); + } + + .accent-teal & { + background: var(--ccd-teal-l); + color: var(--ccd-teal); + } + + .accent-amber & { + background: var(--ccd-amber-l); + color: var(--ccd-amber); + } + + .accent-purple & { + background: var(--ccd-purple-l); + color: var(--ccd-purple); + } +} + +.ccd-row-text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; + line-height: 1.25; +} + +.ccd-row-label { + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ccd-g500); +} + +.ccd-row-name { + font-size: 13px; + font-weight: 700; + color: var(--ccd-g900); +} + +.ccd-row-mobile { + font-size: 12.5px; + font-weight: 600; + color: var(--ccd-g700); + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} + +/* ───────── Row actions ───────── */ +.ccd-row-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.ccd-act { + display: inline-flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 11px; + border-radius: 8px; + border: 1px solid var(--ccd-g200); + background: #fff; + color: var(--ccd-g700); + font-size: 12.5px; + font-weight: 700; + cursor: pointer; + transition: all 0.12s ease; + + fa-icon { + font-size: 12px; + color: var(--ccd-g500); + } + + span { + letter-spacing: 0.02em; + } + + &:hover { + background: var(--ccd-blue-l); + border-color: var(--ccd-blue-tint); + color: var(--ccd-blue-d); + + fa-icon { + color: var(--ccd-blue-d); + } + } + + &:focus-visible { + outline: 2px solid var(--ccd-blue); + outline-offset: 2px; + } +} + +/* Primary "Call" button → solid green so it pops as the recommended action. */ +.ccd-act.ccd-act--primary { + background: var(--ccd-green); + color: #fff; + border-color: var(--ccd-green); + + fa-icon { + color: #fff; + } + + &:hover { + background: #14532d; + border-color: #14532d; + color: #fff; + + fa-icon { + color: #fff; + } + } +} + +/* "Log call outcome" toggle button. */ +.ccd-act.ccd-act--log { + width: 34px; + padding: 0; + justify-content: center; + + &.is-on { + background: var(--ccd-blue-d); + border-color: var(--ccd-blue-d); + color: #fff; + + fa-icon { + color: #fff; + } + } +} + +/* "Save call log" — primary blue when enabled. */ +.ccd-act.ccd-act--save { + background: var(--ccd-blue-d); + color: #fff; + border-color: var(--ccd-blue-d); + + fa-icon { + color: #fff; + } + + &:hover:not(:disabled) { + background: var(--ccd-blue); + border-color: var(--ccd-blue); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +/* ───────── Latest-log strip on the contact row ───────── */ +.ccd-row-group { + display: flex; + flex-direction: column; + gap: 0; +} + +.ccd-row.is-logging { + border-color: var(--ccd-blue-tint); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.ccd-row-lastlog { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 4px; + padding: 3px 9px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.02em; + align-self: flex-start; + + fa-icon { + font-size: 10px; + } + + &.tone-green { + background: var(--ccd-green-l); + color: var(--ccd-green); + } + + &.tone-blue { + background: var(--ccd-blue-l); + color: var(--ccd-blue-d); + } + + &.tone-amber { + background: var(--ccd-amber-l); + color: var(--ccd-amber); + } + + &.tone-red { + background: var(--ccd-red-l); + color: var(--ccd-red); + } +} + +.ccd-lastlog-m { + font-weight: 500; + color: inherit; + opacity: 0.85; +} + +.ccd-lastlog-ptp { + margin-left: 6px; + padding-left: 6px; + border-left: 1px solid currentcolor; + opacity: 0.85; +} + +/* ───────── Inline disposition picker ───────── */ +.ccd-log { + border: 1px solid var(--ccd-blue-tint); + border-top: 0; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; + background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); + padding: 12px 14px 14px; + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 8px; +} + +.ccd-log-h { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + font-weight: 800; + color: var(--ccd-g900); + padding-bottom: 8px; + border-bottom: 1px dashed var(--ccd-g200); +} + +.ccd-log-h-ic { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 7px; + background: var(--ccd-blue-d); + color: #fff; + font-size: 11px; +} + +.ccd-log-h-sub { + margin-left: auto; + font-size: 11px; + font-weight: 600; + color: var(--ccd-g500); + letter-spacing: 0.01em; +} + +.ccd-log-l { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ccd-g500); +} + +/* Outcome chip group */ +.ccd-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ccd-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 11px; + border-radius: 999px; + border: 1px solid var(--ccd-g200); + background: #fff; + color: var(--ccd-g700); + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.01em; + cursor: pointer; + transition: all 0.12s ease; + + fa-icon { + font-size: 11px; + color: var(--ccd-g500); + } + + &:hover:not(.is-on) { + background: var(--ccd-g50); + border-color: var(--ccd-g300); + } + + &.is-on { + color: #fff; + + fa-icon { + color: #fff; + } + + &.tone-green { + background: var(--ccd-green); + border-color: var(--ccd-green); + } + + &.tone-blue { + background: var(--ccd-blue-d); + border-color: var(--ccd-blue-d); + } + + &.tone-amber { + background: var(--ccd-amber); + border-color: var(--ccd-amber); + } + + &.tone-red { + background: var(--ccd-red); + border-color: var(--ccd-red); + } + } +} + +/* Promise-to-pay fields */ +.ccd-ptp { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + padding: 10px 12px; + background: var(--ccd-green-l); + border: 1px solid #bbf7d0; + border-radius: 10px; +} + +.ccd-field { + display: flex; + flex-direction: column; + gap: 4px; + + label { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ccd-g500); + } + + input, + textarea { + box-sizing: border-box; + width: 100%; + border: 1px solid var(--ccd-g200); + border-radius: 8px; + padding: 8px 11px; + background: #fff; + font-size: 12.5px; + color: var(--ccd-g900); + font-family: inherit; + outline: none; + transition: border-color 0.15s ease; + resize: vertical; + + &:focus { + border-color: var(--ccd-blue); + } + } + + textarea { + min-height: 50px; + } +} + +.ccd-log-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 4px; +} + +/* ───────── Recent-calls history list ───────── */ +.ccd-history { + margin-top: 14px; + padding-top: 14px; + border-top: 1px dashed var(--ccd-g200); +} + +.ccd-history-h { + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ccd-g500); + margin-bottom: 8px; + + fa-icon { + font-size: 11px; + color: var(--ccd-blue); + } +} + +.ccd-history-c { + margin-left: 4px; + padding: 2px 8px; + border-radius: 999px; + background: var(--ccd-g100); + color: var(--ccd-g500); + font-size: 9.5px; + font-weight: 800; +} + +.ccd-history-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.ccd-history-item { + padding: 8px 10px 8px 14px; + border: 1px solid var(--ccd-g200); + border-radius: 9px; + background: #fff; + position: relative; + + &.tone-green { + border-left: 4px solid var(--ccd-green); + } + + &.tone-blue { + border-left: 4px solid var(--ccd-blue); + } + + &.tone-amber { + border-left: 4px solid var(--ccd-amber); + } + + &.tone-red { + border-left: 4px solid var(--ccd-red); + } +} + +.ccd-history-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 11.5px; +} + +.ccd-history-outcome { + display: inline-flex; + align-items: center; + gap: 5px; + font-weight: 800; + color: var(--ccd-g900); + + fa-icon { + font-size: 10.5px; + } + + .tone-green & fa-icon { + color: var(--ccd-green); + } + + .tone-blue & fa-icon { + color: var(--ccd-blue-d); + } + + .tone-amber & fa-icon { + color: var(--ccd-amber); + } + + .tone-red & fa-icon { + color: var(--ccd-red); + } +} + +.ccd-history-kind { + font-size: 10.5px; + font-weight: 600; + color: var(--ccd-g500); +} + +.ccd-history-num { + font-size: 11.5px; + font-weight: 600; + color: var(--ccd-g700); + font-variant-numeric: tabular-nums; +} + +.ccd-history-meta { + margin-left: auto; + font-size: 11px; + color: var(--ccd-g500); +} + +.ccd-history-ptp { + margin-top: 6px; + padding: 4px 9px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + color: var(--ccd-green); + background: var(--ccd-green-l); + border-radius: 8px; + + fa-icon { + font-size: 10px; + } +} + +.ccd-history-notes { + margin-top: 6px; + font-size: 11.5px; + font-style: italic; + color: var(--ccd-g500); + line-height: 1.45; +} + +/* ───────── Empty state ───────── */ +.ccd-empty { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 18px 16px; + border: 1px dashed var(--ccd-g300); + border-radius: 10px; + background: var(--ccd-g50); + color: var(--ccd-g700); + + fa-icon { + color: var(--ccd-blue); + font-size: 18px; + margin-top: 2px; + } + + p { + margin: 4px 0 0; + font-size: 12.5px; + color: var(--ccd-g500); + } +} + +@media (width <= 540px) { + .ccd-row { + grid-template-columns: 38px 1fr; + grid-template-areas: + 'icon text' + 'actions actions'; + row-gap: 8px; + } + + .ccd-row-icon { + grid-area: icon; + } + + .ccd-row-text { + grid-area: text; + } + + .ccd-row-actions { + grid-area: actions; + justify-content: flex-end; + } +} diff --git a/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.ts b/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.ts new file mode 100644 index 000000000..2001b7c30 --- /dev/null +++ b/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.ts @@ -0,0 +1,306 @@ +/** + * 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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { CallContactKind, CallLog, CallOutcome, OverdueLoan } from '../notice.component'; + +export interface CallContactDialogData { + loan: OverdueLoan; +} + +export type ContactKind = CallContactKind; + +export interface ContactEntry { + kind: ContactKind; + /** Display label e.g. "Customer", "Alternative", "Family — Spouse", "Nominee" */ + label: string; + /** Person's name, when applicable (customer / family / nominee). */ + name?: string; + /** Just the 10-digit India mobile (no +91). */ + mobile: string; + /** Icon name (already registered) */ + icon: string; + /** Color accent for the row strip. */ + accent: 'blue' | 'teal' | 'purple' | 'amber'; +} + +interface OutcomeOption { + value: CallOutcome; + label: string; + /** Pill color theme — green = good, amber = neutral, red = bad. */ + tone: 'green' | 'blue' | 'amber' | 'red'; + icon: string; +} + +interface LogDraft { + outcome: CallOutcome | null; + notes: string; + promiseAmount: number | null; + promiseDate: string; +} + +@Component({ + selector: 'mifosx-call-contact-dialog', + templateUrl: './call-contact-dialog.component.html', + styleUrls: ['./call-contact-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class CallContactDialogComponent { + private dialogRef = inject(MatDialogRef); + private snack = inject(MatSnackBar); + data: CallContactDialogData = inject(MAT_DIALOG_DATA); + + /** Tracks which row is currently in disposition-logging mode (one at a time). */ + loggingKind: ContactKind | null = null; + /** Per-session draft state for the inline disposition picker. */ + draft: LogDraft = this.emptyDraft(); + + readonly outcomeOptions: OutcomeOption[] = [ + { value: 'PROMISE_TO_PAY', label: 'Promise to Pay', tone: 'green', icon: 'check-circle' }, + { value: 'SETTLED', label: 'Settled / Paid', tone: 'green', icon: 'check-double' }, + { value: 'CALLBACK_REQUESTED', label: 'Callback Requested', tone: 'blue', icon: 'history' }, + { value: 'NOT_REACHABLE', label: 'Not Reachable', tone: 'amber', icon: 'phone' }, + { value: 'SWITCHED_OFF', label: 'Switched Off', tone: 'amber', icon: 'mobile-alt' }, + { value: 'RNR', label: 'Ring No Response', tone: 'amber', icon: 'bell' }, + { value: 'BUSY', label: 'Busy', tone: 'amber', icon: 'hourglass-half' }, + { value: 'WRONG_NUMBER', label: 'Wrong Number', tone: 'red', icon: 'exclamation-triangle' }, + { value: 'REFUSED', label: 'Refused / Abusive', tone: 'red', icon: 'ban' } + ]; + + /** All reachable contacts for the loan — only the ones that actually have a number. */ + get contacts(): ContactEntry[] { + const l = this.data.loan; + const out: ContactEntry[] = []; + + out.push({ + kind: 'PRIMARY', + label: 'Customer (primary)', + name: l.customerName, + mobile: l.mobile, + icon: 'user', + accent: 'blue' + }); + + if (l.mobileAlt) { + out.push({ + kind: 'ALTERNATIVE', + label: 'Alternative number', + name: l.customerName, + mobile: l.mobileAlt, + icon: 'mobile-alt', + accent: 'teal' + }); + } + + if (l.familyMobile) { + out.push({ + kind: 'FAMILY', + label: l.familyRelation ? `Family — ${l.familyRelation}` : 'Family', + name: l.familyName, + mobile: l.familyMobile, + icon: 'users', + accent: 'amber' + }); + } + + if (l.nomineeMobile) { + out.push({ + kind: 'NOMINEE', + label: 'Nominee', + name: l.nomineeName, + mobile: l.nomineeMobile, + icon: 'shield-alt', + accent: 'purple' + }); + } + + return out; + } + + /** "9818877665" → "98188 77665" — easier to read in the row. */ + formatMobile(m: string): string { + if (!m) return ''; + const trimmed = m.replace(/\D/g, '').slice(-10); + return trimmed.length === 10 ? `${trimmed.slice(0, 5)} ${trimmed.slice(5)}` : m; + } + + call(c: ContactEntry): void { + window.location.href = `tel:+91${c.mobile}`; + this.snack.open(`Calling ${c.name || 'contact'} · +91 ${this.formatMobile(c.mobile)}…`, 'OK', { + duration: 2500 + }); + } + + whatsapp(c: ContactEntry): void { + const msg = `Dear ${c.name || 'Sir/Madam'}, regarding your loan account ${this.data.loan.accountNo} with our bank — kindly contact our Recovery & Collections team.`; + const url = `https://wa.me/91${c.mobile}?text=${encodeURIComponent(msg)}`; + window.open(url, '_blank', 'noopener'); + this.snack.open(`Opening WhatsApp for ${c.name || 'contact'}…`, 'OK', { duration: 2000 }); + } + + copy(c: ContactEntry): void { + const number = `+91 ${this.formatMobile(c.mobile)}`; + const fallback = (): void => { + const ta = document.createElement('textarea'); + ta.value = number; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + } catch { + /* ignore */ + } + document.body.removeChild(ta); + }; + if (navigator?.clipboard?.writeText) { + navigator.clipboard.writeText(number).catch(fallback); + } else { + fallback(); + } + this.snack.open(`Copied ${number} to clipboard.`, 'OK', { duration: 2000 }); + } + + // ───────── Disposition logging ───────── + isLogging(c: ContactEntry): boolean { + return this.loggingKind === c.kind; + } + + startLogging(c: ContactEntry): void { + this.loggingKind = c.kind; + this.draft = this.emptyDraft(); + } + + cancelLogging(): void { + this.loggingKind = null; + this.draft = this.emptyDraft(); + } + + pickOutcome(o: CallOutcome): void { + this.draft.outcome = this.draft.outcome === o ? null : o; + } + + isOutcomePicked(o: CallOutcome): boolean { + return this.draft.outcome === o; + } + + get isPromiseToPay(): boolean { + return this.draft.outcome === 'PROMISE_TO_PAY'; + } + + /** True once an outcome is picked and (for PTP) the promise fields are filled. */ + get canSaveLog(): boolean { + if (!this.draft.outcome) return false; + if (this.isPromiseToPay) { + if (!this.draft.promiseAmount || this.draft.promiseAmount <= 0) return false; + if (!this.draft.promiseDate) return false; + } + return true; + } + + saveLog(c: ContactEntry): void { + if (!this.canSaveLog || !this.draft.outcome) return; + const log: CallLog = { + id: 'CL' + Date.now().toString().slice(-9), + contactKind: c.kind, + contactNumber: c.mobile, + contactName: c.name, + calledAt: this.nowStr(), + calledBy: 'Current User', + outcome: this.draft.outcome, + promiseAmount: this.isPromiseToPay ? Number(this.draft.promiseAmount) : undefined, + promiseDate: this.isPromiseToPay ? this.draft.promiseDate : undefined, + notes: (this.draft.notes || '').trim() || undefined + }; + this.data.loan.callLogs = [ + log, + ...(this.data.loan.callLogs || []) + ]; + const label = this.outcomeLabel(log.outcome); + this.snack.open(`Call disposition saved · ${label}`, 'OK', { duration: 2400 }); + this.cancelLogging(); + } + + // ───────── Display helpers ───────── + /** Most-recent call disposition for a particular contact number (or null). */ + latestLog(c: ContactEntry): CallLog | null { + const logs = this.data.loan.callLogs || []; + return logs.find((l) => l.contactNumber === c.mobile) || null; + } + + /** All logs sorted newest first — drives the "Recent calls" footer list. */ + get allLogs(): CallLog[] { + return this.data.loan.callLogs || []; + } + + outcomeLabel(o: CallOutcome): string { + return this.outcomeOptions.find((x) => x.value === o)?.label || o; + } + + outcomeTone(o: CallOutcome): 'green' | 'blue' | 'amber' | 'red' { + return this.outcomeOptions.find((x) => x.value === o)?.tone || 'amber'; + } + + outcomeIcon(o: CallOutcome): string { + return this.outcomeOptions.find((x) => x.value === o)?.icon || 'phone'; + } + + contactKindLabel(k: ContactKind): string { + switch (k) { + case 'PRIMARY': + return 'Primary'; + case 'ALTERNATIVE': + return 'Alternative'; + case 'FAMILY': + return 'Family'; + case 'NOMINEE': + return 'Nominee'; + } + } + + close(): void { + this.dialogRef.close(); + } + + private emptyDraft(): LogDraft { + return { outcome: null, notes: '', promiseAmount: null, promiseDate: '' }; + } + + private nowStr(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + d.getFullYear() + + '-' + + pad(d.getMonth() + 1) + + '-' + + pad(d.getDate()) + + ' ' + + pad(d.getHours()) + + ':' + + pad(d.getMinutes()) + + ':' + + pad(d.getSeconds()) + ); + } +} diff --git a/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.html b/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.html new file mode 100644 index 000000000..1a2ae8d85 --- /dev/null +++ b/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.html @@ -0,0 +1,148 @@ + + +
+ +
+
+ +
+

+ {{ data.loan.customerName }} · {{ data.loan.accountNo }} +

+
+ {{ data.loan.notices.length }} notice(s) generated · Overdue + ₹{{ data.loan.overdueTotal | number: '1.0-0' }} · DPD {{ data.loan.daysOverdue }} +
+
+
+ +
+ + +
+ @if (data.loan.notices.length === 0) { +
+ +
+ No notices have been generated yet. +

Click the Generate icon on the row to issue a first-stage notice.

+
+
+ } @else { +
    + @for (n of data.loan.notices; track n.id) { +
  1. + + + +
    +
    + + {{ stageLabel(n.stage) }} + + + Generated {{ n.generatedAt }} by {{ n.generatedBy }} + + {{ n.id }} + + + + +
    + + @if (n.sends.length === 0) { +
    + + Generated but not yet dispatched. +
    + } @else { + + + + + + + + + + + + @for (s of n.sends; track s.sentAt + s.channel) { + + + + + + + + @if (s.channel === 'POST' && (s.consignmentNo || s.postOffice)) { + + + + } + } + +
    ChannelDestinationSentByStatus
    + + + {{ channelLabel(s.channel) }} + + {{ s.destination }}{{ s.sentAt }}{{ s.sentBy }} + + {{ s.status }} + +
    + +
    + } +
    +
  2. + } +
+ } +
+
diff --git a/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.scss b/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.scss new file mode 100644 index 000000000..844da4334 --- /dev/null +++ b/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.scss @@ -0,0 +1,435 @@ +/** + * 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/. + */ + +:host { + --nhd-blue: #1d4ed8; + --nhd-blue-d: #1e40af; + --nhd-blue-l: #eff6ff; + --nhd-blue-tint: #dbeafe; + --nhd-green: #15803d; + --nhd-green-l: #dcfce7; + --nhd-amber: #d97706; + --nhd-amber-l: #fef3c7; + --nhd-red: #b91c1c; + --nhd-red-l: #fee2e2; + --nhd-red-tint: #fecaca; + --nhd-purple: #6d28d9; + --nhd-purple-l: #ede9fe; + --nhd-purple-tint: #ddd6fe; + --nhd-g50: #f8fafc; + --nhd-g100: #f1f5f9; + --nhd-g200: #e2e8f0; + --nhd-g300: #cbd5e1; + --nhd-g500: #64748b; + --nhd-g700: #334155; + --nhd-g900: #0f172a; + + display: block; +} + +.nhd-root { + background: #fff; + padding: 16px 20px 22px; + width: 100%; + max-height: 84vh; + display: flex; + flex-direction: column; +} + +/* ───────── Header ───────── */ +.nhd-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding-bottom: 12px; + margin-bottom: 14px; + border-bottom: 1px solid var(--nhd-g200); +} + +.nhd-head-l { + display: flex; + align-items: center; + gap: 12px; +} + +.nhd-head-ic { + width: 40px; + height: 40px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: var(--nhd-blue-d); + color: #fff; + font-size: 17px; +} + +.nhd-head-t { + margin: 0; + font-size: 16px; + font-weight: 800; + color: var(--nhd-g900); + + small { + font-weight: 500; + color: var(--nhd-g500); + font-size: 12.5px; + margin-left: 4px; + } +} + +.nhd-head-s { + margin-top: 3px; + font-size: 12px; + color: var(--nhd-g500); + + strong { + color: var(--nhd-g900); + } +} + +.nhd-x { + width: 32px; + height: 32px; + border-radius: 999px; + border: 1px solid var(--nhd-g200); + background: #fff; + color: var(--nhd-g500); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 13px; + flex-shrink: 0; + transition: + background 0.12s, + color 0.12s, + border-color 0.12s; + + &:hover { + background: var(--nhd-red-l); + color: var(--nhd-red); + border-color: var(--nhd-red-tint); + } +} + +/* ───────── Body / Empty ───────── */ +.nhd-body { + flex: 1; + overflow: auto; +} + +.nhd-empty { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 18px 16px; + border: 1px dashed var(--nhd-g300); + border-radius: 10px; + background: var(--nhd-g50); + color: var(--nhd-g700); + + fa-icon { + color: var(--nhd-blue); + font-size: 18px; + margin-top: 2px; + } + + p { + margin: 4px 0 0; + font-size: 12.5px; + color: var(--nhd-g500); + } +} + +/* ───────── Timeline ───────── */ +.nhd-timeline { + list-style: none; + margin: 0; + padding: 6px 0 0 28px; + position: relative; + + &::before { + content: ''; + position: absolute; + left: 11px; + top: 0; + bottom: 8px; + width: 2px; + background: var(--nhd-g200); + } +} + +.nhd-tl-item { + position: relative; + padding-bottom: 16px; +} + +.nhd-tl-dot { + position: absolute; + left: -28px; + top: 4px; + width: 24px; + height: 24px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + color: #fff; + background: var(--nhd-blue-d); + border: 3px solid #fff; + box-shadow: 0 0 0 2px var(--nhd-blue-tint); + + .accent-amber & { + background: var(--nhd-amber); + box-shadow: 0 0 0 2px #fde68a; + } + + .accent-red & { + background: var(--nhd-red); + box-shadow: 0 0 0 2px var(--nhd-red-tint); + } + + .accent-purple & { + background: var(--nhd-purple); + box-shadow: 0 0 0 2px var(--nhd-purple-tint); + } +} + +.nhd-tl-card { + border: 1px solid var(--nhd-g200); + border-radius: 12px; + background: #fff; + padding: 12px 14px; +} + +.nhd-tl-head { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--nhd-g200); +} + +.nhd-stage-pill { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.02em; + text-transform: uppercase; + + &--blue { + background: var(--nhd-blue-l); + color: var(--nhd-blue-d); + } + + &--amber { + background: var(--nhd-amber-l); + color: var(--nhd-amber); + } + + &--red { + background: var(--nhd-red-l); + color: var(--nhd-red); + } + + &--purple { + background: var(--nhd-purple-l); + color: var(--nhd-purple); + } +} + +.nhd-tl-meta { + font-size: 11.5px; + color: var(--nhd-g500); + + strong { + color: var(--nhd-g900); + } +} + +.nhd-tl-id { + margin-left: auto; + font-size: 11px; + color: var(--nhd-g500); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.nhd-tl-actions { + display: inline-flex; + gap: 4px; +} + +.nhd-action-btn { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--nhd-g200); + background: #fff; + color: var(--nhd-g500); + border-radius: 7px; + cursor: pointer; + font-size: 11px; + transition: + background 0.15s, + color 0.15s, + border-color 0.15s; + + &:hover { + background: var(--nhd-blue-l); + color: var(--nhd-blue-d); + border-color: var(--nhd-blue-tint); + } +} + +.nhd-no-sends { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: var(--nhd-amber); + + fa-icon { + font-size: 12px; + } +} + +/* ───────── Send table ───────── */ +.nhd-send-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + th { + text-align: left; + padding: 6px 10px; + background: var(--nhd-g50); + color: var(--nhd-g500); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 10px; + border-bottom: 1px solid var(--nhd-g200); + } + + td { + padding: 8px 10px; + border-bottom: 1px solid var(--nhd-g100); + color: var(--nhd-g900); + vertical-align: top; + } + + tr:last-child td { + border-bottom: 0; + } + + .small { + font-size: 11.5px; + } + + .mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: var(--nhd-g700); + } +} + +.nhd-channel { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 700; + color: var(--nhd-g900); + + fa-icon { + color: var(--nhd-blue); + font-size: 12px; + } +} + +.nhd-status-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + + &--green { + background: var(--nhd-green-l); + color: var(--nhd-green); + } + + &--amber { + background: var(--nhd-amber-l); + color: var(--nhd-amber); + } + + &--red { + background: var(--nhd-red-l); + color: var(--nhd-red); + } +} + +/* ───────── Postal-dispatch detail strip (under POST send rows) ───────── */ +.nhd-post-row td { + padding: 6px 10px 10px; + background: #fff7ed; + border-bottom: 1px solid var(--nhd-g100); +} + +.nhd-post-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.nhd-post-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: 999px; + background: #fff; + border: 1px solid #fed7aa; + font-size: 11px; + color: var(--nhd-g900); + + fa-icon { + font-size: 10px; + color: #c2410c; + } +} + +.nhd-post-l { + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--nhd-g500); +} + +.nhd-post-v { + font-size: 11.5px; + font-weight: 700; + color: var(--nhd-g900); + + &.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } +} diff --git a/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.ts b/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.ts new file mode 100644 index 000000000..633f83582 --- /dev/null +++ b/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.ts @@ -0,0 +1,133 @@ +/** + * 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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { + downloadNoticePdf, + NoticeChannel, + NoticeEvent, + NoticeStage, + OverdueLoan, + SendStatus +} from '../notice.component'; +import { + NoticePreviewDialogComponent, + NoticePreviewDialogData +} from '../notice-preview-dialog/notice-preview-dialog.component'; + +export interface NoticeHistoryDialogData { + loan: OverdueLoan; +} + +@Component({ + selector: 'mifosx-notice-history-dialog', + templateUrl: './notice-history-dialog.component.html', + styleUrls: ['./notice-history-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FaIconComponent, + MatTooltip + ] +}) +export class NoticeHistoryDialogComponent { + private dialogRef = inject(MatDialogRef); + private dialog = inject(MatDialog); + private snack = inject(MatSnackBar); + data: NoticeHistoryDialogData = inject(MAT_DIALOG_DATA); + + close(): void { + this.dialogRef.close(); + } + + /** Opens the printable preview for a specific notice in the timeline. */ + viewNotice(notice: NoticeEvent): void { + const data: NoticePreviewDialogData = { loan: this.data.loan, notice }; + this.dialog.open(NoticePreviewDialogComponent, { + width: '880px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false, + panelClass: 'ntc-preview-dialog', + data + }); + } + + /** Builds and downloads the PDF version of a specific historical notice. */ + downloadNotice(notice: NoticeEvent): void { + downloadNoticePdf(this.data.loan, notice); + this.snack.open(`Notice ${notice.id} downloaded as PDF.`, 'OK', { duration: 2200 }); + } + + stageLabel(s: NoticeStage): string { + return s === 'FIRST' + ? '1st Notice' + : s === 'SECOND' + ? '2nd Notice' + : s === 'FINAL' + ? 'Final Notice' + : 'Legal Notice'; + } + + stageColor(s: NoticeStage): string { + return s === 'FIRST' ? 'blue' : s === 'SECOND' ? 'amber' : s === 'FINAL' ? 'red' : 'purple'; + } + + channelLabel(c: NoticeChannel): string { + switch (c) { + case 'EMAIL': + return 'Email'; + case 'POST': + return 'Speed Post'; + case 'COURIER': + return 'Courier'; + case 'SMS': + return 'SMS'; + case 'WHATSAPP': + return 'WhatsApp'; + case 'IN_PERSON': + return 'In-Person'; + } + } + + channelIcon(c: NoticeChannel): string { + switch (c) { + case 'EMAIL': + return 'envelope'; + case 'POST': + return 'stamp'; + case 'COURIER': + return 'paper-plane'; + case 'SMS': + return 'mobile-alt'; + case 'WHATSAPP': + return 'comment-alt'; + case 'IN_PERSON': + return 'user-tie'; + } + } + + statusColor(s: SendStatus): string { + switch (s) { + case 'DELIVERED': + return 'green'; + case 'PENDING': + return 'amber'; + case 'FAILED': + case 'BOUNCED': + return 'red'; + } + } +} diff --git a/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.html b/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.html new file mode 100644 index 000000000..bcca421e7 --- /dev/null +++ b/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.html @@ -0,0 +1,150 @@ + + +
+ +
+
+ +
+
{{ stageLabel(data.notice.stage) }}
+
{{ data.notice.id }} · {{ data.notice.generatedAt }}
+
+
+
+ + + +
+
+ + +
+ +
+
{{ bankName }}
+
{{ bankAddress }}
+
{{ bankRegNo }} · {{ bankContact }}
+
+ + +
+
+
Notice Reference
+
{{ data.notice.id }}
+
+
+
Date of Issue
+
{{ data.notice.generatedAt.slice(0, 10) | date: 'd MMMM y' }}
+
+
+
Stage
+
+ + {{ stageLabel(data.notice.stage) }} + +
+
+
+ + +
+
To,
+
+
+ {{ data.loan.customerName }} +
+
+ Customer ID: {{ data.loan.customerId }} +
+
{{ data.loan.address }}
+
Mob: +91 {{ data.loan.mobile }} · {{ data.loan.email }}
+
+
+ + +
+ Subject: + {{ letter.subject }} +
+ + +

{{ letter.salutation }}

+

{{ letter.intro }}

+ @for (p of letter.body; track p) { +

{{ p }}

+ } + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Loan Account No. + {{ data.loan.accountNo }} + Branch{{ data.loan.branch }}
Days Past Due + {{ data.loan.daysOverdue }} + Last Paid Date{{ data.loan.lastPaidDate | date: 'd MMM y' }}
Overdue Principal₹{{ data.loan.overduePrincipal | number: '1.2-2' }}Overdue Interest₹{{ data.loan.overdueInterest | number: '1.2-2' }}
TOTAL DUES OUTSTANDING + ₹{{ data.loan.overdueTotal | number: '1.2-2' }} +
+ + +

{{ letter.callToAction }}

+ + +

{{ letter.consequences }}

+ + +

{{ letter.signOff }}

+
+
+
+
+ For {{ bankName }} +
+
Authorised Signatory — Recovery & Collections
+
{{ data.loan.branch }}
+
+
+ + + +
+
diff --git a/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.scss b/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.scss new file mode 100644 index 000000000..f7ea5b786 --- /dev/null +++ b/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.scss @@ -0,0 +1,501 @@ +/** + * 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/. + */ + +:host { + --npd-blue: #1d4ed8; + --npd-blue-d: #1e40af; + --npd-blue-l: #eff6ff; + --npd-blue-tint: #dbeafe; + --npd-amber: #d97706; + --npd-amber-l: #fef3c7; + --npd-red: #b91c1c; + --npd-red-l: #fee2e2; + --npd-red-tint: #fecaca; + --npd-purple: #6d28d9; + --npd-purple-l: #ede9fe; + --npd-g50: #f8fafc; + --npd-g100: #f1f5f9; + --npd-g200: #e2e8f0; + --npd-g300: #cbd5e1; + --npd-g500: #64748b; + --npd-g700: #334155; + --npd-g900: #0f172a; + + display: block; +} + +.npd-root { + background: var(--npd-g100); + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ───────── Toolbar (hidden on print) ───────── */ +.npd-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 12px 16px; + background: #fff; + border-bottom: 1px solid var(--npd-g200); + flex-shrink: 0; +} + +.npd-toolbar-l { + display: flex; + align-items: center; + gap: 12px; +} + +.npd-toolbar-ic { + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--npd-blue-d); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 15px; +} + +.npd-toolbar-t { + font-size: 14px; + font-weight: 800; + color: var(--npd-g900); +} + +.npd-toolbar-s { + font-size: 11.5px; + color: var(--npd-g500); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.npd-toolbar-r { + display: inline-flex; + gap: 8px; +} + +.npd-tb-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border: 1px solid var(--npd-g200); + background: #fff; + color: var(--npd-g700); + font-size: 12.5px; + font-weight: 700; + border-radius: 8px; + cursor: pointer; + transition: + background 0.15s, + color 0.15s, + border-color 0.15s; + + fa-icon { + font-size: 12px; + color: var(--npd-blue); + } + + &:hover { + background: var(--npd-blue-l); + border-color: var(--npd-blue-tint); + color: var(--npd-blue-d); + } + + &.npd-tb-x { + width: 34px; + padding: 0; + justify-content: center; + + &:hover { + background: var(--npd-red-l); + color: var(--npd-red); + border-color: var(--npd-red-tint); + + fa-icon { + color: var(--npd-red); + } + } + } +} + +/* ───────── Letter page (A4 feel) ───────── */ +.npd-page { + background: #fff; + border: 1px solid var(--npd-g200); + padding: 38px 48px 32px; + margin: 18px auto; + width: min(720px, 94%); + border-top: 6px solid var(--npd-blue-d); + font-family: Georgia, 'Times New Roman', Times, serif; + color: var(--npd-g900); + line-height: 1.6; + overflow: auto; + + &.tone-firm { + border-top-color: var(--npd-amber); + } + + &.tone-urgent { + border-top-color: var(--npd-red); + } + + &.tone-legal { + border-top-color: var(--npd-purple); + } +} + +/* ───────── Letterhead ───────── */ +.npd-letterhead { + text-align: center; + padding-bottom: 14px; + margin-bottom: 18px; + border-bottom: 1px solid var(--npd-g200); +} + +.npd-bank-name { + font-size: 19px; + font-weight: 800; + letter-spacing: 0.02em; + color: var(--npd-blue-d); + + .tone-firm & { + color: var(--npd-amber); + } + + .tone-urgent & { + color: var(--npd-red); + } + + .tone-legal & { + color: var(--npd-purple); + } +} + +.npd-bank-sub { + font-size: 11.5px; + color: var(--npd-g500); + margin-top: 2px; + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +/* ───────── Meta row ───────── */ +.npd-meta-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 12px; + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px dashed var(--npd-g200); + + > div { + display: flex; + flex-direction: column; + gap: 2px; + } +} + +.npd-meta-l { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--npd-g500); +} + +.npd-meta-v { + font-size: 12.5px; + color: var(--npd-g900); + font-weight: 700; +} + +.npd-stage-pill { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + + &--polite { + background: var(--npd-blue-l); + color: var(--npd-blue-d); + } + + &--firm { + background: var(--npd-amber-l); + color: var(--npd-amber); + } + + &--urgent { + background: var(--npd-red-l); + color: var(--npd-red); + } + + &--legal { + background: var(--npd-purple-l); + color: var(--npd-purple); + } +} + +/* ───────── Addressee + subject ───────── */ +.npd-block { + display: flex; + gap: 8px; + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 13px; + margin-bottom: 14px; +} + +.npd-block-l { + font-weight: 700; + color: var(--npd-g700); + width: 36px; + flex-shrink: 0; +} + +.npd-block-v { + flex: 1; + color: var(--npd-g900); + line-height: 1.5; +} + +.npd-subject { + margin: 14px 0; + padding: 8px 0; + border-top: 1px solid var(--npd-g200); + border-bottom: 1px solid var(--npd-g200); + font-size: 13px; + + .npd-subject-l { + font-weight: 800; + color: var(--npd-g700); + margin-right: 6px; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 11px; + } + + .npd-subject-v { + font-weight: 700; + color: var(--npd-g900); + } +} + +/* ───────── Body paragraphs ───────── */ +.npd-salutation { + font-size: 13.5px; + margin: 16px 0 8px; + font-weight: 700; + color: var(--npd-g900); +} + +.npd-para { + margin: 10px 0; + font-size: 13px; + text-align: justify; + color: var(--npd-g900); +} + +.npd-cta { + background: var(--npd-blue-l); + border-left: 4px solid var(--npd-blue-d); + padding: 10px 14px; + font-weight: 600; + border-radius: 0 6px 6px 0; + + .tone-firm & { + background: var(--npd-amber-l); + border-left-color: var(--npd-amber); + } + + .tone-urgent & { + background: var(--npd-red-l); + border-left-color: var(--npd-red); + } + + .tone-legal & { + background: var(--npd-purple-l); + border-left-color: var(--npd-purple); + } +} + +.npd-consequences { + font-size: 12px; + color: var(--npd-g700); + font-style: italic; +} + +/* ───────── Summary table ───────── */ +.npd-summary { + width: 100%; + margin: 16px 0; + border-collapse: collapse; + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 12px; + + td { + padding: 8px 10px; + border: 1px solid var(--npd-g200); + vertical-align: top; + color: var(--npd-g900); + } + + td:nth-child(odd) { + background: var(--npd-g50); + color: var(--npd-g500); + font-weight: 700; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.04em; + width: 22%; + } + + .mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + + .npd-summary-total td { + background: var(--npd-blue-l); + color: var(--npd-blue-d); + font-weight: 800; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + + &:last-child { + text-align: right; + font-variant-numeric: tabular-nums; + } + } + + .tone-urgent &, + .tone-legal & { + .npd-summary-total td { + background: var(--npd-red-l); + color: var(--npd-red); + } + } +} + +/* ───────── Sign-off ───────── */ +.npd-signoff { + margin-top: 24px; + font-size: 13px; + color: var(--npd-g900); +} + +.npd-signature { + display: flex; + margin-top: 38px; +} + +.npd-sig-line { + width: 220px; + border-top: 1px solid var(--npd-g500); + margin-bottom: 6px; +} + +.npd-sig-name { + font-size: 12.5px; + color: var(--npd-g900); +} + +.npd-sig-role { + font-size: 11px; + color: var(--npd-g500); + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +.npd-footer { + margin-top: 30px; + padding-top: 12px; + border-top: 1px dashed var(--npd-g200); + display: flex; + justify-content: space-between; + align-items: center; + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 10.5px; + color: var(--npd-g500); + + .mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +/* ───────── Print: show only the letter, no toolbar, no dialog chrome ───────── */ +@media print { + :host { + background: #fff; + } + + .no-print { + display: none !important; + } + + .npd-root { + background: #fff; + max-height: none; + overflow: visible; + height: auto; + } + + .npd-page { + border: 0; + border-top: 4px solid var(--npd-blue-d); + margin: 0; + padding: 24px 32px; + width: 100%; + box-shadow: none; + } +} diff --git a/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.ts b/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.ts new file mode 100644 index 000000000..3641b8920 --- /dev/null +++ b/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.ts @@ -0,0 +1,159 @@ +/** + * 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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { downloadNoticePdf, NoticeEvent, NoticeStage, OverdueLoan } from '../notice.component'; + +export interface NoticePreviewDialogData { + loan: OverdueLoan; + notice: NoticeEvent; +} + +interface LetterCopy { + subject: string; + salutation: string; + intro: string; + body: string[]; + callToAction: string; + consequences: string; + signOff: string; + tone: 'polite' | 'firm' | 'urgent' | 'legal'; +} + +@Component({ + selector: 'mifosx-notice-preview-dialog', + templateUrl: './notice-preview-dialog.component.html', + styleUrls: ['./notice-preview-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FaIconComponent, + MatTooltip + ] +}) +export class NoticePreviewDialogComponent { + private dialogRef = inject(MatDialogRef); + data: NoticePreviewDialogData = inject(MAT_DIALOG_DATA); + + readonly bankName = 'Nexora Co-operative Bank Ltd.'; + readonly bankAddress = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051'; + readonly bankRegNo = 'RBI Lic. No. NX-2018/MH/482'; + readonly bankContact = 'recovery@nexorabank.in · 022 6789 0000'; + + /** The letter body adapts to the notice stage — polite reminder up to legal demand. */ + get letter(): LetterCopy { + return buildLetterCopy(this.data.loan, this.data.notice); + } + + close(): void { + this.dialogRef.close(); + } + + printNotice(): void { + window.print(); + } + + /** Builds and saves the notice as a PDF using the shared jsPDF generator. */ + downloadPdf(): void { + downloadNoticePdf(this.data.loan, this.data.notice); + } + + stageLabel = stageLabel; +} + +function stageLabel(s: NoticeStage): string { + return s === 'FIRST' + ? '1st Reminder Notice' + : s === 'SECOND' + ? '2nd Reminder Notice' + : s === 'FINAL' + ? 'Final Demand Notice' + : 'Legal Notice (Pre-Litigation)'; +} + +function buildLetterCopy(loan: OverdueLoan, notice: NoticeEvent): LetterCopy { + const overdue = `Rs. ${loan.overdueTotal.toLocaleString('en-IN')}`; + const lastPaid = loan.lastPaidDate; + + switch (notice.stage) { + case 'FIRST': + return { + tone: 'polite', + subject: `Reminder — Overdue dues on Loan A/c ${loan.accountNo}`, + salutation: `Dear ${loan.customerName},`, + intro: + 'We refer to your captioned loan account with our bank. Our records indicate that your account has fallen into arrears as detailed below.', + body: [ + `This is a gentle reminder requesting you to regularise the account at the earliest. The overdue amount of ${overdue} (as on date) has remained unpaid since your last payment on ${lastPaid}.`, + 'Maintaining a healthy repayment record helps preserve your credit score and avoids additional penal interest. If you have already made the payment in the last 24 hours, kindly disregard this notice.' + ], + callToAction: `Action Requested: Please remit the total dues of ${overdue} within 7 days from the date of this notice through any of our accepted channels — branch deposit, NEFT/RTGS, IMPS, or UPI.`, + consequences: + 'Failure to clear the dues may attract additional penal interest charges in accordance with the sanction terms and may lead to escalation to the next stage of recovery action.', + signOff: 'Yours sincerely,' + }; + + case 'SECOND': + return { + tone: 'firm', + subject: `Second Reminder — Overdue dues on Loan A/c ${loan.accountNo}`, + salutation: `Dear ${loan.customerName},`, + intro: + 'Despite our earlier reminder, our records show that the dues in your captioned loan account remain unsettled.', + body: [ + `The overdue amount of ${overdue} continues to remain outstanding. Your last payment was received on ${lastPaid}, after which no further repayment has been credited to the account.`, + 'We urge you to treat this matter with utmost urgency and clear the dues immediately to avoid further action on the account.' + ], + callToAction: `Action Required: Kindly regularise the account by paying ${overdue} within 7 days from this notice. If you have already made the payment, please share the UTR / transaction reference with our recovery team so we can update the records.`, + consequences: + 'Continued non-payment may result in your account being classified as a Special Mention Account (SMA) per RBI guidelines, reported to credit bureaus (CIBIL/Experian/Equifax/CRIF), and escalated for further recovery action including issuance of a final demand notice.', + signOff: 'Regards,' + }; + + case 'FINAL': + return { + tone: 'urgent', + subject: `FINAL DEMAND NOTICE — Loan A/c ${loan.accountNo}`, + salutation: `Dear ${loan.customerName},`, + intro: + 'This communication serves as the FINAL DEMAND NOTICE in respect of your captioned loan account, which continues to remain in default despite our prior reminders.', + body: [ + `As on date, an amount of ${overdue} is overdue and payable. Your last payment was received on ${lastPaid}. No payments have been credited since then.`, + 'You are hereby called upon to clear the entire overdue amount along with applicable penal interest within 15 days from the date of this notice.' + ], + callToAction: `Action Required: Payment of ${overdue} (plus applicable penal interest accrued up to the date of payment) must reach the bank within 15 days from receipt of this notice.`, + consequences: + 'Failure to comply within the stipulated period shall constrain the bank, without further reference to you, to (i) classify your account as a Non-Performing Asset (NPA) per RBI norms, (ii) report the same to all credit information companies, and (iii) initiate appropriate legal and recovery proceedings, including action under the SARFAESI Act, 2002 / RDDB Act, 1993 where applicable, with all costs and consequences thereof being recoverable from you.', + signOff: 'For and on behalf of the bank,' + }; + + case 'LEGAL': + default: + return { + tone: 'legal', + subject: `LEGAL NOTICE — Loan A/c ${loan.accountNo} — Demand for Payment`, + salutation: `To ${loan.customerName},`, + intro: + 'Take notice that the bank, through its undersigned authorised officer, hereby serves upon you this Legal Notice in respect of the captioned loan account.', + body: [ + `That you availed credit facilities from the bank vide Loan Account No. ${loan.accountNo}. That as on the date of this notice, a sum of ${overdue} is outstanding and payable by you to the bank, having remained unpaid since ${lastPaid}, in spite of repeated reminders and demands made by the bank.`, + 'That your continued default constitutes a breach of the sanction terms and loan agreement entered into between you and the bank, and the entire outstanding has become recallable forthwith.' + ], + callToAction: `You are hereby called upon to pay to the bank a sum of ${overdue} together with further interest, penal charges, and costs, within 15 days from receipt of this notice, failing which the bank shall be constrained to initiate appropriate legal proceedings against you, including but not limited to proceedings under the SARFAESI Act, 2002, the Negotiable Instruments Act, 1881, and / or recovery proceedings before the Debts Recovery Tribunal (DRT), entirely at your risk, cost, and consequences.`, + consequences: + 'Please treat this notice with the seriousness it deserves. The bank reserves the right to take any and all such steps as may be advised in law, without any further reference to you, in the event of your continued default. A copy of this notice is being retained for legal record.', + signOff: 'Authorised Officer,' + }; + } +} diff --git a/src/app/letter-mgmt/notice/notice.component.html b/src/app/letter-mgmt/notice/notice.component.html new file mode 100644 index 000000000..7819067b2 --- /dev/null +++ b/src/app/letter-mgmt/notice/notice.component.html @@ -0,0 +1,371 @@ + + +
+ +
+
+ +
+

Loan Overdue Notices

+
+ Generate, dispatch, and audit notices for accounts with overdue dues. Bulk operations supported. +
+
+
+
+ + +
+
+ +
+
{{ totalOverdueAccounts | number }}
+
Overdue Accounts
+
+
+
+ +
+
₹{{ totalOverdueAmount | number: '1.0-0' }}
+
Total Overdue Value
+
+
+
+ +
+
{{ noticesGeneratedCount | number }}
+
Notices Generated
+
+
+
+ +
+
{{ noticesPendingGenCount | number }}
+
Pending Generation
+
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+ +
+
+ +
+ + +
+
+ @if (filterMode === 'range') { +
+ + +
+ } @else { +
+ +
+ } +
+ + @if (search || accountTypeFilter || startDate || endDate || asOnDate) { + + } +
+ + + @if (selectedCount > 0) { +
+
+ + {{ selectedCount }} selected +
+
+ + + + +
+
+ } + + + + + +
+ +
+
+ {{ loan.customerName.charAt(0).toUpperCase() }} +
+
+
{{ loan.customerName }}
+
{{ loan.accountNo }}
+
+ {{ accountTypeLabel(loan.accountType) }} +
+ + +
+
+ Customer ID + {{ loan.customerId }} +
+
+ Branch + {{ loan.branch }} +
+
+ + +
+
+ Overdue dues + ₹{{ loan.overdueTotal | number: '1.0-0' }} +
+
+ {{ loan.daysOverdue }} + days past due +
+
+
+ + +
+
Notice document
+
+ + +
+
+ +
+
Send to customer
+
+ + + + +
+
+ +
+
Customer
+
+ + + + +
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + @for (loan of filteredLoans; track loan.accountNo) { + + + + + + + + + + + + + } + @if (filteredLoans.length === 0) { + + + + } + +
+ + Customer · A/CTypeBranchOverdueDPDLast PaidNotice StatusLast SentActions
+ + +
{{ loan.customerName }}
+
{{ loan.accountNo }} · {{ loan.customerId }}
+
+ {{ accountTypeLabel(loan.accountType) }} + {{ loan.branch }} +
₹{{ loan.overdueTotal | number: '1.0-0' }}
+
+ P ₹{{ loan.overduePrincipal | number: '1.0-0' }} · I ₹{{ loan.overdueInterest | number: '1.0-0' }} +
+
+ + {{ loan.daysOverdue }} d + + {{ loan.lastPaidDate | date: 'd MMM y' }} + @switch (noticeStatus(loan)) { + @case ('NONE') { + + Not Generated + + } + @case ('GENERATED') { + + + {{ stageLabel(latestNotice(loan)!.stage) }} · Awaiting Send + + } + @case ('SENT') { + + + {{ stageLabel(latestNotice(loan)!.stage) }} · Sent ({{ totalSendCount(loan) }}) + + } + } + + @if (latestNotice(loan) && latestNotice(loan)!.sends.length > 0) { +
+ + {{ channelLabel(latestNotice(loan)!.sends[0].channel) }} +
+
{{ latestNotice(loan)!.sends[0].sentAt }}
+ } @else { + — + } +
+
+ + +
+
+ + No overdue accounts match the current filters. +
+
+
+
diff --git a/src/app/letter-mgmt/notice/notice.component.scss b/src/app/letter-mgmt/notice/notice.component.scss new file mode 100644 index 000000000..527dc7c1b --- /dev/null +++ b/src/app/letter-mgmt/notice/notice.component.scss @@ -0,0 +1,1028 @@ +/** + * 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/. + */ + +:host { + --ntc-blue: #1d4ed8; + --ntc-blue-d: #1e40af; + --ntc-blue-l: #eff6ff; + --ntc-blue-tint: #dbeafe; + --ntc-teal: #0d9488; + --ntc-teal-l: #ccfbf1; + --ntc-green: #15803d; + --ntc-green-d: #166534; + --ntc-green-l: #dcfce7; + --ntc-amber: #d97706; + --ntc-amber-l: #fef3c7; + --ntc-red: #b91c1c; + --ntc-red-l: #fee2e2; + --ntc-red-tint: #fecaca; + --ntc-purple: #6d28d9; + --ntc-purple-l: #ede9fe; + --ntc-g50: #f8fafc; + --ntc-g100: #f1f5f9; + --ntc-g200: #e2e8f0; + --ntc-g300: #cbd5e1; + --ntc-g500: #64748b; + --ntc-g700: #334155; + --ntc-g900: #0f172a; + + display: block; +} + +.ntc-root { + padding: 20px 24px 32px; + max-width: 1340px; + margin: 0 auto; +} + +/* ───────── Header ───────── */ +.ntc-head { + display: flex; + align-items: center; + gap: 16px; + padding: 18px 22px; + border-radius: 14px; + background: linear-gradient(135deg, var(--ntc-blue-l) 0%, #fff 65%); + border: 1px solid var(--ntc-blue-tint); + margin-bottom: 16px; +} + +.ntc-head-l { + display: flex; + align-items: center; + gap: 14px; +} + +.ntc-head-ic { + width: 48px; + height: 48px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + background: var(--ntc-blue-d); + color: #fff; + font-size: 20px; +} + +.ntc-head-t { + margin: 0 0 3px; + font-size: 17px; + font-weight: 800; + color: var(--ntc-g900); +} + +.ntc-head-s { + font-size: 12.5px; + color: var(--ntc-g500); + line-height: 1.5; +} + +/* ───────── KPI strip ───────── */ +.ntc-kpis { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 14px; + + @media (width <= 900px) { + grid-template-columns: repeat(2, 1fr); + } +} + +.ntc-kpi { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 12px; + background: #fff; + border: 1px solid var(--ntc-g200); + + &--blue { + background: var(--ntc-blue-l); + border-color: var(--ntc-blue-tint); + } + + &--red { + background: var(--ntc-red-l); + border-color: var(--ntc-red-tint); + } + + &--green { + background: var(--ntc-green-l); + border-color: #bbf7d0; + } + + &--amber { + background: var(--ntc-amber-l); + border-color: #fde68a; + } +} + +.ntc-kpi-ic { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: rgb(255 255 255 / 70%); + color: var(--ntc-g900); + font-size: 16px; + + .ntc-kpi--blue & { + color: var(--ntc-blue); + } + + .ntc-kpi--red & { + color: var(--ntc-red); + } + + .ntc-kpi--green & { + color: var(--ntc-green-d); + } + + .ntc-kpi--amber & { + color: var(--ntc-amber); + } +} + +.ntc-kpi-n { + font-size: 17px; + font-weight: 800; + color: var(--ntc-g900); + font-variant-numeric: tabular-nums; + line-height: 1.15; +} + +.ntc-kpi-l { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ntc-g500); + margin-top: 2px; +} + +/* ───────── Filters ───────── */ +.ntc-filters { + display: flex; + align-items: flex-end; + gap: 12px; + padding: 14px; + background: #fff; + border: 1px solid var(--ntc-g200); + border-radius: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.ntc-filter { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1 1 0; + min-width: 220px; + min-height: 0; + + /* Each filter column is its own positioning context — keeps the search icon + (absolutely positioned inside .ntc-input-ic) firmly inside its own column. */ + position: relative; + overflow: visible; + + label { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--ntc-g500); + } + + select, + input { + box-sizing: border-box; + width: 100%; + height: 36px; + padding: 0 12px; + border: 1px solid var(--ntc-g200); + border-radius: 8px; + background: #fff; + font-size: 13px; + color: var(--ntc-g900); + outline: none; + transition: border-color 0.15s ease; + appearance: none; + + &:focus { + border-color: var(--ntc-blue); + } + } + + /* Custom chevron for selects — the native one wasn't reliably padded, which + caused option text to be visually clipped behind it on some Chrome builds. */ + select { + padding-right: 34px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%2364748b' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='1 2 6 7 11 2'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 12px 8px; + cursor: pointer; + } +} + +.ntc-input-ic { + position: relative; + display: block; + width: 100%; + + fa-icon { + position: absolute; + left: 11px; + top: 50%; + transform: translateY(-50%); + color: var(--ntc-g500); + font-size: 12px; + pointer-events: none; + z-index: 1; + } + + input { + width: 100%; + padding-left: 32px; + } +} + +.ntc-filter--date { + flex: 1.4; + min-width: 300px; +} + +/* Header row: column label on the left, mode toggle on the right. + Same vertical footprint as other filters' label row → no taller column, + no toggle leaking into adjacent columns. */ +.ntc-date-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 16px; + + label { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--ntc-g500); + margin: 0; + } +} + +.ntc-date-toggle { + display: inline-flex; + background: var(--ntc-g100); + border: 1px solid var(--ntc-g200); + border-radius: 999px; + padding: 2px; + + button { + border: 0; + background: transparent; + padding: 2px 10px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.03em; + color: var(--ntc-g500); + border-radius: 999px; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; + + &.is-on { + background: #fff; + color: var(--ntc-blue-d); + box-shadow: 0 1px 3px rgb(15 23 42 / 12%); + } + } +} + +.ntc-date-row { + display: flex; + gap: 8px; + + > input { + flex: 1; + min-width: 0; + } +} + +.ntc-clear { + height: 36px; + padding: 0 14px; + border: 1px solid var(--ntc-g200); + border-radius: 8px; + background: #fff; + color: var(--ntc-g700); + font-size: 12px; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + + &:hover { + background: var(--ntc-red-l); + color: var(--ntc-red); + border-color: var(--ntc-red-tint); + } +} + +/* ───────── Bulk action bar ───────── */ +.ntc-bulk-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 10px 16px; + background: var(--ntc-blue-d); + color: #fff; + border-radius: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.ntc-bulk-l { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + + strong { + font-size: 15px; + } +} + +.ntc-bulk-r { + display: inline-flex; + gap: 8px; + flex-wrap: wrap; +} + +.ntc-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: 8px; + border: 1px solid transparent; + font-size: 12.5px; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + transition: + background 0.15s, + color 0.15s, + border-color 0.15s; + + fa-icon { + font-size: 12px; + } +} + +.ntc-btn-primary { + background: #fff; + color: var(--ntc-blue-d); + + &:hover { + background: var(--ntc-blue-l); + } +} + +.ntc-btn-secondary { + background: rgb(255 255 255 / 14%); + color: #fff; + border-color: rgb(255 255 255 / 24%); + + &:hover { + background: rgb(255 255 255 / 22%); + } +} + +/* ───────── Table card ───────── */ +.ntc-card { + background: #fff; + border: 1px solid var(--ntc-g200); + border-radius: 12px; + overflow: hidden; +} + +.ntc-table-wrap { + overflow-x: visible; +} + +.ntc-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + /* Default to wrapping content — only force nowrap on specific columns below + so the table fits the viewport without a horizontal scrollbar. */ + white-space: normal; + + th { + text-align: left; + padding: 12px; + background: var(--ntc-g50); + color: var(--ntc-g500); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 10.5px; + border-bottom: 1px solid var(--ntc-g200); + position: sticky; + top: 0; + z-index: 1; + } + + td { + padding: 12px; + border-bottom: 1px solid var(--ntc-g100); + vertical-align: top; + color: var(--ntc-g900); + } + + tr:last-child td { + border-bottom: 0; + } + + tr.is-selected td { + background: var(--ntc-blue-l); + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + + .small { + font-size: 11.5px; + } + + .muted { + color: var(--ntc-g500); + } + + .col-sel { + width: 36px; + text-align: center; + white-space: nowrap; + } + + .col-actions { + width: 84px; + white-space: nowrap; + text-align: right; + } + + /* Keep monospace IDs, currency, DPD pills, and dates on one line — these are + short cells that look odd if they wrap. */ + .mono, + .num { + white-space: nowrap; + } + + // stylelint-disable-next-line selector-no-qualifying-type + input[type='checkbox'] { + width: 14px; + height: 14px; + accent-color: var(--ntc-blue); + cursor: pointer; + } +} + +.ntc-cust-name { + font-weight: 700; + color: var(--ntc-g900); +} + +.ntc-amount { + font-weight: 800; + color: var(--ntc-red); +} + +/* Pills */ +.ntc-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + background: var(--ntc-g100); + color: var(--ntc-g700); + white-space: nowrap; + + fa-icon { + font-size: 10.5px; + } + + &--type { + background: var(--ntc-purple-l); + color: var(--ntc-purple); + } + + &--blue { + background: var(--ntc-blue-l); + color: var(--ntc-blue-d); + } + + &--green { + background: var(--ntc-green-l); + color: var(--ntc-green-d); + } + + &--amber { + background: var(--ntc-amber-l); + color: var(--ntc-amber); + } + + &--red { + background: var(--ntc-red-l); + color: var(--ntc-red); + } +} + +.ntc-dpd-pill { + display: inline-block; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.02em; + font-variant-numeric: tabular-nums; + + &--mild { + background: var(--ntc-amber-l); + color: var(--ntc-amber); + } + + &--severe { + background: #ffedd5; + color: #c2410c; + } + + &--critical { + background: var(--ntc-red-l); + color: var(--ntc-red); + } +} + +.ntc-last-send { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 700; + color: var(--ntc-g700); + + fa-icon { + color: var(--ntc-blue); + } +} + +/* Row action icon buttons */ +.ntc-row-actions { + display: inline-flex; + gap: 4px; +} + +/* The kebab "More actions" trigger — slightly narrower than the icon buttons + beside it since the 3-dot glyph is already vertical. */ +.ntc-more-btn { + width: 26px; + + fa-icon { + font-size: 14px; + } + + &[aria-expanded='true'] { + background: var(--ntc-blue-l); + color: var(--ntc-blue-d); + border-color: var(--ntc-blue-tint); + } +} + +.ntc-icon-btn { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--ntc-g200); + background: #fff; + color: var(--ntc-g500); + border-radius: 8px; + cursor: pointer; + font-size: 11.5px; + transition: + background 0.15s, + color 0.15s, + border-color 0.15s; + + &:hover:not(:disabled) { + background: var(--ntc-blue-l); + color: var(--ntc-blue-d); + border-color: var(--ntc-blue-tint); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + &.primary { + background: var(--ntc-blue-d); + color: #fff; + border-color: var(--ntc-blue-d); + + &:hover:not(:disabled) { + background: var(--ntc-blue); + border-color: var(--ntc-blue); + } + } +} + +.ntc-empty { + text-align: center; + padding: 28px 16px; + color: var(--ntc-g500); + font-size: 13px; + + fa-icon { + margin-right: 6px; + color: var(--ntc-blue); + } +} + +/* ───────── Row "More actions" menu (mat-menu overlay) ───────── + mat-menu renders into the CDK overlay outside this component's view + encapsulation, so every selector for the menu interior is scoped via + `::ng-deep .ntc-actions-menu` to make sure the styles actually land. +*/ +::ng-deep .ntc-actions-menu.mat-mdc-menu-panel { + min-width: 480px; + max-width: 520px; + border-radius: 14px !important; + overflow: hidden; + box-shadow: + 0 14px 32px -10px rgb(15 23 42 / 28%), + 0 4px 12px -4px rgb(15 23 42 / 14%) !important; +} + +::ng-deep .ntc-actions-menu .mat-mdc-menu-content { + padding: 0 !important; + max-height: none; +} + +/* ── Header block (identity / facts / hero) — slimmed down ── */ +::ng-deep .ntc-actions-menu .nmh { + padding: 12px 14px 10px; + background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); + border-bottom: 1px solid #e2e8f0; + cursor: default; + font-family: var(--mat-app-body-large-font, inherit); +} + +/* identity row: avatar + name + type pill */ +::ng-deep .ntc-actions-menu .nmh-id { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +::ng-deep .ntc-actions-menu .nmh-avatar { + width: 38px; + height: 38px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 15px; + font-weight: 800; + letter-spacing: -0.02em; + color: #fff; + background: #1e40af; + box-shadow: 0 0 0 3px rgb(30 64 175 / 14%); + + /* avatar tint = DPD severity (mild blue → severe amber → critical red) */ + &.nmh-avatar--mild { + background: #1e40af; + box-shadow: 0 0 0 3px rgb(30 64 175 / 14%); + } + + &.nmh-avatar--severe { + background: #c2410c; + box-shadow: 0 0 0 3px rgb(194 65 12 / 14%); + } + + &.nmh-avatar--critical { + background: #b91c1c; + box-shadow: 0 0 0 3px rgb(185 28 28 / 16%); + } +} + +::ng-deep .ntc-actions-menu .nmh-id-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; + line-height: 1.2; +} + +::ng-deep .ntc-actions-menu .nmh-name { + font-size: 14px; + font-weight: 800; + color: #0f172a; + word-break: break-word; +} + +::ng-deep .ntc-actions-menu .nmh-acc { + font-size: 11.5px; + font-weight: 600; + color: #475569; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.02em; +} + +::ng-deep .ntc-actions-menu .nmh-type { + flex-shrink: 0; + align-self: flex-start; + padding: 3px 9px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + background: #ede9fe; + color: #6d28d9; + white-space: nowrap; +} + +/* facts grid (Customer ID + Branch) */ +::ng-deep .ntc-actions-menu .nmh-facts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px 14px; + margin-bottom: 8px; +} + +::ng-deep .ntc-actions-menu .nmh-fact { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +::ng-deep .ntc-actions-menu .nmh-fact-l { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #64748b; +} + +::ng-deep .ntc-actions-menu .nmh-fact-v { + font-size: 11.5px; + font-weight: 600; + color: #0f172a; + line-height: 1.25; + word-break: break-word; + + &.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } +} + +/* Hero strip: prominent overdue value + DPD badge */ +::ng-deep .ntc-actions-menu .nmh-hero { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-radius: 10px; + border: 1px solid; + + &.nmh-hero--mild { + background: #eff6ff; + border-color: #dbeafe; + } + + &.nmh-hero--severe { + background: #fff7ed; + border-color: #fed7aa; + } + + &.nmh-hero--critical { + background: #fef2f2; + border-color: #fecaca; + } +} + +::ng-deep .ntc-actions-menu .nmh-overdue { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +::ng-deep .ntc-actions-menu .nmh-overdue-l { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #64748b; +} + +::ng-deep .ntc-actions-menu .nmh-overdue-v { + font-size: 17px; + font-weight: 800; + color: #b91c1c; + font-variant-numeric: tabular-nums; + line-height: 1.1; + letter-spacing: -0.01em; + + .nmh-hero--mild & { + color: #1e40af; + } + + .nmh-hero--severe & { + color: #c2410c; + } +} + +::ng-deep .ntc-actions-menu .nmh-dpd { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4px 12px; + border-radius: 8px; + background: rgb(255 255 255 / 72%); + border: 1px solid rgb(255 255 255 / 90%); + min-width: 64px; + + .nmh-hero--mild & { + background: #fff; + border-color: #c7d2fe; + } + + .nmh-hero--severe & { + background: #fff; + border-color: #fed7aa; + } + + .nmh-hero--critical & { + background: #fff; + border-color: #fecaca; + } +} + +::ng-deep .ntc-actions-menu .nmh-dpd-v { + font-size: 18px; + font-weight: 800; + line-height: 1; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + + .nmh-hero--mild & { + color: #1e40af; + } + + .nmh-hero--severe & { + color: #c2410c; + } + + .nmh-hero--critical & { + color: #b91c1c; + } +} + +::ng-deep .ntc-actions-menu .nmh-dpd-l { + font-size: 8.5px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #64748b; + margin-top: 1px; +} + +/* ── Action grid sections ── */ +::ng-deep .ntc-actions-menu .nma-section { + padding: 8px 12px 0; + + &:last-child { + padding-bottom: 10px; + } +} + +::ng-deep .ntc-actions-menu .nma-section-l { + padding: 4px 4px 6px; + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #94a3b8; + cursor: default; + user-select: none; +} + +::ng-deep .ntc-actions-menu .nma-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +::ng-deep .ntc-actions-menu .nma-tile { + display: inline-flex; + align-items: center; + gap: 9px; + padding: 8px 11px; + border: 1px solid #e2e8f0; + border-radius: 9px; + background: #fff; + color: #334155; + font-size: 12.5px; + font-weight: 600; + text-align: left; + cursor: pointer; + width: 100%; + min-height: 38px; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease, + transform 0.12s ease; + + &:hover:not(:disabled) { + background: #eff6ff; + border-color: #c7d2fe; + color: #1e40af; + transform: translateY(-1px); + + .nma-tile-ic { + color: #1e40af; + } + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + + .nma-tile-ic { + color: #cbd5e1; + } + } + + &:focus-visible { + outline: 2px solid #1d4ed8; + outline-offset: 2px; + } +} + +::ng-deep .ntc-actions-menu .nma-tile-ic { + width: 16px; + font-size: 13px; + color: #1d4ed8; + flex-shrink: 0; + text-align: center; + transition: color 0.15s ease; +} + +::ng-deep .ntc-actions-menu .nma-tile-l { + flex: 1; + min-width: 0; + line-height: 1.2; + letter-spacing: 0.01em; +} diff --git a/src/app/letter-mgmt/notice/notice.component.ts b/src/app/letter-mgmt/notice/notice.component.ts new file mode 100644 index 000000000..65d899b67 --- /dev/null +++ b/src/app/letter-mgmt/notice/notice.component.ts @@ -0,0 +1,1526 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatDialog } from '@angular/material/dialog'; +import { MatMenu, MatMenuContent, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { jsPDF } from 'jspdf'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { + NoticeHistoryDialogComponent, + NoticeHistoryDialogData +} from './notice-history-dialog/notice-history-dialog.component'; +import { + NoticePreviewDialogComponent, + NoticePreviewDialogData +} from './notice-preview-dialog/notice-preview-dialog.component'; +import { CallContactDialogComponent, CallContactDialogData } from './call-contact-dialog/call-contact-dialog.component'; +import { + SpeedPostDialogComponent, + SpeedPostDialogData, + SpeedPostDialogResult +} from './speed-post-dialog/speed-post-dialog.component'; + +export type LoanAccountType = 'HOUSING' | 'PERSONAL' | 'AUTO' | 'GOLD' | 'EDUCATION' | 'BUSINESS' | 'CC' | 'OD'; + +export type NoticeChannel = 'EMAIL' | 'POST' | 'COURIER' | 'SMS' | 'WHATSAPP' | 'IN_PERSON'; +export type NoticeStage = 'FIRST' | 'SECOND' | 'FINAL' | 'LEGAL'; +export type SendStatus = 'DELIVERED' | 'PENDING' | 'FAILED' | 'BOUNCED'; + +export interface SendEvent { + channel: NoticeChannel; + sentAt: string; // YYYY-MM-DD HH:mm:ss + sentBy: string; + status: SendStatus; + destination: string; + /** India Post / courier consignment / tracking number — POST + COURIER only. */ + consignmentNo?: string; + /** Originating post office or courier branch — POST + COURIER only. */ + postOffice?: string; + /** Booking date — may differ from sentAt if a counter-booking was made + and entered into the system the next day. YYYY-MM-DD. */ + bookedDate?: string; + /** Postage paid in ₹ — POST + COURIER only. */ + postage?: number; +} + +export interface NoticeEvent { + id: string; + stage: NoticeStage; + generatedAt: string; + generatedBy: string; + sends: SendEvent[]; +} + +export type CallOutcome = + | 'PROMISE_TO_PAY' + | 'NOT_REACHABLE' + | 'SWITCHED_OFF' + | 'RNR' + | 'BUSY' + | 'WRONG_NUMBER' + | 'CALLBACK_REQUESTED' + | 'REFUSED' + | 'SETTLED'; + +export type CallContactKind = 'PRIMARY' | 'ALTERNATIVE' | 'FAMILY' | 'NOMINEE'; + +export interface CallLog { + id: string; + contactKind: CallContactKind; + contactNumber: string; + contactName?: string; + /** YYYY-MM-DD HH:mm:ss */ + calledAt: string; + calledBy: string; + outcome: CallOutcome; + /** Set only when outcome === 'PROMISE_TO_PAY'. */ + promiseAmount?: number; + promiseDate?: string; + notes?: string; +} + +export interface OverdueLoan { + accountNo: string; + accountType: LoanAccountType; + customerId: string; + customerName: string; + branch: string; + email: string; + mobile: string; + /** Secondary number on file (work / spouse handset / etc.). */ + mobileAlt?: string; + /** Family member contact captured during onboarding (KYC). */ + familyName?: string; + familyRelation?: string; + familyMobile?: string; + /** Nominee captured for the loan account. */ + nomineeName?: string; + nomineeMobile?: string; + address: string; + emiAmount: number; + overduePrincipal: number; + overdueInterest: number; + overdueTotal: number; + daysOverdue: number; + lastPaidDate: string; + /** Most recent notice events first. */ + notices: NoticeEvent[]; + /** Recovery-call dispositions logged by collections operators. Newest first. */ + callLogs?: CallLog[]; +} + +@Component({ + selector: 'mifosx-notice', + templateUrl: './notice.component.html', + styleUrls: ['./notice.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip, + MatMenu, + MatMenuItem, + MatMenuTrigger, + MatMenuContent + ] +}) +export class NoticeComponent implements OnInit { + private snack = inject(MatSnackBar); + private dialog = inject(MatDialog); + + // ─── Filters ─── + search = ''; + accountTypeFilter: '' | LoanAccountType = ''; + filterMode: 'range' | 'ason' = 'range'; + startDate = ''; + endDate = ''; + asOnDate = ''; + + loans: OverdueLoan[] = []; + selected = new Set(); + + readonly accountTypeOptions: { value: LoanAccountType; label: string }[] = [ + { value: 'HOUSING', label: 'Housing Loan' }, + { value: 'PERSONAL', label: 'Personal Loan' }, + { value: 'AUTO', label: 'Auto Loan' }, + { value: 'GOLD', label: 'Gold Loan' }, + { value: 'EDUCATION', label: 'Education Loan' }, + { value: 'BUSINESS', label: 'Business Loan' }, + { value: 'CC', label: 'Cash Credit (CC)' }, + { value: 'OD', label: 'Overdraft (OD)' } + ]; + + ngOnInit(): void { + this.loans = seedOverdueLoans(); + } + + // ───────────────────── Derived state ───────────────────── + get filteredLoans(): OverdueLoan[] { + const q = this.search.trim().toLowerCase(); + return this.loans.filter((l) => { + if (this.accountTypeFilter && l.accountType !== this.accountTypeFilter) return false; + if (!this.matchesDateFilter(l)) return false; + if (!q) return true; + return ( + l.accountNo.toLowerCase().includes(q) || + l.customerName.toLowerCase().includes(q) || + l.customerId.toLowerCase().includes(q) || + l.branch.toLowerCase().includes(q) + ); + }); + } + + private matchesDateFilter(l: OverdueLoan): boolean { + if (this.filterMode === 'ason') { + if (!this.asOnDate) return true; + // "As on date" — include loans whose last-paid-date is on or before that date. + return l.lastPaidDate <= this.asOnDate; + } + // Range mode — include loans whose last-paid-date falls inside the range. + if (this.startDate && l.lastPaidDate < this.startDate) return false; + if (this.endDate && l.lastPaidDate > this.endDate) return false; + return true; + } + + get totalOverdueAccounts(): number { + return this.filteredLoans.length; + } + get totalOverdueAmount(): number { + return this.filteredLoans.reduce((s, l) => s + l.overdueTotal, 0); + } + get noticesGeneratedCount(): number { + return this.filteredLoans.filter((l) => l.notices.length > 0).length; + } + get noticesPendingGenCount(): number { + return this.filteredLoans.filter((l) => l.notices.length === 0).length; + } + + /** Loans never noticed = quick "pending generation" bucket. */ + get pendingGenerationLoans(): OverdueLoan[] { + return this.filteredLoans.filter((l) => l.notices.length === 0); + } + + // ───────────────────── Selection ───────────────────── + toggleSelect(accountNo: string, checked: boolean): void { + if (checked) this.selected.add(accountNo); + else this.selected.delete(accountNo); + } + + isSelected(accountNo: string): boolean { + return this.selected.has(accountNo); + } + + toggleSelectAll(checked: boolean): void { + if (checked) { + this.filteredLoans.forEach((l) => this.selected.add(l.accountNo)); + } else { + this.filteredLoans.forEach((l) => this.selected.delete(l.accountNo)); + } + } + + get allOnPageSelected(): boolean { + const visible = this.filteredLoans; + return visible.length > 0 && visible.every((l) => this.selected.has(l.accountNo)); + } + + get someOnPageSelected(): boolean { + const visible = this.filteredLoans; + return visible.some((l) => this.selected.has(l.accountNo)) && !this.allOnPageSelected; + } + + get selectedCount(): number { + return this.selected.size; + } + + // ───────────────────── Actions ───────────────────── + clearFilters(): void { + this.search = ''; + this.accountTypeFilter = ''; + this.startDate = ''; + this.endDate = ''; + this.asOnDate = ''; + } + + setFilterMode(mode: 'range' | 'ason'): void { + this.filterMode = mode; + } + + /** + * Stamp a new generated-notice event onto the given loan. Stage is chosen + * automatically based on prior history (1st → 2nd → Final → Legal). + */ + generateNotice(loan: OverdueLoan): void { + const stage = this.nextStage(loan); + const now = this.nowStr(); + loan.notices = [ + { + id: 'NTC' + Date.now().toString().slice(-9), + stage, + generatedAt: now, + generatedBy: 'Current User', + sends: [] + }, + ...loan.notices + ]; + this.snack.open(`${this.stageLabel(stage)} notice generated for ${loan.accountNo}`, 'OK', { duration: 2800 }); + } + + /** Generate a notice for every selected loan in one click. */ + bulkGenerate(): void { + if (this.selectedCount === 0) return; + let made = 0; + for (const acc of this.selected) { + const loan = this.loans.find((l) => l.accountNo === acc); + if (loan) { + this.generateNoticeQuiet(loan); + made++; + } + } + this.snack.open(`Generated ${made} notices.`, 'OK', { duration: 2800 }); + this.selected.clear(); + } + + private generateNoticeQuiet(loan: OverdueLoan): void { + const stage = this.nextStage(loan); + loan.notices = [ + { + id: 'NTC' + Date.now().toString().slice(-9) + Math.floor(Math.random() * 99), + stage, + generatedAt: this.nowStr(), + generatedBy: 'Current User', + sends: [] + }, + ...loan.notices + ]; + } + + /** Send the most recent generated notice via the chosen channel. */ + sendNotice(loan: OverdueLoan, channel: NoticeChannel): void { + const latest = loan.notices[0]; + if (!latest) { + this.snack.open('Generate a notice first before sending.', 'OK', { duration: 2500 }); + return; + } + + // Speed Post requires consignment + post office details before dispatch. + if (channel === 'POST') { + this.dispatchBySpeedPost(loan, latest); + return; + } + + const dest = this.destinationFor(loan, channel); + latest.sends = [ + { + channel, + sentAt: this.nowStr(), + sentBy: 'Current User', + status: 'DELIVERED', + destination: dest + }, + ...latest.sends + ]; + this.snack.open(`${this.channelLabel(channel)} sent for ${loan.accountNo}`, 'OK', { duration: 2500 }); + } + + /** Opens the Speed Post dispatch dialog. On confirm, stamps a send event with + the consignment number, originating post office, booking date, and postage. */ + private dispatchBySpeedPost(loan: OverdueLoan, notice: NoticeEvent): void { + const data: SpeedPostDialogData = { loan, notice }; + const ref = this.dialog.open( + SpeedPostDialogComponent, + { + width: '620px', + maxWidth: '94vw', + maxHeight: '88vh', + autoFocus: false, + panelClass: 'spd-dialog', + data + } + ); + ref.afterClosed().subscribe((result) => { + if (!result?.confirmed) return; + notice.sends = [ + { + channel: 'POST', + sentAt: this.nowStr(), + sentBy: 'Current User', + status: 'DELIVERED', + destination: loan.address, + consignmentNo: result.consignmentNo, + postOffice: result.postOffice, + bookedDate: result.bookedDate, + postage: result.postage + }, + ...notice.sends + ]; + this.snack.open(`Speed Post booked · ${result.consignmentNo} (${result.postOffice})`, 'OK', { duration: 3200 }); + }); + } + + /** Send the most-recent notice for every selected loan via Email + Post. */ + bulkSend(channels: NoticeChannel[] = [ + 'EMAIL', + 'POST' + ]): void { + if (this.selectedCount === 0) return; + let sent = 0; + for (const acc of this.selected) { + const loan = this.loans.find((l) => l.accountNo === acc); + const latest = loan?.notices[0]; + if (!loan || !latest) continue; + for (const channel of channels) { + latest.sends = [ + { + channel, + sentAt: this.nowStr(), + sentBy: 'Current User', + status: 'DELIVERED', + destination: this.destinationFor(loan, channel) + }, + ...latest.sends + ]; + } + sent++; + } + this.snack.open( + `Dispatched notices for ${sent} accounts via ${channels.map((c) => this.channelLabel(c)).join(' + ')}.`, + 'OK', + { + duration: 3200 + } + ); + this.selected.clear(); + } + + openHistory(loan: OverdueLoan): void { + const data: NoticeHistoryDialogData = { loan }; + this.dialog.open(NoticeHistoryDialogComponent, { + width: '780px', + maxWidth: '94vw', + maxHeight: '88vh', + autoFocus: false, + panelClass: 'ntc-history-dialog', + data + }); + } + + /** Opens the printable preview of the most-recently generated notice. */ + viewLatestNotice(loan: OverdueLoan): void { + const latest = this.latestNotice(loan); + if (!latest) { + this.snack.open('Generate a notice first to preview it.', 'OK', { duration: 2500 }); + return; + } + this.openPreview(loan, latest); + } + + /** Opens the printable preview for any specific notice (used from history dialog too). */ + openPreview(loan: OverdueLoan, notice: NoticeEvent): void { + const data: NoticePreviewDialogData = { loan, notice }; + this.dialog.open(NoticePreviewDialogComponent, { + width: '880px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false, + panelClass: 'ntc-preview-dialog', + data + }); + } + + /** Downloads the most-recent notice as a PDF. */ + downloadLatestNotice(loan: OverdueLoan): void { + const latest = this.latestNotice(loan); + if (!latest) { + this.snack.open('Generate a notice first to download it.', 'OK', { duration: 2500 }); + return; + } + this.downloadNoticeFile(loan, latest); + } + + /** Generates and downloads the notice PDF using jsPDF. */ + downloadNoticeFile(loan: OverdueLoan, notice: NoticeEvent): void { + downloadNoticePdf(loan, notice); + this.snack.open(`Notice ${notice.id} downloaded as PDF.`, 'OK', { duration: 2200 }); + } + + /** + * Opens the Call Contact dialog with every reachable number on file: + * primary mobile · alternative · family member · nominee. The operator + * picks which one to dial / WhatsApp / copy. + */ + callCustomer(loan: OverdueLoan): void { + const data: CallContactDialogData = { loan }; + this.dialog.open(CallContactDialogComponent, { + width: '560px', + maxWidth: '94vw', + maxHeight: '88vh', + autoFocus: false, + panelClass: 'ccd-dialog', + data + }); + } + + /** Copies the loan account number to the OS clipboard. */ + copyAccountNo(loan: OverdueLoan): void { + const fallback = (): void => { + const ta = document.createElement('textarea'); + ta.value = loan.accountNo; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + } catch { + /* ignore */ + } + document.body.removeChild(ta); + }; + if (navigator?.clipboard?.writeText) { + navigator.clipboard.writeText(loan.accountNo).catch(fallback); + } else { + fallback(); + } + this.snack.open(`Copied ${loan.accountNo} to clipboard.`, 'OK', { duration: 2000 }); + } + + /** Stub — would deep-link to the loan view in a real deployment. */ + openLoanProfile(loan: OverdueLoan): void { + this.snack.open(`Opening loan profile for ${loan.accountNo}…`, 'OK', { duration: 2200 }); + } + + // ───────────────────── Display helpers ───────────────────── + accountTypeLabel(t: LoanAccountType): string { + return this.accountTypeOptions.find((o) => o.value === t)?.label || t; + } + + stageLabel(s: NoticeStage): string { + return s === 'FIRST' ? '1st' : s === 'SECOND' ? '2nd' : s === 'FINAL' ? 'Final' : 'Legal'; + } + + channelLabel(c: NoticeChannel): string { + switch (c) { + case 'EMAIL': + return 'Email'; + case 'POST': + return 'Speed Post'; + case 'COURIER': + return 'Courier'; + case 'SMS': + return 'SMS'; + case 'WHATSAPP': + return 'WhatsApp'; + case 'IN_PERSON': + return 'In-Person'; + } + } + + channelIcon(c: NoticeChannel): string { + switch (c) { + case 'EMAIL': + return 'envelope'; + case 'POST': + return 'stamp'; + case 'COURIER': + return 'paper-plane'; + case 'SMS': + return 'mobile-alt'; + case 'WHATSAPP': + return 'comment-alt'; + case 'IN_PERSON': + return 'user-tie'; + } + } + + dpdBucket(d: number): 'mild' | 'severe' | 'critical' { + if (d <= 30) return 'mild'; + if (d <= 90) return 'severe'; + return 'critical'; + } + + noticeStatus(loan: OverdueLoan): 'NONE' | 'GENERATED' | 'SENT' { + if (loan.notices.length === 0) return 'NONE'; + return loan.notices[0].sends.length > 0 ? 'SENT' : 'GENERATED'; + } + + latestNotice(loan: OverdueLoan): NoticeEvent | null { + return loan.notices[0] || null; + } + + totalSendCount(loan: OverdueLoan): number { + return loan.notices.reduce((s, n) => s + n.sends.length, 0); + } + + private nextStage(loan: OverdueLoan): NoticeStage { + const used = new Set(loan.notices.map((n) => n.stage)); + if (!used.has('FIRST')) return 'FIRST'; + if (!used.has('SECOND')) return 'SECOND'; + if (!used.has('FINAL')) return 'FINAL'; + return 'LEGAL'; + } + + private destinationFor(loan: OverdueLoan, channel: NoticeChannel): string { + switch (channel) { + case 'EMAIL': + return loan.email; + case 'SMS': + case 'WHATSAPP': + return '+91 ' + loan.mobile; + case 'POST': + case 'COURIER': + return loan.address; + case 'IN_PERSON': + return loan.branch + ' counter'; + } + } + + private nowStr(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + d.getFullYear() + + '-' + + pad(d.getMonth() + 1) + + '-' + + pad(d.getDate()) + + ' ' + + pad(d.getHours()) + + ':' + + pad(d.getMinutes()) + + ':' + + pad(d.getSeconds()) + ); + } +} + +// ───────────────────── PDF rendering ───────────────────── +interface StageCopy { + title: string; + subject: string; + salutation: string; + body: string[]; + cta: string; + consequences: string; + signOff: string; + accent: [number, number, number]; +} + +function stageCopy(loan: OverdueLoan, notice: NoticeEvent): StageCopy { + const overdue = `Rs. ${loan.overdueTotal.toLocaleString('en-IN')}`; + const lastPaid = loan.lastPaidDate; + + switch (notice.stage) { + case 'FIRST': + return { + title: '1st Reminder Notice', + subject: `Reminder — Overdue dues on Loan A/c ${loan.accountNo}`, + salutation: `Dear ${loan.customerName},`, + body: [ + 'We refer to your captioned loan account with our bank. Our records indicate that your account has fallen into arrears as detailed below.', + `This is a gentle reminder requesting you to regularise the account at the earliest. The overdue amount of ${overdue} (as on date) has remained unpaid since your last payment on ${lastPaid}.`, + 'Maintaining a healthy repayment record helps preserve your credit score and avoids additional penal interest. If you have already made the payment in the last 24 hours, kindly disregard this notice.' + ], + cta: `Please remit the total dues of ${overdue} within 7 days from the date of this notice through any of our accepted channels — branch deposit, NEFT/RTGS, IMPS, or UPI.`, + consequences: + 'Failure to clear the dues may attract additional penal interest charges and may lead to escalation to the next stage of recovery action.', + signOff: 'Yours sincerely,', + accent: [ + 29, + 78, + 216 + ] + }; + case 'SECOND': + return { + title: '2nd Reminder Notice', + subject: `Second Reminder — Overdue dues on Loan A/c ${loan.accountNo}`, + salutation: `Dear ${loan.customerName},`, + body: [ + 'Despite our earlier reminder, our records show that the dues in your captioned loan account remain unsettled.', + `The overdue amount of ${overdue} continues to remain outstanding. Your last payment was received on ${lastPaid}, after which no further repayment has been credited to the account.`, + 'We urge you to treat this matter with utmost urgency and clear the dues immediately to avoid further action on the account.' + ], + cta: `Kindly regularise the account by paying ${overdue} within 7 days from this notice. If you have already made the payment, please share the UTR / transaction reference with our recovery team so we can update the records.`, + consequences: + 'Continued non-payment may result in classification as a Special Mention Account (SMA) per RBI guidelines, reporting to credit bureaus (CIBIL/Experian/Equifax/CRIF), and escalation for further recovery action including issuance of a final demand notice.', + signOff: 'Regards,', + accent: [ + 217, + 119, + 6 + ] + }; + case 'FINAL': + return { + title: 'Final Demand Notice', + subject: `FINAL DEMAND NOTICE — Loan A/c ${loan.accountNo}`, + salutation: `Dear ${loan.customerName},`, + body: [ + 'This communication serves as the FINAL DEMAND NOTICE in respect of your captioned loan account, which continues to remain in default despite our prior reminders.', + `As on date, an amount of ${overdue} is overdue and payable. Your last payment was received on ${lastPaid}. No payments have been credited since then.`, + 'You are hereby called upon to clear the entire overdue amount along with applicable penal interest within 15 days from the date of this notice.' + ], + cta: `Payment of ${overdue} (plus applicable penal interest accrued up to the date of payment) must reach the bank within 15 days from receipt of this notice.`, + consequences: + 'Failure to comply within the stipulated period shall constrain the bank, without further reference to you, to classify your account as a Non-Performing Asset (NPA) per RBI norms, report the same to all credit information companies, and initiate appropriate legal and recovery proceedings, including action under the SARFAESI Act, 2002 / RDDB Act, 1993 where applicable.', + signOff: 'For and on behalf of the bank,', + accent: [ + 185, + 28, + 28 + ] + }; + case 'LEGAL': + default: + return { + title: 'Legal Notice (Pre-Litigation)', + subject: `LEGAL NOTICE — Loan A/c ${loan.accountNo} — Demand for Payment`, + salutation: `To ${loan.customerName},`, + body: [ + 'Take notice that the bank, through its undersigned authorised officer, hereby serves upon you this Legal Notice in respect of the captioned loan account.', + `That you availed credit facilities from the bank vide Loan Account No. ${loan.accountNo}. That as on the date of this notice, a sum of ${overdue} is outstanding and payable by you to the bank, having remained unpaid since ${lastPaid}, in spite of repeated reminders and demands made by the bank.`, + 'That your continued default constitutes a breach of the sanction terms and loan agreement entered into between you and the bank, and the entire outstanding has become recallable forthwith.' + ], + cta: `You are hereby called upon to pay to the bank a sum of ${overdue} together with further interest, penal charges, and costs, within 15 days from receipt of this notice, failing which the bank shall be constrained to initiate appropriate legal proceedings against you, including but not limited to proceedings under the SARFAESI Act, 2002, the Negotiable Instruments Act, 1881, and / or recovery proceedings before the Debts Recovery Tribunal (DRT), entirely at your risk, cost, and consequences.`, + consequences: + 'Please treat this notice with the seriousness it deserves. The bank reserves the right to take any and all such steps as may be advised in law, without any further reference to you, in the event of your continued default.', + signOff: 'Authorised Officer,', + accent: [ + 109, + 40, + 217 + ] + }; + } +} + +/** + * Build a printable PDF that visually mirrors the on-screen preview dialog: + * - serif (Times) body, sans-serif (Helvetica) for labels / meta + * - centered letterhead with accent-coloured bank name + * - three-column "Notice Reference / Date of Issue / Stage" meta strip + * - "To," addressee block + subject row with hairline dividers + * - justified serif body + * - 2x4 loan-summary grid with accent-coloured TOTAL DUES row + * - accent left-bar CTA, italic consequences, signature line + */ +export function buildNoticePdf(loan: OverdueLoan, notice: NoticeEvent): jsPDF { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + const pageW = doc.internal.pageSize.getWidth(); + const pageH = doc.internal.pageSize.getHeight(); + const margin = 20; + const contentW = pageW - margin * 2; + const copy = stageCopy(loan, notice); + + // Helper: lighter tint of an [r,g,b] colour (~ alpha .14 over white). + const tint = ([ + r, + g, + b + ]: [ + number, + number, + number + ]): [number, number, number] => [ + Math.round(255 - (255 - r) * 0.12), + Math.round(255 - (255 - g) * 0.12), + Math.round(255 - (255 - b) * 0.12) + ]; + const accent = copy.accent as [number, number, number]; + const accentTint = tint(accent); + + // Palette (matches the preview dialog SCSS) + const C_TEXT: [number, number, number] = [ + 15, + 23, + 42 + ]; // g900 + const C_MUTED: [number, number, number] = [ + 100, + 116, + 139 + ]; // g500 + const C_BORDER: [number, number, number] = [ + 226, + 232, + 240 + ]; // g200 + const C_LABEL_BG: [number, number, number] = [ + 248, + 250, + 252 + ]; // g50 + + const setText = (rgb: [number, number, number]): void => { + doc.setTextColor(rgb[0], rgb[1], rgb[2]); + }; + const setFill = (rgb: [number, number, number]): void => { + doc.setFillColor(rgb[0], rgb[1], rgb[2]); + }; + const setDraw = (rgb: [number, number, number]): void => { + doc.setDrawColor(rgb[0], rgb[1], rgb[2]); + }; + + // ── Accent top bar (matches `.npd-page` border-top: 6px) ── + setFill(accent); + doc.rect(0, 0, pageW, 4, 'F'); + + // ── Letterhead ── + let y = 22; + doc.setFont('times', 'bold'); + doc.setFontSize(20); + setText(accent); + doc.text('Nexora Co-operative Bank Ltd.', pageW / 2, y, { align: 'center' }); + y += 6; + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + setText(C_MUTED); + doc.text('Head Office, 4th Floor, Nexora Tower, Mumbai 400051', pageW / 2, y, { align: 'center' }); + y += 4; + doc.text('RBI Lic. No. NX-2018/MH/482 · recovery@nexorabank.in · 022 6789 0000', pageW / 2, y, { + align: 'center' + }); + y += 4; + setDraw(C_BORDER); + doc.setLineWidth(0.3); + doc.line(margin, y, pageW - margin, y); + y += 8; + + // ── Notice meta row (3 columns) ── + const colW = contentW / 3; + doc.setFont('helvetica', 'bold'); + doc.setFontSize(8); + setText(C_MUTED); + doc.text('NOTICE REFERENCE', margin, y); + doc.text('DATE OF ISSUE', margin + colW, y); + doc.text('STAGE', margin + colW * 2, y); + y += 5; + + // Values row + setText(C_TEXT); + doc.setFont('courier', 'bold'); + doc.setFontSize(11); + doc.text(notice.id, margin, y + 1); + + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + // Date in "8 April 2026" style + const issueDate = formatLongDate(notice.generatedAt.slice(0, 10)); + doc.text(issueDate, margin + colW, y + 1); + + // Stage pill + const stagePillX = margin + colW * 2; + doc.setFont('helvetica', 'bold'); + doc.setFontSize(9); + const pillW = doc.getTextWidth(copy.title.toUpperCase()) + 8; + const pillH = 6; + const pillY = y - 3.5; + setFill(accentTint); + doc.roundedRect(stagePillX, pillY, pillW, pillH, pillH / 2, pillH / 2, 'F'); + setText(accent); + doc.text(copy.title.toUpperCase(), stagePillX + pillW / 2, pillY + pillH / 2 + 1.4, { align: 'center' }); + + y += 6; + setDraw(C_BORDER); + doc.setLineDashPattern( + [ + 0.8, + 0.8 + ], + 0 + ); + doc.line(margin, y, pageW - margin, y); + doc.setLineDashPattern([], 0); + y += 8; + + // ── Addressee block ── + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + setText(C_TEXT); + doc.text('To,', margin, y); + + const addrX = margin + 14; + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + doc.text(loan.customerName, addrX, y); + y += 5; + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + setText(C_TEXT); + doc.text(`Customer ID: ${loan.customerId}`, addrX, y); + y += 4.5; + + const addrLines = doc.splitTextToSize(loan.address, contentW - 14); + doc.text(addrLines, addrX, y); + y += addrLines.length * 4.5; + + doc.text(`Mob: +91 ${loan.mobile} · ${loan.email}`, addrX, y); + y += 8; + + // ── Subject (divider above + below) ── + setDraw(C_BORDER); + doc.setLineWidth(0.3); + doc.line(margin, y, pageW - margin, y); + y += 6; + doc.setFont('helvetica', 'bold'); + doc.setFontSize(8); + setText(C_MUTED); + doc.text('SUBJECT:', margin, y); + + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + setText(C_TEXT); + const subjLines = doc.splitTextToSize(copy.subject, contentW - 26); + doc.text(subjLines, margin + 24, y); + y += subjLines.length * 5 + 1; + doc.line(margin, y, pageW - margin, y); + y += 8; + + // ── Salutation ── + doc.setFont('times', 'bold'); + doc.setFontSize(12); + setText(C_TEXT); + doc.text(copy.salutation, margin, y); + y += 7; + + // ── Body paragraphs (Times, justified) ── + doc.setFont('times', 'normal'); + doc.setFontSize(11); + setText(C_TEXT); + const writePara = (text: string): void => { + const lines = doc.splitTextToSize(text, contentW); + if (y + lines.length * 5 > pageH - margin - 55) { + doc.addPage(); + setFill(accent); + doc.rect(0, 0, pageW, 4, 'F'); + y = margin; + } + doc.text(lines, margin, y, { align: 'justify', maxWidth: contentW }); + y += lines.length * 5 + 3; + }; + for (const p of copy.body) { + writePara(p); + } + + // ── Loan summary table (2x4 grid + accent total row) ── + y += 2; + if (y > pageH - margin - 70) { + doc.addPage(); + setFill(accent); + doc.rect(0, 0, pageW, 4, 'F'); + y = margin; + } + const rowH = 8; + const colHalf = contentW / 2; + const labelW = 40; + const valueW = colHalf - labelW; + const drawCell = (x: number, w: number, text: string, isLabel: boolean): void => { + if (isLabel) { + setFill(C_LABEL_BG); + doc.rect(x, y, w, rowH, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(7.5); + setText(C_MUTED); + doc.text(text, x + 2.5, y + rowH / 2 + 1.3); + } else { + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + setText(C_TEXT); + doc.text(text, x + 2.5, y + rowH / 2 + 1.3); + } + setDraw(C_BORDER); + doc.setLineWidth(0.2); + doc.rect(x, y, w, rowH); + }; + + // Row 1 + drawCell(margin, labelW, 'LOAN ACCOUNT NO.', true); + doc.setFont('courier', 'bold'); + doc.setFontSize(10); + setText(C_TEXT); + doc.text(loan.accountNo, margin + labelW + 2.5, y + rowH / 2 + 1.3); + setDraw(C_BORDER); + doc.rect(margin + labelW, y, valueW, rowH); + drawCell(margin + colHalf, labelW, 'BRANCH', true); + drawCell(margin + colHalf + labelW, valueW, loan.branch, false); + y += rowH; + + // Row 2 + drawCell(margin, labelW, 'DAYS PAST DUE', true); + drawCell(margin + labelW, valueW, String(loan.daysOverdue), false); + drawCell(margin + colHalf, labelW, 'LAST PAID DATE', true); + drawCell(margin + colHalf + labelW, valueW, formatLongDate(loan.lastPaidDate), false); + y += rowH; + + // Row 3 + drawCell(margin, labelW, 'OVERDUE PRINCIPAL', true); + drawCell(margin + labelW, valueW, `Rs. ${loan.overduePrincipal.toLocaleString('en-IN')}`, false); + drawCell(margin + colHalf, labelW, 'OVERDUE INTEREST', true); + drawCell(margin + colHalf + labelW, valueW, `Rs. ${loan.overdueInterest.toLocaleString('en-IN')}`, false); + y += rowH; + + // Total row (accent background, white text) + setFill(accent); + doc.rect(margin, y, contentW, rowH + 1, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(10); + setText([ + 255, + 255, + 255 + ]); + doc.text('TOTAL DUES OUTSTANDING', margin + 3, y + rowH / 2 + 2); + doc.text(`Rs. ${loan.overdueTotal.toLocaleString('en-IN')}`, margin + contentW - 3, y + rowH / 2 + 2, { + align: 'right' + }); + y += rowH + 8; + + // ── CTA (accent left bar + tinted background) ── + if (y > pageH - margin - 55) { + doc.addPage(); + setFill(accent); + doc.rect(0, 0, pageW, 4, 'F'); + y = margin; + } + doc.setFont('times', 'bold'); + doc.setFontSize(11); + setText(C_TEXT); + const ctaLines = doc.splitTextToSize(copy.cta, contentW - 10); + const ctaH = ctaLines.length * 5 + 6; + setFill(accentTint); + doc.rect(margin, y, contentW, ctaH, 'F'); + setFill(accent); + doc.rect(margin, y, 1.8, ctaH, 'F'); + doc.text(ctaLines, margin + 6, y + 5, { align: 'justify', maxWidth: contentW - 10 }); + y += ctaH + 6; + + // ── Consequences (italic, muted) ── + if (y > pageH - margin - 40) { + doc.addPage(); + setFill(accent); + doc.rect(0, 0, pageW, 4, 'F'); + y = margin; + } + doc.setFont('times', 'italic'); + doc.setFontSize(9.5); + setText(C_MUTED); + const conLines = doc.splitTextToSize(copy.consequences, contentW); + doc.text(conLines, margin, y, { align: 'justify', maxWidth: contentW }); + y += conLines.length * 4.5 + 8; + + // ── Sign-off ── + if (y > pageH - margin - 35) { + doc.addPage(); + setFill(accent); + doc.rect(0, 0, pageW, 4, 'F'); + y = margin; + } + doc.setFont('times', 'normal'); + doc.setFontSize(11); + setText(C_TEXT); + doc.text(copy.signOff, margin, y); + y += 16; + setDraw(C_MUTED); + doc.setLineWidth(0.3); + doc.line(margin, y, margin + 70, y); + y += 5; + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.text('For ', margin, y); + doc.setFont('helvetica', 'bold'); + doc.text('Nexora Co-operative Bank Ltd.', margin + 7, y); + y += 4.5; + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + setText(C_MUTED); + doc.text(`Authorised Signatory — Recovery & Collections`, margin, y); + y += 4; + doc.setFont('courier', 'normal'); + doc.text(loan.branch, margin, y); + y += 8; + + // ── Footer ── + setDraw(C_BORDER); + doc.setLineDashPattern( + [ + 0.8, + 0.8 + ], + 0 + ); + doc.line(margin, y, pageW - margin, y); + doc.setLineDashPattern([], 0); + y += 4.5; + doc.setFont('helvetica', 'normal'); + doc.setFontSize(8); + setText(C_MUTED); + doc.text(`Notice generated by ${notice.generatedBy} on ${notice.generatedAt}.`, margin, y); + doc.setFont('courier', 'normal'); + doc.text(notice.id, pageW - margin, y, { align: 'right' }); + + return doc; +} + +/** "2026-04-08" → "8 April 2026" — matches the date format in the preview header. */ +function formatLongDate(iso: string): string { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' }); +} + +/** Convenience: build the PDF and trigger a download with a sensible filename. */ +export function downloadNoticePdf(loan: OverdueLoan, notice: NoticeEvent): void { + const doc = buildNoticePdf(loan, notice); + doc.save(`${notice.id}-${loan.accountNo}-Notice.pdf`); +} + +// ───────────────────── Mock seed (top-level export) ───────────────────── +/** + * Mock list of loan accounts with overdue dues. Replace with an API call + * (e.g., GET /loans?overdue=true&branch=...) once the back-end is wired. + */ +export function seedOverdueLoans(): OverdueLoan[] { + const daysAgo = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() - n); + const pad = (x: number) => String(x).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + }; + const ts = (offset: number, h = 10, m = 0): string => { + const d = new Date(); + d.setDate(d.getDate() - offset); + const pad = (x: number) => String(x).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(h)}:${pad(m)}:00`; + }; + + return [ + { + accountNo: 'LN10029384', + accountType: 'HOUSING', + customerId: 'CUST112233', + customerName: 'Rohan S. Mehta', + branch: 'Mumbai — Andheri (E)', + email: 'rohan.mehta@example.com', + mobile: '9821234567', + mobileAlt: '9870011234', + familyName: 'Priya Mehta', + familyRelation: 'Spouse', + familyMobile: '9821987654', + nomineeName: 'Aarav Mehta', + nomineeMobile: '9819900112', + address: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069', + emiAmount: 38500, + overduePrincipal: 65000, + overdueInterest: 12000, + overdueTotal: 77000, + daysOverdue: 22, + lastPaidDate: daysAgo(22), + notices: [] + }, + { + accountNo: 'LN20034112', + accountType: 'PERSONAL', + customerId: 'CUST334455', + customerName: 'Anita Verma', + branch: 'New Delhi — Connaught Place', + email: 'anita.verma@example.com', + mobile: '9845112233', + mobileAlt: '9810044556', + familyName: 'Suresh Verma', + familyRelation: 'Father', + familyMobile: '9845998877', + nomineeName: 'Kavita Verma', + nomineeMobile: '9899012233', + address: 'B-12, Connaught Pl, New Delhi 110001', + emiAmount: 14500, + overduePrincipal: 28000, + overdueInterest: 4200, + overdueTotal: 32200, + daysOverdue: 45, + lastPaidDate: daysAgo(45), + notices: [ + { + id: 'NTC700001234', + stage: 'FIRST', + generatedAt: ts(15, 10, 20), + generatedBy: 'Sushma Iyer', + sends: [ + { + channel: 'EMAIL', + sentAt: ts(15, 11, 5), + sentBy: 'Sushma Iyer', + status: 'DELIVERED', + destination: 'anita.verma@example.com' + }, + { + channel: 'POST', + sentAt: ts(14, 14, 30), + sentBy: 'Sushma Iyer', + status: 'DELIVERED', + destination: 'B-12, Connaught Pl, New Delhi 110001', + consignmentNo: 'EM123456789IN', + postOffice: 'New Delhi GPO', + bookedDate: ts(14, 14, 30).slice(0, 10), + postage: 45 + } + ] + } + ] + }, + { + accountNo: 'LN50200077', + accountType: 'AUTO', + customerId: 'CUST556677', + customerName: 'Karthik Rao', + branch: 'Bengaluru — MG Road', + email: 'karthik.rao@example.com', + mobile: '9900112233', + mobileAlt: '9845001122', + familyName: 'Lalitha Rao', + familyRelation: 'Mother', + familyMobile: '9900998877', + nomineeName: 'Shreya Rao', + nomineeMobile: '9844551133', + address: '24, MG Road, Bengaluru 560001', + emiAmount: 22000, + overduePrincipal: 18000, + overdueInterest: 2400, + overdueTotal: 20400, + daysOverdue: 12, + lastPaidDate: daysAgo(12), + notices: [] + }, + { + accountNo: 'LN50200099', + accountType: 'GOLD', + customerId: 'CUST667788', + customerName: 'Lakshmi Naidu', + branch: 'Hyderabad — Banjara Hills', + email: 'lakshmi.n@example.com', + mobile: '9849988776', + mobileAlt: '9849111223', + familyName: 'Sundar Naidu', + familyRelation: 'Husband', + familyMobile: '9849776655', + nomineeName: 'Meera Naidu', + nomineeMobile: '9848220011', + address: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034', + emiAmount: 8500, + overduePrincipal: 9200, + overdueInterest: 1100, + overdueTotal: 10300, + daysOverdue: 18, + lastPaidDate: daysAgo(18), + notices: [ + { + id: 'NTC700002345', + stage: 'FIRST', + generatedAt: ts(6, 9, 45), + generatedBy: 'Lakshmi Naidu', + sends: [ + { + channel: 'EMAIL', + sentAt: ts(6, 10, 0), + sentBy: 'Lakshmi Naidu', + status: 'DELIVERED', + destination: 'lakshmi.n@example.com' + } + ] + } + ] + }, + { + accountNo: 'LN70010023', + accountType: 'BUSINESS', + customerId: 'CUST889900', + customerName: 'Vikram Joshi', + branch: 'Pune — Koregaon Park', + email: 'vikram.j@example.com', + mobile: '9890065432', + mobileAlt: '9876543210', + familyName: 'Aruna Joshi', + familyRelation: 'Spouse', + familyMobile: '9890112233', + nomineeName: 'Aditya Joshi', + nomineeMobile: '9823344556', + address: 'Office 3B, Koregaon Park Road, Pune 411001', + emiAmount: 145000, + overduePrincipal: 320000, + overdueInterest: 48000, + overdueTotal: 368000, + daysOverdue: 95, + lastPaidDate: daysAgo(95), + notices: [ + { + id: 'NTC700003456', + stage: 'FIRST', + generatedAt: ts(60, 11, 0), + generatedBy: 'Sushma Iyer', + sends: [ + { + channel: 'EMAIL', + sentAt: ts(60, 11, 30), + sentBy: 'Sushma Iyer', + status: 'DELIVERED', + destination: 'vikram.j@example.com' + }, + { + channel: 'POST', + sentAt: ts(59, 15, 12), + sentBy: 'Sushma Iyer', + status: 'DELIVERED', + destination: 'Office 3B, Koregaon Park Road, Pune 411001', + consignmentNo: 'EM987654321IN', + postOffice: 'Pune City HPO', + bookedDate: ts(59, 15, 12).slice(0, 10), + postage: 55 + } + ] + }, + { + id: 'NTC700003457', + stage: 'SECOND', + generatedAt: ts(30, 10, 15), + generatedBy: 'Sushma Iyer', + sends: [ + { + channel: 'EMAIL', + sentAt: ts(30, 10, 45), + sentBy: 'Sushma Iyer', + status: 'DELIVERED', + destination: 'vikram.j@example.com' + }, + { + channel: 'COURIER', + sentAt: ts(29, 17, 22), + sentBy: 'Sushma Iyer', + status: 'DELIVERED', + destination: 'Office 3B, Koregaon Park Road, Pune 411001' + }, + { + channel: 'WHATSAPP', + sentAt: ts(28, 9, 5), + sentBy: 'Sushma Iyer', + status: 'DELIVERED', + destination: '+91 9890065432' + } + ] + } + ] + }, + { + accountNo: 'LN88990022', + accountType: 'EDUCATION', + customerId: 'CUST990011', + customerName: 'Arjun Banerjee', + branch: 'Kolkata — Park Street', + email: 'arjun.banerjee@example.com', + mobile: '9831100225', + mobileAlt: '9831566778', + familyName: 'Subir Banerjee', + familyRelation: 'Father', + familyMobile: '9830012345', + nomineeName: 'Madhumita Banerjee', + nomineeMobile: '9830998877', + address: '42, Park Street, Kolkata 700016', + emiAmount: 18500, + overduePrincipal: 22000, + overdueInterest: 3300, + overdueTotal: 25300, + daysOverdue: 8, + lastPaidDate: daysAgo(8), + notices: [] + }, + { + accountNo: 'LN50100445', + accountType: 'CC', + customerId: 'CUST110022', + customerName: 'Spectra Components Pvt Ltd', + branch: 'Mumbai — Andheri (E)', + email: 'finance@spectracomponents.in', + mobile: '9821100110', + mobileAlt: '9821100220', + familyName: 'Nitin Shah', + familyRelation: 'Managing Director', + familyMobile: '9820011988', + nomineeName: 'Priti Shah', + nomineeMobile: '9820011990', + address: 'Plot 9, MIDC, Andheri (E), Mumbai 400093', + emiAmount: 0, + overduePrincipal: 0, + overdueInterest: 142000, + overdueTotal: 142000, + daysOverdue: 60, + lastPaidDate: daysAgo(60), + notices: [ + { + id: 'NTC700004567', + stage: 'FIRST', + generatedAt: ts(40, 10, 0), + generatedBy: 'Anita Desai', + sends: [ + { + channel: 'EMAIL', + sentAt: ts(40, 10, 15), + sentBy: 'Anita Desai', + status: 'DELIVERED', + destination: 'finance@spectracomponents.in' + } + ] + } + ] + }, + { + accountNo: 'LN50100887', + accountType: 'OD', + customerId: 'CUST220033', + customerName: 'Bluestone Trading Co.', + branch: 'Bengaluru — MG Road', + email: 'ops@bluestonetrading.in', + mobile: '9845009900', + mobileAlt: '9845009911', + familyName: 'Harish Iyer', + familyRelation: 'Proprietor', + familyMobile: '9844456677', + nomineeName: 'Nandita Iyer', + nomineeMobile: '9844456680', + address: '12, Brigade Road, Bengaluru 560025', + emiAmount: 0, + overduePrincipal: 0, + overdueInterest: 88500, + overdueTotal: 88500, + daysOverdue: 35, + lastPaidDate: daysAgo(35), + notices: [] + }, + { + accountNo: 'LN50100332', + accountType: 'HOUSING', + customerId: 'CUST330044', + customerName: 'Madhavi Singh', + branch: 'New Delhi — Connaught Place', + email: 'madhavi.singh@example.com', + mobile: '9818877665', + mobileAlt: '9818800220', + familyName: 'Rajesh Singh', + familyRelation: 'Spouse', + familyMobile: '9810022334', + nomineeName: 'Pooja Singh', + nomineeMobile: '9818099887', + address: 'D-5, Greater Kailash, New Delhi 110048', + emiAmount: 42000, + overduePrincipal: 78000, + overdueInterest: 13500, + overdueTotal: 91500, + daysOverdue: 145, + lastPaidDate: daysAgo(145), + notices: [ + { + id: 'NTC700005678', + stage: 'FIRST', + generatedAt: ts(120, 9, 30), + generatedBy: 'Sushma Iyer', + sends: [ + { + channel: 'EMAIL', + sentAt: ts(120, 10, 0), + sentBy: 'Sushma Iyer', + status: 'DELIVERED', + destination: 'madhavi.singh@example.com' + }, + { + channel: 'POST', + sentAt: ts(119, 14, 5), + sentBy: 'Sushma Iyer', + status: 'DELIVERED', + destination: 'D-5, Greater Kailash, New Delhi 110048', + consignmentNo: 'EM200334455IN', + postOffice: 'Greater Kailash SO', + bookedDate: ts(119, 14, 5).slice(0, 10), + postage: 45 + } + ] + }, + { + id: 'NTC700005679', + stage: 'SECOND', + generatedAt: ts(75, 10, 30), + generatedBy: 'Sushma Iyer', + sends: [ + { + channel: 'POST', + sentAt: ts(75, 16, 22), + sentBy: 'Sushma Iyer', + status: 'DELIVERED', + destination: 'D-5, Greater Kailash, New Delhi 110048', + consignmentNo: 'EM200445566IN', + postOffice: 'Greater Kailash SO', + bookedDate: ts(75, 16, 22).slice(0, 10), + postage: 50 + } + ] + }, + { + id: 'NTC700005680', + stage: 'FINAL', + generatedAt: ts(20, 11, 0), + generatedBy: 'Sushma Iyer', + sends: [ + { + channel: 'COURIER', + sentAt: ts(20, 18, 12), + sentBy: 'Sushma Iyer', + status: 'DELIVERED', + destination: 'D-5, Greater Kailash, New Delhi 110048' + }, + { + channel: 'EMAIL', + sentAt: ts(20, 11, 5), + sentBy: 'Sushma Iyer', + status: 'BOUNCED', + destination: 'madhavi.singh@example.com' + } + ] + } + ] + }, + { + accountNo: 'LN70020045', + accountType: 'PERSONAL', + customerId: 'CUST440055', + customerName: 'Indrani Sen', + branch: 'Kolkata — Park Street', + email: 'indrani.sen@example.com', + mobile: '9831200115', + mobileAlt: '9831200220', + familyName: 'Pranab Sen', + familyRelation: 'Brother', + familyMobile: '9830445566', + nomineeName: 'Rohini Sen', + nomineeMobile: '9830445599', + address: '18, Camac Street, Kolkata 700017', + emiAmount: 9800, + overduePrincipal: 12500, + overdueInterest: 1850, + overdueTotal: 14350, + daysOverdue: 5, + lastPaidDate: daysAgo(5), + notices: [] + } + ]; +} diff --git a/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.html b/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.html new file mode 100644 index 000000000..283aee49a --- /dev/null +++ b/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.html @@ -0,0 +1,97 @@ + + +
+ +
+
+ +
+

Record Speed Post dispatch

+
+ {{ data.loan.customerName }} · {{ data.loan.accountNo }} · Notice + {{ data.notice.id }} +
+
+
+ +
+ + +
+ +
+
Dispatch address
+
{{ data.loan.address }}
+
+
+ + +
+
+ + + + India Post Speed Post 13-char article ID, or any private courier tracking number. + + @if (consignmentNo && !isConsignmentValid) { + + + Use 6–20 alphanumeric characters (e.g. EM123456789IN). + + } +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + + Speed Post tariff payable at booking. Leave blank if unknown. +
+
+
+
+ + +
+ + +
+
diff --git a/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.scss b/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.scss new file mode 100644 index 000000000..aa2c6da61 --- /dev/null +++ b/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.scss @@ -0,0 +1,282 @@ +/** + * 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/. + */ + +:host { + --spd-blue: #1d4ed8; + --spd-blue-d: #1e40af; + --spd-blue-l: #eff6ff; + --spd-blue-tint: #dbeafe; + --spd-amber: #d97706; + --spd-amber-l: #fef3c7; + --spd-red: #b91c1c; + --spd-red-l: #fee2e2; + --spd-red-tint: #fecaca; + --spd-green: #15803d; + --spd-green-l: #dcfce7; + --spd-orange: #c2410c; + --spd-orange-l: #ffedd5; + --spd-g50: #f8fafc; + --spd-g100: #f1f5f9; + --spd-g200: #e2e8f0; + --spd-g300: #cbd5e1; + --spd-g500: #64748b; + --spd-g700: #334155; + --spd-g900: #0f172a; + + display: block; +} + +.spd-root { + background: #fff; + padding: 16px 20px 18px; + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + gap: 14px; + overflow: hidden; +} + +/* ───────── Header ───────── */ +.spd-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding-bottom: 12px; + border-bottom: 1px solid var(--spd-g200); +} + +.spd-head-l { + display: flex; + align-items: center; + gap: 12px; +} + +.spd-head-ic { + width: 40px; + height: 40px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: var(--spd-orange); + color: #fff; + font-size: 16px; +} + +.spd-head-t { + margin: 0; + font-size: 16px; + font-weight: 800; + color: var(--spd-g900); + line-height: 1.2; +} + +.spd-head-s { + margin-top: 3px; + font-size: 11.5px; + color: var(--spd-g500); +} + +.spd-x { + width: 32px; + height: 32px; + border-radius: 999px; + border: 1px solid var(--spd-g200); + background: #fff; + color: var(--spd-g500); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 13px; + flex-shrink: 0; + transition: all 0.12s ease; + + &:hover { + background: var(--spd-red-l); + color: var(--spd-red); + border-color: var(--spd-red-tint); + } +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +/* ───────── Delivery address card ───────── */ +.spd-addr { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 14px; + background: var(--spd-orange-l); + border: 1px solid #fed7aa; + border-radius: 10px; +} + +.spd-addr-ic { + color: var(--spd-orange); + font-size: 14px; + margin-top: 2px; + flex-shrink: 0; +} + +.spd-addr-l { + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--spd-g500); +} + +.spd-addr-v { + font-size: 12.5px; + font-weight: 600; + color: var(--spd-g900); + line-height: 1.4; + margin-top: 2px; +} + +/* ───────── Form ───────── */ +.spd-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.spd-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.spd-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + + label { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--spd-g500); + } + + input, + textarea { + box-sizing: border-box; + width: 100%; + height: 38px; + border: 1px solid var(--spd-g200); + border-radius: 8px; + padding: 0 11px; + background: #fff; + font-size: 13px; + color: var(--spd-g900); + font-family: inherit; + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: var(--spd-blue); + } + + &::placeholder { + color: var(--spd-g300); + } + } + + &--empty { + visibility: hidden; + } +} + +.spd-req { + color: var(--spd-red); + font-weight: 800; +} + +.spd-hint { + font-size: 11px; + font-weight: 500; + color: var(--spd-g500); + line-height: 1.4; +} + +.spd-err { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 700; + color: var(--spd-red); + + fa-icon { + font-size: 10.5px; + } +} + +/* ───────── Footer actions ───────── */ +.spd-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--spd-g200); +} + +.spd-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 38px; + padding: 0 16px; + border-radius: 9px; + border: 1px solid var(--spd-g200); + background: #fff; + color: var(--spd-g700); + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: all 0.12s ease; + + fa-icon { + font-size: 12px; + } + + &:hover { + background: var(--spd-g50); + border-color: var(--spd-g300); + } + + &--primary { + background: var(--spd-orange); + border-color: var(--spd-orange); + color: #fff; + + fa-icon { + color: #fff; + } + + &:hover:not(:disabled) { + background: #9a3412; + border-color: #9a3412; + color: #fff; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} diff --git a/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.ts b/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.ts new file mode 100644 index 000000000..877b66511 --- /dev/null +++ b/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.ts @@ -0,0 +1,91 @@ +/** + * 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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { NoticeEvent, OverdueLoan } from '../notice.component'; + +export interface SpeedPostDialogData { + loan: OverdueLoan; + notice: NoticeEvent; +} + +export interface SpeedPostDialogResult { + confirmed: boolean; + consignmentNo?: string; + postOffice?: string; + bookedDate?: string; + postage?: number; +} + +@Component({ + selector: 'mifosx-speed-post-dialog', + templateUrl: './speed-post-dialog.component.html', + styleUrls: ['./speed-post-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class SpeedPostDialogComponent { + private dialogRef = inject(MatDialogRef); + data: SpeedPostDialogData = inject(MAT_DIALOG_DATA); + + consignmentNo = ''; + postOffice = ''; + bookedDate = todayIso(); + postage: number | null = 45; + + /** Suggested India-post Speed Post 13-char article ID format: 2 letters + 9 digits + 2 letters + (e.g. "EM123456789IN"). Loose pattern — operator can paste any tracking ID. */ + readonly consignmentPattern = /^[A-Z]{2}\d{9}[A-Z]{2}$|^[A-Za-z0-9]{6,20}$/; + + get isConsignmentValid(): boolean { + const c = (this.consignmentNo || '').trim(); + return c.length >= 6 && this.consignmentPattern.test(c); + } + + get isFormValid(): boolean { + return this.isConsignmentValid && (this.postOffice || '').trim().length >= 2 && !!this.bookedDate; + } + + /** Force the consignment number to uppercase for the India-Post-style ID. */ + upperCaseConsignment(value: string): void { + this.consignmentNo = (value || '').toUpperCase(); + } + + confirm(): void { + if (!this.isFormValid) return; + this.dialogRef.close({ + confirmed: true, + consignmentNo: this.consignmentNo.trim(), + postOffice: this.postOffice.trim(), + bookedDate: this.bookedDate, + postage: this.postage != null ? Number(this.postage) : undefined + }); + } + + cancel(): void { + this.dialogRef.close({ confirmed: false }); + } +} + +function todayIso(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} diff --git a/src/app/products/current-products/current-products.component.html b/src/app/products/current-products/current-products.component.html index 4ff29d6b3..f9f46dcb6 100644 --- a/src/app/products/current-products/current-products.component.html +++ b/src/app/products/current-products/current-products.component.html @@ -1,37 +1,163 @@ -
-
- + + +
+
+
+
+
+

{{ 'labels.text.Current Products' | translate }}

+

Manage current-account products — overdraft limits, transaction rules and accounting.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'product' : 'products' }} +
+ +
-
- -
-
- - {{ 'labels.inputs.Filter' | translate }} - - - + +
+
+
+
+
{{ totalCount | number }}
+
Total Current Products
+
+
+
+
+
+
{{ currencyCount | number }}
+
{{ currencyCount === 1 ? 'Currency' : 'Currencies' }} in use
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
-
- - - - - +
+
+ +
+ + @if (totalCount === 0) { +
+
+

No current-account products yet

+

Create your first current-account product to enable transactional account onboarding.

+ +
+ } @else { +
+
{{ 'labels.inputs.Name' | translate }}{{ product.name }}
+ + + + + + + + + + + + + + + + + + + - - - - + + +
{{ 'labels.inputs.Name' | translate }} +
+
+
+
{{ p.name }}
+ @if (p.currency?.code) { +
{{ p.currency.code }}
+ } +
+
+
{{ 'labels.inputs.Short Name' | translate }} + {{ p.shortName }} + Currency + @if (p.currency?.code) { + {{ p.currency.code }} + } @else { + — + } + Actions + + + {{ 'labels.inputs.Short Name' | translate }}{{ product.shortName }}
- - - + @if (filteredCount === 0 && totalCount > 0) { +
+ + No products match your filter. +
+ } - + +
+ }
diff --git a/src/app/products/current-products/current-products.component.scss b/src/app/products/current-products/current-products.component.scss index 24edb63bb..b16b7bdf3 100644 --- a/src/app/products/current-products/current-products.component.scss +++ b/src/app/products/current-products/current-products.component.scss @@ -1,7 +1,17 @@ -table { - width: 100%; +/** + * 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/. + */ - .select-row:hover { - cursor: pointer; - } +@use 'assets/styles/product-list' as pl; + +@include pl.product-list-styles; + +/* Current-account-specific green accent */ +:host { + --pl-accent: #15803d; + --pl-accent-l: #dcfce7; } diff --git a/src/app/products/current-products/current-products.component.ts b/src/app/products/current-products/current-products.component.ts index b2eb80a43..52c0e5f05 100644 --- a/src/app/products/current-products/current-products.component.ts +++ b/src/app/products/current-products/current-products.component.ts @@ -1,3 +1,11 @@ +/** + * 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 { MatPaginator } from '@angular/material/paginator'; import { MatSort, MatSortHeader } from '@angular/material/sort'; @@ -16,6 +24,7 @@ import { } from '@angular/material/table'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatTooltip } from '@angular/material/tooltip'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @Component({ @@ -37,35 +46,54 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatHeaderRow, MatRowDef, MatRow, - MatPaginator + MatPaginator, + MatTooltip ] }) export class CurrentProductsComponent implements OnInit { private route = inject(ActivatedRoute); - currentProductsData: any; + currentProductsData: any[] = []; displayedColumns: string[] = [ 'name', - 'shortName' + 'shortName', + 'currency', + 'actions' ]; dataSource: MatTableDataSource; + filteredCount = 0; + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; @ViewChild(MatSort, { static: true }) sort: MatSort; constructor() { this.route.data.subscribe((data: { currentProducts: any }) => { - this.currentProductsData = data.currentProducts; + this.currentProductsData = data.currentProducts || []; }); } + get totalCount(): number { + return this.currentProductsData?.length || 0; + } + + get currencyCount(): number { + const set = new Set(); + for (const p of this.currentProductsData || []) { + if (p?.currency?.code) set.add(p.currency.code); + } + return set.size; + } + ngOnInit() { this.dataSource = new MatTableDataSource(this.currentProductsData); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; + this.filteredCount = this.totalCount; + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); } applyFilter(filterValue: string) { - this.dataSource.filter = filterValue.trim().toLowerCase(); + this.dataSource.filter = (filterValue || '').trim().toLowerCase(); } } diff --git a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html index 0a68dd9d7..b1159bcaf 100644 --- a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html +++ b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html @@ -6,623 +6,637 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- -
- -
-

{{ currentProduct.name }}

- - -

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

- - -
- {{ 'labels.inputs.Short Name' | translate }}: - {{ currentProduct.shortName }} +
+ +
+
+
+
+
Current-Account Product
+

{{ currentProduct.name }}

+
+ {{ currentProduct.shortName }} + @if (currentProduct.currency?.code) { + {{ currentProduct.currency.code }} + } +
+ @if (currentProduct.description) { +

{{ currentProduct.description }}

+ } +
+
+
- @if (currentProduct.description) { -
- {{ 'labels.inputs.Description' | translate }}: - {{ currentProduct.description }} -
- } +
+

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

- @if ( - currentProduct.accountType || - currentProduct.customerSegment || - currentProduct.glAccountId || - currentProduct.effectiveDate || - currentProduct.effectiveTo || - currentProduct.endDate || - currentProduct.introducerRequired !== undefined || - currentProduct.shareLinkingEnabled !== undefined - ) { -

Product Classification

+ +
+ {{ 'labels.inputs.Short Name' | translate }}: + {{ currentProduct.shortName }} +
+ + @if (currentProduct.description) { +
+ {{ 'labels.inputs.Description' | translate }}: + {{ currentProduct.description }} +
+ } + + @if ( + currentProduct.accountType || + currentProduct.customerSegment || + currentProduct.glAccountId || + currentProduct.effectiveDate || + currentProduct.effectiveTo || + currentProduct.endDate || + currentProduct.introducerRequired !== undefined || + currentProduct.shareLinkingEnabled !== undefined + ) { +

Product Classification

+ + + + @if (currentProduct.accountType) { +
+ Account Type: + {{ currentProduct.accountType }} +
+ } + + @if (currentProduct.customerSegment) { +
+ Customer Segment: + {{ currentProduct.customerSegment }} +
+ } + + @if (currentProduct.glAccountId) { +
+ GL Account (Liability): + {{ currentProduct.glAccountName || currentProduct.glAccountId }} +
+ } + + @if (currentProduct.effectiveDate) { +
+ Effective Date: + {{ currentProduct.effectiveDate | dateFormat }} +
+ } + + @if (currentProduct.effectiveTo) { +
+ Effective To: + {{ currentProduct.effectiveTo | dateFormat }} +
+ } + + @if (currentProduct.endDate) { +
+ End Date: + {{ currentProduct.endDate | dateFormat }} +
+ } + + @if (currentProduct.introducerRequired !== undefined) { +
+ Introducer Required: + {{ + currentProduct.introducerRequired === true || currentProduct.introducerRequired === 'Yes' ? 'Yes' : 'No' + }} +
+ } + + @if (currentProduct.shareLinkingEnabled !== undefined) { +
+ Share Linking Enabled: + {{ currentProduct.shareLinkingEnabled | yesNo }} +
+ } + } + +

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

- @if (currentProduct.accountType) { +
+ {{ 'labels.inputs.Currency' | translate }}: + {{ currentProduct.currency.name }} +
+ +
+ {{ 'labels.inputs.Decimal Places' | translate }}: + {{ currentProduct.currency.decimalPlaces }} +
+ + @if (currentProduct.currency.inMultiplesOf !== undefined && currentProduct.currency.inMultiplesOf !== null) {
- Account Type: - {{ currentProduct.accountType }} + {{ 'labels.inputs.Currency in multiples of' | translate }}: + {{ currentProduct.currency.inMultiplesOf }}
} - @if (currentProduct.customerSegment) { + @if (currentProduct.dormancyAlert !== undefined) {
- Customer Segment: - {{ currentProduct.customerSegment }} + Dormancy Alert: + {{ currentProduct.dormancyAlert | yesNo }}
} - @if (currentProduct.glAccountId) { + @if (currentProduct.nominationFacility !== undefined) {
- GL Account (Liability): - {{ currentProduct.glAccountName || currentProduct.glAccountId }} + Nomination Facility: + {{ currentProduct.nominationFacility | yesNo }}
} - @if (currentProduct.effectiveDate) { + @if (currentProduct.balanceSwapOption !== undefined) {
- Effective Date: - {{ currentProduct.effectiveDate | dateFormat }} + Balance Swap Option: + {{ currentProduct.balanceSwapOption | yesNo }}
} - @if (currentProduct.effectiveTo) { + @if (currentProduct.msme !== undefined) {
- Effective To: - {{ currentProduct.effectiveTo | dateFormat }} + MSME: + {{ currentProduct.msme | yesNo }}
} - @if (currentProduct.endDate) { + @if (currentProduct.autoDebitFacility !== undefined) {
- End Date: - {{ currentProduct.endDate | dateFormat }} + Auto Debit Facility: + {{ currentProduct.autoDebitFacility | yesNo }}
} - @if (currentProduct.introducerRequired !== undefined) { +

Interest On OD

+ + + + @if (currentProduct.interestPostingPeriodType?.value) {
- Introducer Required: + {{ 'labels.inputs.Interest Posting Period' | translate }}: + {{ currentProduct.interestPostingPeriodType.value | translateKey: 'catalogs' }} +
+ } + + @if (currentProduct.interestCalculateSelector) { +
+ Interest Calculate: + {{ currentProduct.interestCalculateSelector }} +
+ } + + @if (currentProduct.averagePeriod) { +
+ Average Period: + {{ currentProduct.averagePeriod }} +
+ } + + @if (currentProduct.autoPost !== undefined) { +
+ Auto Post: + {{ currentProduct.autoPost | yesNo }} +
+ } + + @if (currentProduct.frequencyFixDay) { +
+ Frequency Fix Day: + {{ currentProduct.frequencyFixDay }} +
+ } + + @if (currentProduct.lastInterestDate) { +
+ Last Interest Date: + {{ currentProduct.lastInterestDate | dateFormat }} +
+ } + +

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

+ + + + @if (currentProduct.minRequiredOpeningBalance) { +
+ {{ 'labels.inputs.Minimum Opening Balance' | translate }}: + {{ currentProduct.minRequiredOpeningBalance | formatNumber }} +
+ } + + @if (currentProduct.lockinPeriodFrequency) { +
+ {{ 'labels.inputs.Lock-in Period' | translate }}: {{ - currentProduct.introducerRequired === true || currentProduct.introducerRequired === 'Yes' ? 'Yes' : 'No' + currentProduct.lockinPeriodFrequency + + ' ' + + (currentProduct.lockinPeriodFrequencyType?.value || currentProduct.lockinPeriodFrequencyType?.code || '') }}
} - @if (currentProduct.shareLinkingEnabled !== undefined) { +
+ {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}: + {{ currentProduct.withdrawalFeeForTransfers | yesNo }} +
+ + @if (currentProduct.balanceFrequency) {
- Share Linking Enabled: - {{ currentProduct.shareLinkingEnabled | yesNo }} -
- } - } - -

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

- - - -
- {{ 'labels.inputs.Currency' | translate }}: - {{ currentProduct.currency.name }} -
- -
- {{ 'labels.inputs.Decimal Places' | translate }}: - {{ currentProduct.currency.decimalPlaces }} -
- - @if (currentProduct.currency.inMultiplesOf !== undefined && currentProduct.currency.inMultiplesOf !== null) { -
- {{ 'labels.inputs.Currency in multiples of' | translate }}: - {{ currentProduct.currency.inMultiplesOf }} -
- } - - @if (currentProduct.dormancyAlert !== undefined) { -
- Dormancy Alert: - {{ currentProduct.dormancyAlert | yesNo }} -
- } - - @if (currentProduct.nominationFacility !== undefined) { -
- Nomination Facility: - {{ currentProduct.nominationFacility | yesNo }} -
- } - - @if (currentProduct.balanceSwapOption !== undefined) { -
- Balance Swap Option: - {{ currentProduct.balanceSwapOption | yesNo }} -
- } - - @if (currentProduct.msme !== undefined) { -
- MSME: - {{ currentProduct.msme | yesNo }} -
- } - - @if (currentProduct.autoDebitFacility !== undefined) { -
- Auto Debit Facility: - {{ currentProduct.autoDebitFacility | yesNo }} -
- } - -

Interest On OD

- - - - @if (currentProduct.interestPostingPeriodType?.value) { -
- {{ 'labels.inputs.Interest Posting Period' | translate }}: - {{ currentProduct.interestPostingPeriodType.value | translateKey: 'catalogs' }} -
- } - - @if (currentProduct.interestCalculateSelector) { -
- Interest Calculate: - {{ currentProduct.interestCalculateSelector }} -
- } - - @if (currentProduct.averagePeriod) { -
- Average Period: - {{ currentProduct.averagePeriod }} -
- } - - @if (currentProduct.autoPost !== undefined) { -
- Auto Post: - {{ currentProduct.autoPost | yesNo }} -
- } - - @if (currentProduct.frequencyFixDay) { -
- Frequency Fix Day: - {{ currentProduct.frequencyFixDay }} -
- } - - @if (currentProduct.lastInterestDate) { -
- Last Interest Date: - {{ currentProduct.lastInterestDate | dateFormat }} -
- } - -

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

- - - - @if (currentProduct.minRequiredOpeningBalance) { -
- {{ 'labels.inputs.Minimum Opening Balance' | translate }}: - {{ currentProduct.minRequiredOpeningBalance | formatNumber }} -
- } - - @if (currentProduct.lockinPeriodFrequency) { -
- {{ 'labels.inputs.Lock-in Period' | translate }}: - {{ - currentProduct.lockinPeriodFrequency + - ' ' + - (currentProduct.lockinPeriodFrequencyType?.value || currentProduct.lockinPeriodFrequencyType?.code || '') - }} -
- } - -
- {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}: - {{ currentProduct.withdrawalFeeForTransfers | yesNo }} -
- - @if (currentProduct.balanceFrequency) { -
- Balance Frequency: - {{ currentProduct.balanceFrequency }} -
- } - -
- Enable Lock-in Period: - {{ currentProduct.enableLockinPeriod | yesNo }} -
- - @if (currentProduct.minBalanceForInterestCalculation) { -
- {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}: - {{ currentProduct.minBalanceForInterestCalculation | formatNumber }} -
- } - -
- {{ 'labels.inputs.Enforce Minimum Balance' | translate }}: - {{ currentProduct.enforceMinRequiredBalance | yesNo }} -
- - @if (currentProduct.minRequiredBalance) { -
- {{ 'labels.inputs.Minimum Balance' | translate }}: - {{ currentProduct.minRequiredBalance | formatNumber }} -
- } - -
- {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}: - {{ currentProduct.withHoldTax | yesNo }} -
- - @if (currentProduct.withHoldTax) { -
- {{ 'labels.inputs.Tax Group' | translate }}: - {{ currentProduct.taxGroup.name }} -
- } - -
- {{ 'labels.inputs.Is Overdraft Allowed' | translate }}: - {{ currentProduct.allowOverdraft | yesNo }} -
- -
- Account Closed Zero Balance in Day Closing: - {{ currentProduct.accountClosedZeroBalanceInDayClosing | yesNo }} -
- - @if (currentProduct.minOverdraftForInterestCalculation) { -
- {{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}: - {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }} -
- } - - @if (currentProduct.nominalAnnualInterestRateOverdraft) { -
- {{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}: - {{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % -
- } - - @if (currentProduct.overdraftLimit) { -
- {{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}: - {{ currentProduct.overdraftLimit | formatNumber }} -
- } - - @if (currentProduct.allowOverdraft) { - @if (currentProduct.avgBalanceCalcPeriod) { -
- Average Balance Calculation Period: - {{ currentProduct.avgBalanceCalcPeriod }} + Balance Frequency: + {{ currentProduct.balanceFrequency }}
} - @if (currentProduct.validForPeriod) { -
- Valid for Period: - {{ currentProduct.validForPeriod }} -
- } - - @if (currentProduct.odAccountClosedZeroBalanceInDayClosing !== undefined) { -
- OD Account Closed with Zero Balance in Day Closing: - {{ currentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }} -
- } - } - -
- {{ 'labels.inputs.Enable Dormancy Tracking' | translate }}: - {{ currentProduct.isDormancyTrackingActive | yesNo }} -
- - @if (currentProduct.isDormancyTrackingActive) { -
- {{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}: - {{ currentProduct.daysToInactive }} - {{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}: - {{ currentProduct.daysToDormancy }} - {{ 'labels.inputs.Number of Days to Escheat' | translate }}: - {{ currentProduct.daysToEscheat }} +
+ Enable Lock-in Period: + {{ currentProduct.enableLockinPeriod | yesNo }}
- } -

Account Facilities

+ @if (currentProduct.minBalanceForInterestCalculation) { +
+ {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}: + {{ currentProduct.minBalanceForInterestCalculation | formatNumber }} +
+ } - - @if (currentProduct.accountFacilityConfig) { -
- @for (feature of accountFacilityFeatureItems; track feature.key) { - @if (currentProduct.accountFacilityConfig[feature.key] !== undefined) { -
- {{ feature.label }}: - {{ currentProduct.accountFacilityConfig[feature.key] | yesNo }} -
+
+ {{ 'labels.inputs.Enforce Minimum Balance' | translate }}: + {{ currentProduct.enforceMinRequiredBalance | yesNo }} +
+ + @if (currentProduct.minRequiredBalance) { +
+ {{ 'labels.inputs.Minimum Balance' | translate }}: + {{ currentProduct.minRequiredBalance | formatNumber }} +
+ } + +
+ {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}: + {{ currentProduct.withHoldTax | yesNo }} +
+ + @if (currentProduct.withHoldTax) { +
+ {{ 'labels.inputs.Tax Group' | translate }}: + {{ currentProduct.taxGroup.name }} +
+ } + +
+ {{ 'labels.inputs.Is Overdraft Allowed' | translate }}: + {{ currentProduct.allowOverdraft | yesNo }} +
+ +
+ Account Closed Zero Balance in Day Closing: + {{ currentProduct.accountClosedZeroBalanceInDayClosing | yesNo }} +
+ + @if (currentProduct.minOverdraftForInterestCalculation) { +
+ {{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}: + {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }} +
+ } + + @if (currentProduct.nominalAnnualInterestRateOverdraft) { +
+ {{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}: + {{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % +
+ } + + @if (currentProduct.overdraftLimit) { +
+ {{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}: + {{ currentProduct.overdraftLimit | formatNumber }} +
+ } + + @if (currentProduct.allowOverdraft) { + @if (currentProduct.avgBalanceCalcPeriod) { +
+ Average Balance Calculation Period: + {{ currentProduct.avgBalanceCalcPeriod }} +
+ } + + @if (currentProduct.validForPeriod) { +
+ Valid for Period: + {{ currentProduct.validForPeriod }} +
+ } + + @if (currentProduct.odAccountClosedZeroBalanceInDayClosing !== undefined) { +
+ OD Account Closed with Zero Balance in Day Closing: + {{ currentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }} +
+ } + } + +
+ {{ 'labels.inputs.Enable Dormancy Tracking' | translate }}: + {{ currentProduct.isDormancyTrackingActive | yesNo }} +
+ + @if (currentProduct.isDormancyTrackingActive) { +
+ {{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}: + {{ currentProduct.daysToInactive }} + {{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}: + {{ currentProduct.daysToDormancy }} + {{ 'labels.inputs.Number of Days to Escheat' | translate }}: + {{ currentProduct.daysToEscheat }} +
+ } + +

Account Facilities

+ + + @if (currentProduct.accountFacilityConfig) { +
+ @for (feature of accountFacilityFeatureItems; track feature.key) { + @if (currentProduct.accountFacilityConfig[feature.key] !== undefined) { +
+ {{ feature.label }}: + {{ currentProduct.accountFacilityConfig[feature.key] | yesNo }} +
+ } } - } -
- } - - @if (currentProduct.accountFacilityLimits?.length) { -
-

Account Facility Limits

- - - - - - - - - - - - - - - - - - - - -
Limit Type{{ row.limitType }}General Limit{{ row.generalLimit }}Minor Account Limit{{ row.minorAccountLimit }}Small Saving Limit{{ row.smallSavingLimit }}
-
- } - - @if (currentProduct.depositLimitKyc) { -
-

Deposit Limit (KYC)

-
- General: - {{ currentProduct.depositLimitKyc.general.kycType }} - - {{ currentProduct.depositLimitKyc.general.limit }}
-
- Minor: - {{ currentProduct.depositLimitKyc.minor.kycType }} - {{ currentProduct.depositLimitKyc.minor.limit }} + } + + @if (currentProduct.accountFacilityLimits?.length) { +
+

Account Facility Limits

+ + + + + + + + + + + + + + + + + + + + +
Limit Type{{ row.limitType }}General Limit{{ row.generalLimit }}Minor Account Limit{{ row.minorAccountLimit }}Small Saving Limit{{ row.smallSavingLimit }}
-
- Small Saving: - {{ currentProduct.depositLimitKyc.smallSaving.kycType }} - - {{ currentProduct.depositLimitKyc.smallSaving.limit }} -
-
- } + } - @if (currentProduct.charges.length) { -
-

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

- - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Name' | translate }} - {{ charge.name + ', ' + charge.currency.displaySymbol }} - {{ 'labels.inputs.Type' | translate }} - {{ charge.chargeCalculationType.value | translateKey: 'catalogs' }} - {{ 'labels.inputs.Amount' | translate }} - {{ charge.amount | formatNumber }} - {{ 'labels.inputs.Collected On' | translate }} - {{ charge.chargeTimeType.value | translateKey: 'catalogs' }} -
-
- } - -

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

- - - - - - - @if (currentProduct.otherGlMappingsMap) { -
-

Other GL Mappings

- - - @if (currentProduct.otherGlMappingsMap.assets?.length) { -

Assets

- - - - - - - - - - - -
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
- } - - @if (currentProduct.otherGlMappingsMap.liabilities?.length) { -

Liabilities

- - - - - - - - - - - -
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
- } - - @if (currentProduct.otherGlMappingsMap.expenses?.length) { -

Expenses

- - - - - - - - - - - -
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
- } - - @if (currentProduct.otherGlMappingsMap.income?.length) { -

Income

- - - - - - - - - - - -
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
- } -
- } - - @if ( - currentProduct.paymentChannelToFundSourceMappings?.length || - currentProduct.feeToIncomeAccountMappings?.length || - currentProduct.penaltyToIncomeAccountMappings?.length - ) { -
-

{{ 'labels.inputs.Advanced Accounting Rules' | translate }}

- - @if (currentProduct.paymentChannelToFundSourceMappings?.length) { -
-

{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}

- +

Deposit Limit (KYC)

+
+ General: + {{ currentProduct.depositLimitKyc.general.kycType }} - + {{ currentProduct.depositLimitKyc.general.limit }} - -
- - - - - - - - -
{{ 'labels.inputs.Payment Type' | translate }} - {{ paymentFundSource.paymentType.name }} - {{ 'labels.inputs.Fund Source' | translate }} - ({{ paymentFundSource.fundSourceAccount.glCode }}) {{ paymentFundSource.fundSourceAccount.name }} -
- } - @if (currentProduct.feeToIncomeAccountMappings?.length) { -
-

{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}

- - - - - - - - - - - -
{{ 'labels.inputs.Fee/Charge' | translate }} - {{ fee.charge.name }} - {{ 'labels.inputs.Income Account' | translate }}({{ fee.incomeAccount.glCode }}) {{ fee.incomeAccount.name }}
+
+ Minor: + {{ currentProduct.depositLimitKyc.minor.kycType }} - {{ currentProduct.depositLimitKyc.minor.limit }}
- } - @if (currentProduct.penaltyToIncomeAccountMappings?.length) { -
-

{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}

+
+ Small Saving: + {{ currentProduct.depositLimitKyc.smallSaving.kycType }} - + {{ currentProduct.depositLimitKyc.smallSaving.limit }} +
+
+ } + + @if (currentProduct.charges.length) { +
+

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

+ + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Name' | translate }} + {{ charge.name + ', ' + charge.currency.displaySymbol }} + {{ 'labels.inputs.Type' | translate }} + {{ charge.chargeCalculationType.value | translateKey: 'catalogs' }} + {{ 'labels.inputs.Amount' | translate }} + {{ charge.amount | formatNumber }} + {{ 'labels.inputs.Collected On' | translate }} + {{ charge.chargeTimeType.value | translateKey: 'catalogs' }} +
+
+ } + +

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

+ + + + + + + @if (currentProduct.otherGlMappingsMap) { +
+

Other GL Mappings

+ + + @if (currentProduct.otherGlMappingsMap.assets?.length) { +

Assets

+ + + + + + + + + + + +
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
+ } + + @if (currentProduct.otherGlMappingsMap.liabilities?.length) { +

Liabilities

- - - + + + - - - + + + - - + +
{{ 'labels.inputs.Penalty' | translate }} - {{ penalty.charge.name }} - Account Name{{ row.accountName }} {{ 'labels.inputs.Income Account' | translate }} - ({{ penalty.incomeAccount.glCode }}) {{ penalty.incomeAccount.name }} - GL Account{{ row.glCode || row.glAccountId }}
-
- } -
- } + } + + @if (currentProduct.otherGlMappingsMap.expenses?.length) { +

Expenses

+ + + + + + + + + + + +
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
+ } + + @if (currentProduct.otherGlMappingsMap.income?.length) { +

Income

+ + + + + + + + + + + +
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
+ } +
+ } + + @if ( + currentProduct.paymentChannelToFundSourceMappings?.length || + currentProduct.feeToIncomeAccountMappings?.length || + currentProduct.penaltyToIncomeAccountMappings?.length + ) { +
+

{{ 'labels.inputs.Advanced Accounting Rules' | translate }}

+ + @if (currentProduct.paymentChannelToFundSourceMappings?.length) { +
+

{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}

+ + + + + + + + + + + +
{{ 'labels.inputs.Payment Type' | translate }} + {{ paymentFundSource.paymentType.name }} + {{ 'labels.inputs.Fund Source' | translate }} + ({{ paymentFundSource.fundSourceAccount.glCode }}) {{ paymentFundSource.fundSourceAccount.name }} +
+
+ } + @if (currentProduct.feeToIncomeAccountMappings?.length) { +
+

{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}

+ + + + + + + + + + + +
{{ 'labels.inputs.Fee/Charge' | translate }} + {{ fee.charge.name }} + {{ 'labels.inputs.Income Account' | translate }}({{ fee.incomeAccount.glCode }}) {{ fee.incomeAccount.name }}
+
+ } + @if (currentProduct.penaltyToIncomeAccountMappings?.length) { +
+

{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}

+ + + + + + + + + + + +
{{ 'labels.inputs.Penalty' | translate }} + {{ penalty.charge.name }} + {{ 'labels.inputs.Income Account' | translate }} + ({{ penalty.incomeAccount.glCode }}) {{ penalty.incomeAccount.name }} +
+
+ } +
+ } +
diff --git a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.scss b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.scss index b4c371673..367f0cc1d 100644 --- a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.scss +++ b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.scss @@ -1,5 +1,22 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + @use 'assets/styles/helper'; @use 'assets/styles/colours' as *; +@use 'assets/styles/product-view' as pv; + +@include pv.product-view-styles; + +/* Current-account-specific green accent */ +:host { + --pv-accent: #15803d; + --pv-accent-l: #dcfce7; +} .tab-container { padding: 1%; diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-products.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-products.component.html index 0443d6386..6659f570b 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-products.component.html +++ b/src/app/products/fixed-deposit-products/fixed-deposit-products.component.html @@ -6,52 +6,167 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
-
- -
-
- -
-
- - {{ 'labels.inputs.Filter' | translate }} - - - +
+
+
+
+
+

{{ 'labels.heading.Fixed Deposit Products' | translate }}

+

Manage fixed-deposit products — tenure, interest charts and maturity rules.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'product' : 'products' }} +
+ +
-
- - - - - +
+
+
+
+
{{ totalCount | number }}
+
Total FD Products
+
+
+
+
+
+
{{ currencyCount | number }}
+
{{ currencyCount === 1 ? 'Currency' : 'Currencies' }} in use
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
- - - - +
+
+ +
-
- -
{{ 'labels.inputs.Name' | translate }}{{ fixedDepositProduct.name }}{{ 'labels.inputs.Short Name' | translate }}{{ fixedDepositProduct.shortName }}
+ @if (totalCount === 0) { +
+
+

No fixed-deposit products yet

+

Create your first FD product to enable term-deposit account onboarding.

+ +
+ } @else { +
+ + + + + - + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Name' | translate }} +
+
+
+
{{ p.name }}
+ @if (p.currency?.code) { +
{{ p.currency.code }}
+ } +
+
+
{{ 'labels.inputs.Short Name' | translate }} + {{ p.shortName }} + Currency + @if (p.currency?.code) { + {{ p.currency.code }} + } @else { + — + } + Actions + + +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No products match your filter. +
+ } + + +
+ }
-

{{ 'labels.buttons.Create Fixed Deposit Product' | translate }}

-

{{ 'labels.text.Allows you to create new fixed deposit product' | translate }}

+

{{ 'labels.heading.Create Fixed Deposit Product' | translate }}

+

{{ 'labels.text.This option allows you to create new fixed deposit product.' | translate }}

-
-
- - -
-

{{ fixedDepositProductData.name }}

- - - -

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

- - - -
- {{ 'labels.inputs.Short Name' | translate }}: - {{ fixedDepositProductData.shortName }} +
+ +
+
+
+
+
Fixed Deposit Product
+

{{ fixedDepositProductData.name }}

+
+ {{ fixedDepositProductData.shortName }} + @if (fixedDepositProductData.currency?.code) { + {{ fixedDepositProductData.currency.code }} + }
- @if (fixedDepositProductData.description) { -
- {{ 'labels.inputs.Description' | translate }}: - {{ fixedDepositProductData.description }} -
+

{{ fixedDepositProductData.description }}

} +
+
+ +
-

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

+
+

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

- + -
- {{ 'labels.heading.Currency' | translate }}: +
+ {{ 'labels.inputs.Short Name' | translate }}: + {{ fixedDepositProductData.shortName }} +
+ + @if (fixedDepositProductData.description) { +
+ {{ 'labels.inputs.Description' | translate }}: + {{ fixedDepositProductData.description }} +
+ } + +

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

+ + + +
+ {{ 'labels.heading.Currency' | translate }}: + ({{ fixedDepositProductData.currency.code }}) {{ fixedDepositProductData.currency.name }} +
+ +
+ {{ 'labels.inputs.Decimal Places' | translate }}: + {{ fixedDepositProductData.currency.decimalPlaces }} +
+ +
+ {{ 'labels.inputs.Currency in multiples of' | translate }}: + {{ fixedDepositProductData.currency.inMultiplesOf | formatNumber }} +
+ +

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

+ + + +
+ {{ 'labels.inputs.Deposit Amount' | translate }}: + {{ + fixedDepositProductData.depositAmount + + ' (Min: ' + + (fixedDepositProductData.minDepositAmount + ? fixedDepositProductData.minDepositAmount + : fixedDepositProductData.depositAmount) + + ', Max: ' + + (fixedDepositProductData.maxDepositAmount + ? fixedDepositProductData.maxDepositAmount + : fixedDepositProductData.depositAmount) + + ')' + }} +
+ +
+ {{ 'labels.inputs.Interest Compounding Period' | translate }}: + {{ fixedDepositProductData.interestCompoundingPeriodType.value }} +
+ +
+ {{ 'labels.inputs.Interest Posting Period' | translate }}: + {{ fixedDepositProductData.interestPostingPeriodType.value }} +
+ +
+ {{ 'labels.inputs.Interest Calculated Using' | translate }}: + {{ fixedDepositProductData.interestCalculationType.value }} +
+ +
+ {{ 'labels.inputs.Days in Year' | translate }}: + {{ fixedDepositProductData.interestCalculationDaysInYearType.value }} +
+ +

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

+ + + + @if (fixedDepositProductData.lockinPeriodFrequency) { +
+ {{ 'labels.inputs.Lock-in Period' | translate }}: + + {{ fixedDepositProductData.lockinPeriodFrequency }} + {{ fixedDepositProductData.lockinPeriodFrequencyType.value }} +
+ } + + @if (fixedDepositProductData.minDepositTerm) { +
+ {{ 'labels.inputs.Minimum Deposit Term' | translate }}: + @if (fixedDepositProductData.minDepositTerm !== undefined) { ({{ fixedDepositProductData.currency.code }}) {{ fixedDepositProductData.currency.name }}{{ fixedDepositProductData.minDepositTerm }} +   + {{ fixedDepositProductData.minDepositTermType.value }} -
+ } +
+ } -
- {{ 'labels.inputs.Decimal Places' | translate }}: - {{ fixedDepositProductData.currency.decimalPlaces }} -
+ @if (fixedDepositProductData.maxDepositTerm) { +
+ {{ 'labels.inputs.Maximum Deposit Term' | translate }}: + @if (fixedDepositProductData.maxDepositTerm !== undefined) { + {{ fixedDepositProductData.maxDepositTerm }} +   + {{ fixedDepositProductData.maxDepositTermType.value }} + } +
+ } -
- {{ 'labels.inputs.Currency in multiples of' | translate }}: - {{ fixedDepositProductData.currency.inMultiplesOf | formatNumber }} -
+ @if (fixedDepositProductData.inMultiplesOfDepositTerm) { +
+ {{ 'labels.inputs.Deposit Term In multiple of' | translate }}: + {{ fixedDepositProductData.inMultiplesOfDepositTerm }} + - + {{ fixedDepositProductData.inMultiplesOfDepositTermType.value }} +
+ } -

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

+
+ {{ 'labels.inputs.Apply Penal Interest (less)' | translate }}: + {{ fixedDepositProductData.preClosurePenalApplicable | yesNo }} +
+ @if (fixedDepositProductData.preClosurePenalApplicable) { +
+ {{ 'labels.inputs.Penal Interest' | translate }} (%): + {{ fixedDepositProductData.preClosurePenalInterest }}     %     On    + {{ fixedDepositProductData.preClosurePenalInterestOnType.value }} +
+ } + + @if (fixedDepositProductData.minBalanceForInterestCalculation) { +
+ {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}: + {{ fixedDepositProductData.minBalanceForInterestCalculation | formatNumber }} +
+ } + +
+ {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}: + {{ fixedDepositProductData.withHoldTax | yesNo }} +
+ + @if (fixedDepositProductData.withHoldTax) { +
+ {{ 'labels.inputs.Withhold Tax group' | translate }}: + {{ fixedDepositProductData.taxGroup.name }} +
+ } + + @if (fixedDepositProductData.activeChart) { +
+

{{ 'labels.heading.Interest Rate Chart' | translate }}

- -
- {{ 'labels.inputs.Deposit Amount' | translate }}: - {{ - fixedDepositProductData.depositAmount + - ' (Min: ' + - (fixedDepositProductData.minDepositAmount - ? fixedDepositProductData.minDepositAmount - : fixedDepositProductData.depositAmount) + - ', Max: ' + - (fixedDepositProductData.maxDepositAmount - ? fixedDepositProductData.maxDepositAmount - : fixedDepositProductData.depositAmount) + - ')' - }} -
- -
- {{ 'labels.inputs.Interest Compounding Period' | translate }}: - {{ fixedDepositProductData.interestCompoundingPeriodType.value }} -
- -
- {{ 'labels.inputs.Interest Posting Period' | translate }}: - {{ fixedDepositProductData.interestPostingPeriodType.value }} -
- -
- {{ 'labels.inputs.Interest Calculated Using' | translate }}: - {{ fixedDepositProductData.interestCalculationType.value }} -
- -
- {{ 'labels.inputs.Days in Year' | translate }}: - {{ fixedDepositProductData.interestCalculationDaysInYearType.value }} -
- -

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

- - - - @if (fixedDepositProductData.lockinPeriodFrequency) { -
- {{ 'labels.inputs.Lock-in Period' | translate }}: - - {{ fixedDepositProductData.lockinPeriodFrequency }} - {{ fixedDepositProductData.lockinPeriodFrequencyType.value }} -
- } - - @if (fixedDepositProductData.minDepositTerm) { -
- {{ 'labels.inputs.Minimum Deposit Term' | translate }}: - @if (fixedDepositProductData.minDepositTerm !== undefined) { - {{ fixedDepositProductData.minDepositTerm }} -   - {{ fixedDepositProductData.minDepositTermType.value }} - } -
- } - - @if (fixedDepositProductData.maxDepositTerm) { -
- {{ 'labels.inputs.Maximum Deposit Term' | translate }}: - @if (fixedDepositProductData.maxDepositTerm !== undefined) { - {{ fixedDepositProductData.maxDepositTerm }} -   - {{ fixedDepositProductData.maxDepositTermType.value }} - } -
- } - - @if (fixedDepositProductData.inMultiplesOfDepositTerm) { -
- {{ 'labels.inputs.Deposit Term In multiple of' | translate }}: - {{ fixedDepositProductData.inMultiplesOfDepositTerm }} - - - {{ fixedDepositProductData.inMultiplesOfDepositTermType.value }} -
- } - -
- {{ 'labels.inputs.Apply Penal Interest (less)' | translate }}: - {{ fixedDepositProductData.preClosurePenalApplicable | yesNo }} -
- - @if (fixedDepositProductData.preClosurePenalApplicable) { -
- {{ 'labels.inputs.Penal Interest' | translate }} (%): - {{ fixedDepositProductData.preClosurePenalInterest }}     %     On    - {{ fixedDepositProductData.preClosurePenalInterestOnType.value }} -
- } - - @if (fixedDepositProductData.minBalanceForInterestCalculation) { -
- {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}: - {{ fixedDepositProductData.minBalanceForInterestCalculation | formatNumber }} -
- } - -
- {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}: - {{ fixedDepositProductData.withHoldTax | yesNo }} -
- - @if (fixedDepositProductData.withHoldTax) { -
- {{ 'labels.inputs.Withhold Tax group' | translate }}: - {{ fixedDepositProductData.taxGroup.name }} -
- } - - @if (fixedDepositProductData.activeChart) { -
-

{{ 'labels.heading.Interest Rate Chart' | translate }}

- -
- @if (fixedDepositProductData.activeChart.name) { -
- {{ 'labels.inputs.Name' | translate }}: - {{ fixedDepositProductData.activeChart.name }} -
- } - @if (fixedDepositProductData.activeChart.description) { -
- {{ 'labels.inputs.Description' | translate }}: - {{ fixedDepositProductData.activeChart.description }} -
- } -
- {{ 'labels.inputs.From Date' | translate }}: - {{ fixedDepositProductData.activeChart.fromDate | dateFormat }} -
- @if (fixedDepositProductData.activeChart.endDate) { -
- {{ 'labels.inputs.End Date' | translate }}: - {{ fixedDepositProductData.activeChart.endDate | dateFormat }} -
- } -
- {{ 'labels.inputs.Primary Grouping by Amount' | translate }}: - {{ fixedDepositProductData.activeChart.isPrimaryGroupingByAmount | yesNo }} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Period' | translate }} - @if (chartSlab.fromPeriod !== undefined) { -
{{ chartSlab.fromPeriod }} - {{ chartSlab.toPeriod }} {{ chartSlab.periodType.value }}
- } - @if (chartSlab.fromPeriod === undefined) { -
 
- } -
{{ 'labels.inputs.Amount Range' | translate }} - @if (chartSlab.amountRangeFrom !== undefined) { -
{{ chartSlab.amountRangeFrom }} - {{ chartSlab.amountRangeTo }}
- } - @if (chartSlab.amountRangeFrom === undefined) { -
 
- } -
{{ 'labels.inputs.Interest' | translate }}{{ chartSlab.annualInterestRate }} %{{ 'labels.inputs.Description' | translate }} - {{ chartSlab.description }} - {{ 'labels.inputs.Actions' | translate }} - - -
- -

- {{ 'labels.heading.Incentives' | translate }} -

- - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Entity Type' | translate }} - {{ - incentive.entityType - | find: fixedDepositProductsTemplate.chartTemplate.entityTypeOptions : 'id' : 'value' - }} - {{ 'labels.inputs.Attribute Name' | translate }} - {{ - incentive.attributeName - | find - : fixedDepositProductsTemplate.chartTemplate.attributeNameOptions - : 'id' - : 'value' - }} - {{ 'labels.inputs.Condition Type' | translate }} - {{ - incentive.conditionType - | find - : fixedDepositProductsTemplate.chartTemplate.conditionTypeOptions - : 'id' - : 'value' - }} - {{ 'labels.inputs.Attribute Value' | translate }} - @switch (incentive.attributeName) { - @case (2) { - {{ - incentive.attributeValue - | find: fixedDepositProductsTemplate.chartTemplate.genderOptions : 'id' : 'name' - }} - } - @case (3) { - {{ incentive.attributeValue }} - } - @case (4) { - {{ - incentive.attributeValue - | find - : fixedDepositProductsTemplate.chartTemplate.clientTypeOptions - : 'id' - : 'name' - }} - } - @case (5) { - {{ - incentive.attributeValue - | find - : fixedDepositProductsTemplate.chartTemplate.clientClassificationOptions - : 'id' - : 'name' - }} - } - } - {{ 'labels.inputs.Incentive Type' | translate }} - {{ - incentive.incentiveType - | find - : fixedDepositProductsTemplate.chartTemplate.incentiveTypeOptions - : 'id' - : 'value' - }} - {{ 'labels.inputs.Amount' | translate }} - {{ incentive.amount | formatNumber }} -
-
-
-
+
+ @if (fixedDepositProductData.activeChart.name) { +
+ {{ 'labels.inputs.Name' | translate }}: + {{ fixedDepositProductData.activeChart.name }}
+ } + @if (fixedDepositProductData.activeChart.description) { +
+ {{ 'labels.inputs.Description' | translate }}: + {{ fixedDepositProductData.activeChart.description }} +
+ } +
+ {{ 'labels.inputs.From Date' | translate }}: + {{ fixedDepositProductData.activeChart.fromDate | dateFormat }}
- } + @if (fixedDepositProductData.activeChart.endDate) { +
+ {{ 'labels.inputs.End Date' | translate }}: + {{ fixedDepositProductData.activeChart.endDate | dateFormat }} +
+ } +
+ {{ 'labels.inputs.Primary Grouping by Amount' | translate }}: + {{ fixedDepositProductData.activeChart.isPrimaryGroupingByAmount | yesNo }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Period' | translate }} + @if (chartSlab.fromPeriod !== undefined) { +
{{ chartSlab.fromPeriod }} - {{ chartSlab.toPeriod }} {{ chartSlab.periodType.value }}
+ } + @if (chartSlab.fromPeriod === undefined) { +
 
+ } +
{{ 'labels.inputs.Amount Range' | translate }} + @if (chartSlab.amountRangeFrom !== undefined) { +
{{ chartSlab.amountRangeFrom }} - {{ chartSlab.amountRangeTo }}
+ } + @if (chartSlab.amountRangeFrom === undefined) { +
 
+ } +
{{ 'labels.inputs.Interest' | translate }}{{ chartSlab.annualInterestRate }} %{{ 'labels.inputs.Description' | translate }} + {{ chartSlab.description }} + {{ 'labels.inputs.Actions' | translate }} + + +
+ +

+ {{ 'labels.heading.Incentives' | translate }} +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Entity Type' | translate }} + {{ + incentive.entityType + | find: fixedDepositProductsTemplate.chartTemplate.entityTypeOptions : 'id' : 'value' + }} + {{ 'labels.inputs.Attribute Name' | translate }} + {{ + incentive.attributeName + | find: fixedDepositProductsTemplate.chartTemplate.attributeNameOptions : 'id' : 'value' + }} + {{ 'labels.inputs.Condition Type' | translate }} + {{ + incentive.conditionType + | find: fixedDepositProductsTemplate.chartTemplate.conditionTypeOptions : 'id' : 'value' + }} + {{ 'labels.inputs.Attribute Value' | translate }} + @switch (incentive.attributeName) { + @case (2) { + {{ + incentive.attributeValue + | find: fixedDepositProductsTemplate.chartTemplate.genderOptions : 'id' : 'name' + }} + } + @case (3) { + {{ incentive.attributeValue }} + } + @case (4) { + {{ + incentive.attributeValue + | find: fixedDepositProductsTemplate.chartTemplate.clientTypeOptions : 'id' : 'name' + }} + } + @case (5) { + {{ + incentive.attributeValue + | find + : fixedDepositProductsTemplate.chartTemplate.clientClassificationOptions + : 'id' + : 'name' + }} + } + } + {{ 'labels.inputs.Incentive Type' | translate }} + {{ + incentive.incentiveType + | find: fixedDepositProductsTemplate.chartTemplate.incentiveTypeOptions : 'id' : 'value' + }} + {{ 'labels.inputs.Amount' | translate }} + {{ incentive.amount | formatNumber }} +
+
+
+
+
+
+ } - @if (fixedDepositProductData.charges.length) { -
-

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

- - - - - - + } - - -
{{ 'labels.inputs.Name' | translate }} - {{ charge.name }} + @if (fixedDepositProductData.charges.length) { +
+

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

+ + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Name' | translate }} + {{ charge.name }} + {{ 'labels.inputs.Type' | translate }} + {{ charge.chargeCalculationType.value }} + {{ 'labels.inputs.Amount' | translate }} + {{ charge.amount | formatNumber }} + {{ 'labels.inputs.Collected On' | translate }} + {{ charge.chargeTimeType.value }} +
+
+ } + +

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

+ + + + + + + @if ( + fixedDepositProductData.advancedAccountingRules && + (fixedDepositProductData.paymentChannelToFundSourceMappings?.length || + fixedDepositProductData.feeToIncomeAccountMappings?.length || + fixedDepositProductData.penaltyToIncomeAccountMappings?.length) + ) { +
+

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

+ +
+ @if (fixedDepositProductData.paymentChannelToFundSourceMappings?.length) { +
+

+ {{ 'labels.inputs.Configure Fund Sources for Payment Channels' | translate }} +

+ + + + - - - + - - - - - - - - - - + +
{{ 'labels.inputs.Payment Type' | translate }} + {{ paymentFundSource.paymentType.name }} {{ 'labels.inputs.Type' | translate }} - {{ charge.chargeCalculationType.value }} + + {{ 'labels.inputs.Fund Source' | translate }} + {{ paymentFundSource.fundSourceAccount.name }} {{ 'labels.inputs.Amount' | translate }} - {{ charge.amount | formatNumber }} - {{ 'labels.inputs.Collected On' | translate }} - {{ charge.chargeTimeType.value }} -
} - -

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

- - - - - - - @if ( - fixedDepositProductData.advancedAccountingRules && - (fixedDepositProductData.paymentChannelToFundSourceMappings?.length || - fixedDepositProductData.feeToIncomeAccountMappings?.length || - fixedDepositProductData.penaltyToIncomeAccountMappings?.length) - ) { -
-

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

- -
- @if (fixedDepositProductData.paymentChannelToFundSourceMappings?.length) { -
-

- {{ 'labels.inputs.Configure Fund Sources for Payment Channels' | translate }} -

- - - - - - - - - - - -
{{ 'labels.inputs.Payment Type' | translate }} - {{ paymentFundSource.paymentType.name }} - {{ 'labels.inputs.Fund Source' | translate }} - {{ paymentFundSource.fundSourceAccount.name }} -
-
- } - @if (fixedDepositProductData.feeToIncomeAccountMappings?.length) { -
-

{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}

- - - - - - - - - - - -
{{ 'labels.inputs.Fees' | translate }} - {{ feesIncome.charge.name }} - {{ 'labels.inputs.Income Account' | translate }} - {{ feesIncome.incomeAccount.name }} -
-
- } - @if (fixedDepositProductData.penaltyToIncomeAccountMappings?.length) { -
-

- {{ 'labels.heading.Penalties to Specific Income Accounts' | translate }} -

- - - - - - - - - - - -
{{ 'labels.inputs.Penalty' | translate }} - {{ penaltyIncome.charge.name }} - {{ 'labels.inputs.Income Account' | translate }} - {{ penaltyIncome.incomeAccount.name }} -
-
- } + @if (fixedDepositProductData.feeToIncomeAccountMappings?.length) { +
+

{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}

+ + + + + + + + + + + +
{{ 'labels.inputs.Fees' | translate }} + {{ feesIncome.charge.name }} + {{ 'labels.inputs.Income Account' | translate }} + {{ feesIncome.incomeAccount.name }} +
+
+ } + @if (fixedDepositProductData.penaltyToIncomeAccountMappings?.length) { +
+

+ {{ 'labels.heading.Penalties to Specific Income Accounts' | translate }} +

+ + + + + + + + + + + +
{{ 'labels.inputs.Penalty' | translate }} + {{ penaltyIncome.charge.name }} + {{ 'labels.inputs.Income Account' | translate }} + {{ penaltyIncome.incomeAccount.name }} +
}
- - + } +
diff --git a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss index 83a6fded5..8402cc126 100644 --- a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss +++ b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss @@ -8,6 +8,15 @@ @use 'assets/styles/helper'; @use 'assets/styles/colours' as *; +@use 'assets/styles/product-view' as pv; + +@include pv.product-view-styles; + +/* FD-specific teal accent */ +:host { + --pv-accent: #0e7e6b; + --pv-accent-l: #ccfbf1; +} .container { width: 100%; diff --git a/src/app/products/loan-products/loan-products.component.html b/src/app/products/loan-products/loan-products.component.html index 0c1887f6f..676738f06 100644 --- a/src/app/products/loan-products/loan-products.component.html +++ b/src/app/products/loan-products/loan-products.component.html @@ -6,73 +6,195 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
-
- -
-
- -
-
- -
-
- - {{ 'labels.inputs.Filter' | translate }} - - - +
+ +
+
+
+
+

{{ 'labels.heading.Loan Products' | translate }}

+

Manage loan products available for disbursement — terms, charges, and accounting rules.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'product' : 'products' }} +
+ + +
-
- - - - - + +
+
+
+
+
{{ totalCount | number }}
+
Total Loan Products
+
+
+
+
+
+
{{ activeCount | number }}
+
Active
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
- - - - + +
+
+ +
- -
- - + @if (totalCount === 0) { +
+
+

No loan products yet

+

Create your first loan product to enable loan account onboarding for clients.

+ +
+ } @else { +
+
{{ 'labels.inputs.Name' | translate }}{{ loanProduct.name }}{{ 'labels.inputs.Short Name' | translate }}{{ loanProduct.shortName }}{{ 'labels.inputs.Expiry Date' | translate }}{{ loanProduct.closeDate | dateFormat }}
+ + + + - - - + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Name' | translate }} +
+
+
+
{{ p.name }}
+ @if (p.currency?.code) { +
{{ p.currency.code }}
+ } +
+
+
{{ 'labels.inputs.Status' | translate }} -
- + +
{{ 'labels.inputs.Short Name' | translate }} + {{ p.shortName }} + {{ 'labels.inputs.Expiry Date' | translate }} + @if (p.closeDate) { + {{ p.closeDate | dateFormat }} + } @else { + — + } + {{ 'labels.inputs.Status' | translate }} + + + {{ + p.status === 'loanProduct.active' + ? ('labels.inputs.Active' | translate) + : ('labels.catalogs.Inactive' | translate) + }} + + Actions + + +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No products match your filter.
-
- - + +
+ }
+

{{ 'labels.heading.Create loan product' | translate }}

{{ 'labels.text.This option allows you to create new loan product.' | translate }}

diff --git a/src/app/products/loan-products/loan-products.component.scss b/src/app/products/loan-products/loan-products.component.scss index 567dcb2f8..e15399058 100644 --- a/src/app/products/loan-products/loan-products.component.scss +++ b/src/app/products/loan-products/loan-products.component.scss @@ -6,10 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -table { - width: 100%; +@use 'assets/styles/product-list' as pl; - .select-row:hover { - cursor: pointer; - } +@include pl.product-list-styles; + +/* Loan-specific accent tint (amber/orange) */ +:host { + --pl-accent: #b45309; + --pl-accent-l: #fef3c7; } diff --git a/src/app/products/loan-products/loan-products.component.ts b/src/app/products/loan-products/loan-products.component.ts index c3733f68b..f16a3bff1 100644 --- a/src/app/products/loan-products/loan-products.component.ts +++ b/src/app/products/loan-products/loan-products.component.ts @@ -78,15 +78,30 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { private settingsService = inject(SettingsService); private errorHandler = inject(ErrorHandlerService); - loanProductsData: any; + loanProductsData: any[] = []; displayedColumns: string[] = [ 'name', 'shortName', 'closeDate', - 'status' + 'status', + 'actions' ]; dataSource: MatTableDataSource; + filteredCount = 0; + + get totalCount(): number { + return this.loanProductsData?.length || 0; + } + + get activeCount(): number { + return (this.loanProductsData || []).filter((p) => p?.status === 'loanProduct.active').length; + } + + get inactiveCount(): number { + return this.totalCount - this.activeCount; + } + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; @ViewChild(MatSort, { static: true }) sort: MatSort; @@ -115,10 +130,12 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { this.dataSource = new MatTableDataSource(this.loanProductsData); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; + this.filteredCount = this.totalCount; + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); } applyFilter(filterValue: string) { - this.dataSource.filter = filterValue.trim().toLowerCase(); + this.dataSource.filter = (filterValue || '').trim().toLowerCase(); } /** diff --git a/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.html b/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.html index 941a1855f..daadc5f00 100644 --- a/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.html +++ b/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.html @@ -6,26 +6,58 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- - - -
+
+ +
+
+
+
+
Loan Product
+

{{ loanProduct.name }}

+
+ {{ loanProduct.shortName }} + @if (loanProduct.currency?.code) { + {{ loanProduct.currency.code }} + } + + + {{ loanProduct.status === 'loanProduct.active' ? 'Active' : 'Inactive' }} + +
+ @if (loanProduct.description) { +

{{ loanProduct.description }}

+ } +
+
+
+ + +
+
- - + +
+ + +
+
diff --git a/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.scss b/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.scss index af9f4c075..9f020ced7 100644 --- a/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.scss +++ b/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.scss @@ -5,3 +5,13 @@ * 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/. */ + +@use 'assets/styles/product-view' as pv; + +@include pv.product-view-styles; + +/* Loan-specific amber accent */ +:host { + --pv-accent: #b45309; + --pv-accent-l: #fef3c7; +} diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html index a2e463e62..7616d87e5 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html @@ -6,71 +6,177 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
-
- -
-
- -
-
- -
-
- - {{ 'labels.inputs.Filter' | translate }} - - - +
+
+
+
+
+

{{ 'labels.heading.Recurring Deposit Products' | translate }}

+

Manage recurring-deposit products — instalment schedules, interest rates and charges.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'product' : 'products' }} +
+ + +
-
- - - - - +
+
+
+
+
{{ totalCount | number }}
+
Total RD Products
+
+
+
+
+
+
{{ currencyCount | number }}
+
{{ currencyCount === 1 ? 'Currency' : 'Currencies' }} in use
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
- - - - +
+
+ +
-
- -
{{ 'labels.inputs.Name' | translate }}{{ recurringDepositProduct.name }}{{ 'labels.inputs.Short Name' | translate }}{{ recurringDepositProduct.shortName }}
+ @if (totalCount === 0) { +
+
+

No recurring-deposit products yet

+

Create your first RD product to enable instalment-savings onboarding.

+ +
+ } @else { +
+ + + + + - + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Name' | translate }} +
+
+
+
{{ p.name }}
+ @if (p.currency?.code) { +
{{ p.currency.code }}
+ } +
+
+
{{ 'labels.inputs.Short Name' | translate }} + {{ p.shortName }} + Currency + @if (p.currency?.code) { + {{ p.currency.code }} + } @else { + — + } + Actions + + +
+ + @if (filteredCount === 0 && totalCount > 0) { +
+ + No products match your filter. +
+ } + + +
+ }
-

{{ 'labels.heading.Create recurring deposit product' | translate }}

-

{{ 'labels.text.This option allows you to create new recurring product.' | translate }}

+

{{ 'labels.heading.Create Recurring Deposit Product' | translate }}

+

{{ 'labels.text.This option allows you to create new recurring deposit product.' | translate }}

-
- -
-

{{ recurringDepositProduct.name }}

- - - -

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

- - -
- {{ 'labels.inputs.Short Name' | translate }}: - {{ recurringDepositProduct.shortName }} -
- - @if (recurringDepositProduct.description) { -
- {{ 'labels.inputs.Description' | translate }}: - {{ recurringDepositProduct.description }} -
- } - - -

Product Classification

- - - @if (recurringDepositProduct.accountType) { -
- Account Type: - {{ recurringDepositProduct.accountType }} -
- } - @if (recurringDepositProduct.customerSegment) { -
- Customer Segment: - {{ recurringDepositProduct.customerSegment }} -
- } - @if (recurringDepositProduct.glAccountId) { -
- GL Account (Liability): - {{ recurringDepositProduct.glAccountName || recurringDepositProduct.glAccountId }} -
- } - @if (recurringDepositProduct.effectiveTo) { -
- Effective To: - {{ recurringDepositProduct.effectiveTo | dateFormat }} -
- } - @if (recurringDepositProduct.endDate) { -
- End Date: - {{ recurringDepositProduct.endDate | dateFormat }} -
- } -
- Introducer Required: - {{ recurringDepositProduct.introducerRequired | yesNo }} -
-
- Deposit Account Link: - {{ recurringDepositProduct.shareLinkingEnabled | yesNo }} -
- - -

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

- - -
- {{ 'labels.inputs.Currency' | translate }}: - ({{ recurringDepositProduct.currency.code }}) {{ recurringDepositProduct.currency.name }} -
-
- {{ 'labels.inputs.Decimal Places' | translate }}: - {{ recurringDepositProduct.currency.decimalPlaces }} -
-
- Dormancy Alert: - {{ recurringDepositProduct.dormancyAlert | yesNo }} -
-
- Balance Swap Option: - {{ recurringDepositProduct.balanceSwapOption | yesNo }} -
-
- MSME: - {{ recurringDepositProduct.msme | yesNo }} -
-
- Nomination Facility: - {{ recurringDepositProduct.nominationFacility | yesNo }} -
-
- Auto Debit Facility: - {{ recurringDepositProduct.autoDebitFacility | yesNo }} -
- - -

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

- - -
- {{ 'labels.inputs.Deposit Amount' | translate }}: - {{ - recurringDepositProduct.depositAmount + - ' (Min: ' + - (recurringDepositProduct.minDepositAmount || recurringDepositProduct.depositAmount) + - ', Max: ' + - (recurringDepositProduct.maxDepositAmount || recurringDepositProduct.depositAmount) + - ')' - }} -
-
- {{ 'labels.inputs.Nominal Annual Interest' | translate }}: - {{ recurringDepositProduct.nominalAnnualInterestRate | formatNumber }} % -
- @if (recurringDepositProduct.effectiveDate) { -
- Effective Date: - {{ recurringDepositProduct.effectiveDate | dateFormat }} -
- } - @if (recurringDepositProduct.interestCalculationTypeName) { -
- {{ 'labels.inputs.Interest Calculation Type' | translate }}: - {{ recurringDepositProduct.interestCalculationTypeName }} -
- } -
- Interest Calculate: - {{ recurringDepositProduct.interestCalculationType?.value }} -
- @if (recurringDepositProduct.averagePeriod) { -
- Average Period: - {{ recurringDepositProduct.averagePeriod }} -
- } -
- {{ 'labels.inputs.Interest Posting Period' | translate }}: - {{ recurringDepositProduct.interestPostingPeriodType.value }} -
-
- {{ 'labels.inputs.Interest Compounding Period' | translate }}: - {{ recurringDepositProduct.interestCompoundingPeriodType.value }} -
-
- {{ 'labels.inputs.Days in Year' | translate }}: - {{ recurringDepositProduct.interestCalculationDaysInYearType.value }} -
-
- Auto Post: - {{ recurringDepositProduct.autoPost | yesNo }} -
- @if (recurringDepositProduct.frequencyFixDay) { -
- Frequency (Fix Day): - {{ recurringDepositProduct.frequencyFixDay }} -
- } - @if (recurringDepositProduct.lastInterestDate) { -
- Last Interest Date: - {{ recurringDepositProduct.lastInterestDate | dateFormat }} -
- } - - -

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

- - - -

Premature Option

-
- Loan Facility: - {{ recurringDepositProduct.loanFacility | yesNo }} -
-
- Premature Withdrawal Option: - {{ recurringDepositProduct.prematureWithdrawalOption | yesNo }} -
-
- Tenure Flexibility: - {{ recurringDepositProduct.tenureFlexibility | yesNo }} -
-
- Lien Marked: - {{ recurringDepositProduct.lienMarked | yesNo }} -
-
- {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}: - {{ recurringDepositProduct.withdrawalFeeForTransfers | yesNo }} -
- - -

{{ 'labels.inputs.Lock-in Period' | translate }}

-
- Enable Lock-in Period: - {{ recurringDepositProduct.enableLockinPeriod | yesNo }} -
- @if (recurringDepositProduct.lockinPeriodFrequency) { -
- {{ 'labels.inputs.Lock-in Period' | translate }}: - {{ recurringDepositProduct.lockinPeriodFrequency }} - {{ recurringDepositProduct.lockinPeriodFrequencyType?.value }} -
- } - - -

Maturity Instructions

-
- Maturity Instructions: - {{ recurringDepositProduct.maturityInstructions | yesNo }} -
-
- Credit Maturity Proceeds: - {{ recurringDepositProduct.creditMaturityProceeds | yesNo }} -
-
- Penalty Grace Period (Missed Installments): - {{ recurringDepositProduct.penaltyGracePeriodMissedInstallments | yesNo }} -
-
- Panel Request Applicable: - {{ recurringDepositProduct.panelRequestApplicable | yesNo }} -
-
- No Penalty (Nominee / Heir Closure): - {{ recurringDepositProduct.noPenaltyNomineeHeirClosure | yesNo }} -
-
- Deceased Settlement: - {{ recurringDepositProduct.deceasedSettlement | yesNo }} -
-
- Allow Debit Txn After Due Date: - {{ recurringDepositProduct.allowDebitTxnAfterDueDate | yesNo }} -
- - -

Additional Rules

-
- Account Closed Zero Balance in Day Closing: - {{ recurringDepositProduct.accountClosedZeroBalanceInDayClosing | yesNo }} -
- @if (recurringDepositProduct.minBalanceForInterestCalculation) { -
- {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}: - {{ recurringDepositProduct.minBalanceForInterestCalculation | formatNumber }} -
- } - - @if (recurringDepositProduct.minDepositTerm) { -
- {{ 'labels.inputs.Minimum Deposit Term' | translate }}: - {{ recurringDepositProduct.minDepositTerm }} {{ recurringDepositProduct.minDepositTermType?.value }} -
- } - @if (recurringDepositProduct.maxDepositTerm) { -
- {{ 'labels.inputs.Maximum Deposit Term' | translate }}: - {{ recurringDepositProduct.maxDepositTerm }} {{ recurringDepositProduct.maxDepositTermType?.value }} -
- } - @if (recurringDepositProduct.inMultipleOfDepositTerm) { -
- {{ 'labels.inputs.Deposit Term In multiple of' | translate }}: - {{ recurringDepositProduct.inMultipleOfDepositTerm }} - - {{ recurringDepositProduct.inMultipleOfDepositTermType?.value }} -
- } - @if (recurringDepositProduct.isMandatoryDeposit !== undefined) { -
- {{ 'labels.inputs.Recurring Deposit Type' | translate }}: - {{ - recurringDepositProduct.isMandatoryDeposit - ? ('labels.text.Mandatory' | translate) - : ('labels.text.Voluntary' | translate) - }} -
- } - @if (recurringDepositProduct.preClosurePenalInterest) { -
- {{ 'labels.inputs.Apply penal interest (less)' | translate }}: - {{ recurringDepositProduct.preClosurePenalInterest }}% On - {{ recurringDepositProduct.preClosurePenalInterestOnType?.value }} -
- } -
- {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}: - {{ recurringDepositProduct.withHoldTax | yesNo }} -
- @if (recurringDepositProduct.withHoldTax) { -
- {{ 'labels.inputs.Tax Group' | translate }}: - {{ recurringDepositProduct.taxGroup?.name }} -
- } - - - @if (recurringDepositProduct.activeChart) { -
-

{{ 'labels.heading.Interest Rate Charts' | translate }}

- -
- @if (recurringDepositProduct.activeChart.name) { -
- {{ 'labels.inputs.Name' | translate }}: - {{ recurringDepositProduct.activeChart.name }} -
- } -
- {{ 'labels.inputs.Valid from Date' | translate }}: - {{ recurringDepositProduct.activeChart.fromDate | dateFormat }} +
+ +
+
+
+
+
Recurring Deposit Product
+

{{ recurringDepositProduct.name }}

+
+ {{ recurringDepositProduct.shortName }} + @if (recurringDepositProduct.currency?.code) { + {{ recurringDepositProduct.currency.code }} + }
- @if (recurringDepositProduct.activeChart.endDate) { -
- {{ 'labels.inputs.End Date' | translate }}: - {{ recurringDepositProduct.activeChart.endDate | dateFormat }} -
+ @if (recurringDepositProduct.description) { +

{{ recurringDepositProduct.description }}

} - @if (recurringDepositProduct.activeChart.description) { -
- {{ 'labels.inputs.Description' | translate }}: - {{ recurringDepositProduct.activeChart.description }} -
- } -
- {{ 'labels.inputs.Primary Grouping by Amount' | translate }}: - {{ recurringDepositProduct.activeChart.isPrimaryGroupingByAmount | yesNo }} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Period' | translate }} - @if (chartSlab.fromPeriod !== undefined) { -
{{ chartSlab.fromPeriod }} - {{ chartSlab.toPeriod }} {{ chartSlab.periodType.value }}
- } - @if (chartSlab.fromPeriod === undefined) { -
 
- } -
{{ 'labels.inputs.Amount Range' | translate }} - @if (chartSlab.amountRangeFrom !== undefined) { -
{{ chartSlab.amountRangeFrom }} - {{ chartSlab.amountRangeTo }}
- } - @if (chartSlab.amountRangeFrom === undefined) { -
 
- } -
{{ 'labels.inputs.Interest' | translate }}{{ chartSlab.annualInterestRate }}{{ 'labels.inputs.Description' | translate }}{{ chartSlab.description }}{{ 'labels.inputs.Actions' | translate }} - - -
- -

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

- - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Entity Type' | translate }} - {{ - incentive.entityType - | find: recurringDepositProductTemplate.chartTemplate.entityTypeOptions : 'id' : 'value' - }} - {{ 'labels.inputs.Attribute Name' | translate }} - {{ - incentive.attributeName - | find: recurringDepositProductTemplate.chartTemplate.attributeNameOptions : 'id' : 'value' - }} - {{ 'labels.inputs.Condition Type' | translate }} - {{ - incentive.conditionType - | find: recurringDepositProductTemplate.chartTemplate.conditionTypeOptions : 'id' : 'value' - }} - {{ 'labels.inputs.Attribute Value' | translate }} - @switch (incentive.attributeName) { - @case (2) { - {{ - incentive.attributeValue - | find: recurringDepositProductTemplate.chartTemplate.genderOptions : 'id' : 'name' - }} - } - @case (3) { - {{ incentive.attributeValue }} - } - @case (4) { - {{ - incentive.attributeValue - | find: recurringDepositProductTemplate.chartTemplate.clientTypeOptions : 'id' : 'name' - }} - } - @case (5) { - {{ - incentive.attributeValue - | find - : recurringDepositProductTemplate.chartTemplate.clientClassificationOptions - : 'id' - : 'name' - }} - } - } - {{ 'labels.inputs.Incentive Type' | translate }} - {{ - incentive.incentiveType - | find: recurringDepositProductTemplate.chartTemplate.incentiveTypeOptions : 'id' : 'value' - }} - {{ 'labels.inputs.Amount' | translate }}{{ incentive.amount }}
-
-
-
-
- } + +
- - @if (recurringDepositProduct.charges.length) { -
-

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

- - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Name' | translate }}{{ charge.name }}{{ 'labels.inputs.Type' | translate }}{{ charge.chargeCalculationType.value }}{{ 'labels.inputs.Amount' | translate }}{{ charge.amount | formatNumber }}{{ 'labels.inputs.Collected On' | translate }}{{ charge.chargeTimeType.value }}
+
+ +

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

+ + +
+ {{ 'labels.inputs.Short Name' | translate }}: + {{ recurringDepositProduct.shortName }}
- } - -

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

- + @if (recurringDepositProduct.description) { +
+ {{ 'labels.inputs.Description' | translate }}: + {{ recurringDepositProduct.description }} +
+ } - - + +

Product Classification

+ - @if ( - recurringDepositProduct.paymentChannelToFundSourceMappings?.length || - recurringDepositProduct.feeToIncomeAccountMappings?.length || - recurringDepositProduct.penaltyToIncomeAccountMappings?.length - ) { -
-

{{ 'labels.inputs.Advanced Accounting Rules' | translate }}

- - @if (recurringDepositProduct.paymentChannelToFundSourceMappings?.length) { -
-

{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}

+ @if (recurringDepositProduct.accountType) { +
+ Account Type: + {{ recurringDepositProduct.accountType }} +
+ } + @if (recurringDepositProduct.customerSegment) { +
+ Customer Segment: + {{ recurringDepositProduct.customerSegment }} +
+ } + @if (recurringDepositProduct.glAccountId) { +
+ GL Account (Liability): + {{ recurringDepositProduct.glAccountName || recurringDepositProduct.glAccountId }} +
+ } + @if (recurringDepositProduct.effectiveTo) { +
+ Effective To: + {{ recurringDepositProduct.effectiveTo | dateFormat }} +
+ } + @if (recurringDepositProduct.endDate) { +
+ End Date: + {{ recurringDepositProduct.endDate | dateFormat }} +
+ } +
+ Introducer Required: + {{ recurringDepositProduct.introducerRequired | yesNo }} +
+
+ Deposit Account Link: + {{ recurringDepositProduct.shareLinkingEnabled | yesNo }} +
+ + +

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

+ + +
+ {{ 'labels.inputs.Currency' | translate }}: + ({{ recurringDepositProduct.currency.code }}) {{ recurringDepositProduct.currency.name }} +
+
+ {{ 'labels.inputs.Decimal Places' | translate }}: + {{ recurringDepositProduct.currency.decimalPlaces }} +
+
+ Dormancy Alert: + {{ recurringDepositProduct.dormancyAlert | yesNo }} +
+
+ Balance Swap Option: + {{ recurringDepositProduct.balanceSwapOption | yesNo }} +
+
+ MSME: + {{ recurringDepositProduct.msme | yesNo }} +
+
+ Nomination Facility: + {{ recurringDepositProduct.nominationFacility | yesNo }} +
+
+ Auto Debit Facility: + {{ recurringDepositProduct.autoDebitFacility | yesNo }} +
+ + +

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

+ + +
+ {{ 'labels.inputs.Deposit Amount' | translate }}: + {{ + recurringDepositProduct.depositAmount + + ' (Min: ' + + (recurringDepositProduct.minDepositAmount || recurringDepositProduct.depositAmount) + + ', Max: ' + + (recurringDepositProduct.maxDepositAmount || recurringDepositProduct.depositAmount) + + ')' + }} +
+
+ {{ 'labels.inputs.Nominal Annual Interest' | translate }}: + {{ recurringDepositProduct.nominalAnnualInterestRate | formatNumber }} % +
+ @if (recurringDepositProduct.effectiveDate) { +
+ Effective Date: + {{ recurringDepositProduct.effectiveDate | dateFormat }} +
+ } + @if (recurringDepositProduct.interestCalculationTypeName) { +
+ {{ 'labels.inputs.Interest Calculation Type' | translate }}: + {{ recurringDepositProduct.interestCalculationTypeName }} +
+ } +
+ Interest Calculate: + {{ recurringDepositProduct.interestCalculationType?.value }} +
+ @if (recurringDepositProduct.averagePeriod) { +
+ Average Period: + {{ recurringDepositProduct.averagePeriod }} +
+ } +
+ {{ 'labels.inputs.Interest Posting Period' | translate }}: + {{ recurringDepositProduct.interestPostingPeriodType.value }} +
+
+ {{ 'labels.inputs.Interest Compounding Period' | translate }}: + {{ recurringDepositProduct.interestCompoundingPeriodType.value }} +
+
+ {{ 'labels.inputs.Days in Year' | translate }}: + {{ recurringDepositProduct.interestCalculationDaysInYearType.value }} +
+
+ Auto Post: + {{ recurringDepositProduct.autoPost | yesNo }} +
+ @if (recurringDepositProduct.frequencyFixDay) { +
+ Frequency (Fix Day): + {{ recurringDepositProduct.frequencyFixDay }} +
+ } + @if (recurringDepositProduct.lastInterestDate) { +
+ Last Interest Date: + {{ recurringDepositProduct.lastInterestDate | dateFormat }} +
+ } + + +

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

+ + + +

Premature Option

+
+ Loan Facility: + {{ recurringDepositProduct.loanFacility | yesNo }} +
+
+ Premature Withdrawal Option: + {{ recurringDepositProduct.prematureWithdrawalOption | yesNo }} +
+
+ Tenure Flexibility: + {{ recurringDepositProduct.tenureFlexibility | yesNo }} +
+
+ Lien Marked: + {{ recurringDepositProduct.lienMarked | yesNo }} +
+
+ {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}: + {{ recurringDepositProduct.withdrawalFeeForTransfers | yesNo }} +
+ + +

{{ 'labels.inputs.Lock-in Period' | translate }}

+
+ Enable Lock-in Period: + {{ recurringDepositProduct.enableLockinPeriod | yesNo }} +
+ @if (recurringDepositProduct.lockinPeriodFrequency) { +
+ {{ 'labels.inputs.Lock-in Period' | translate }}: + {{ recurringDepositProduct.lockinPeriodFrequency }} + {{ recurringDepositProduct.lockinPeriodFrequencyType?.value }} +
+ } + + +

Maturity Instructions

+
+ Maturity Instructions: + {{ recurringDepositProduct.maturityInstructions | yesNo }} +
+
+ Credit Maturity Proceeds: + {{ recurringDepositProduct.creditMaturityProceeds | yesNo }} +
+
+ Penalty Grace Period (Missed Installments): + {{ recurringDepositProduct.penaltyGracePeriodMissedInstallments | yesNo }} +
+
+ Panel Request Applicable: + {{ recurringDepositProduct.panelRequestApplicable | yesNo }} +
+
+ No Penalty (Nominee / Heir Closure): + {{ recurringDepositProduct.noPenaltyNomineeHeirClosure | yesNo }} +
+
+ Deceased Settlement: + {{ recurringDepositProduct.deceasedSettlement | yesNo }} +
+
+ Allow Debit Txn After Due Date: + {{ recurringDepositProduct.allowDebitTxnAfterDueDate | yesNo }} +
+ + +

Additional Rules

+
+ Account Closed Zero Balance in Day Closing: + {{ recurringDepositProduct.accountClosedZeroBalanceInDayClosing | yesNo }} +
+ @if (recurringDepositProduct.minBalanceForInterestCalculation) { +
+ {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}: + {{ recurringDepositProduct.minBalanceForInterestCalculation | formatNumber }} +
+ } + + @if (recurringDepositProduct.minDepositTerm) { +
+ {{ 'labels.inputs.Minimum Deposit Term' | translate }}: + {{ recurringDepositProduct.minDepositTerm }} {{ recurringDepositProduct.minDepositTermType?.value }} +
+ } + @if (recurringDepositProduct.maxDepositTerm) { +
+ {{ 'labels.inputs.Maximum Deposit Term' | translate }}: + {{ recurringDepositProduct.maxDepositTerm }} {{ recurringDepositProduct.maxDepositTermType?.value }} +
+ } + @if (recurringDepositProduct.inMultipleOfDepositTerm) { +
+ {{ 'labels.inputs.Deposit Term In multiple of' | translate }}: + {{ recurringDepositProduct.inMultipleOfDepositTerm }} - + {{ recurringDepositProduct.inMultipleOfDepositTermType?.value }} +
+ } + @if (recurringDepositProduct.isMandatoryDeposit !== undefined) { +
+ {{ 'labels.inputs.Recurring Deposit Type' | translate }}: + {{ + recurringDepositProduct.isMandatoryDeposit + ? ('labels.text.Mandatory' | translate) + : ('labels.text.Voluntary' | translate) + }} +
+ } + @if (recurringDepositProduct.preClosurePenalInterest) { +
+ {{ 'labels.inputs.Apply penal interest (less)' | translate }}: + {{ recurringDepositProduct.preClosurePenalInterest }}% On + {{ recurringDepositProduct.preClosurePenalInterestOnType?.value }} +
+ } +
+ {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}: + {{ recurringDepositProduct.withHoldTax | yesNo }} +
+ @if (recurringDepositProduct.withHoldTax) { +
+ {{ 'labels.inputs.Tax Group' | translate }}: + {{ recurringDepositProduct.taxGroup?.name }} +
+ } + + + @if (recurringDepositProduct.activeChart) { +
+

{{ 'labels.heading.Interest Rate Charts' | translate }}

+ +
+ @if (recurringDepositProduct.activeChart.name) { +
+ {{ 'labels.inputs.Name' | translate }}: + {{ recurringDepositProduct.activeChart.name }} +
+ } +
+ {{ 'labels.inputs.Valid from Date' | translate }}: + {{ recurringDepositProduct.activeChart.fromDate | dateFormat }} +
+ @if (recurringDepositProduct.activeChart.endDate) { +
+ {{ 'labels.inputs.End Date' | translate }}: + {{ recurringDepositProduct.activeChart.endDate | dateFormat }} +
+ } + @if (recurringDepositProduct.activeChart.description) { +
+ {{ 'labels.inputs.Description' | translate }}: + {{ recurringDepositProduct.activeChart.description }} +
+ } +
+ {{ 'labels.inputs.Primary Grouping by Amount' | translate }}: + {{ recurringDepositProduct.activeChart.isPrimaryGroupingByAmount | yesNo }} +
- - - + + + - - - + + + - - + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Payment Type' | translate }}{{ paymentFundSource.paymentType.name }}{{ 'labels.inputs.Period' | translate }} + @if (chartSlab.fromPeriod !== undefined) { +
{{ chartSlab.fromPeriod }} - {{ chartSlab.toPeriod }} {{ chartSlab.periodType.value }}
+ } + @if (chartSlab.fromPeriod === undefined) { +
 
+ } +
{{ 'labels.inputs.Fund Source' | translate }}{{ paymentFundSource.fundSourceAccount.name }}{{ 'labels.inputs.Amount Range' | translate }} + @if (chartSlab.amountRangeFrom !== undefined) { +
{{ chartSlab.amountRangeFrom }} - {{ chartSlab.amountRangeTo }}
+ } + @if (chartSlab.amountRangeFrom === undefined) { +
 
+ } +
{{ 'labels.inputs.Interest' | translate }}{{ chartSlab.annualInterestRate }}{{ 'labels.inputs.Description' | translate }}{{ chartSlab.description }}{{ 'labels.inputs.Actions' | translate }} + + +
+ +

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

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Entity Type' | translate }} + {{ + incentive.entityType + | find: recurringDepositProductTemplate.chartTemplate.entityTypeOptions : 'id' : 'value' + }} + {{ 'labels.inputs.Attribute Name' | translate }} + {{ + incentive.attributeName + | find + : recurringDepositProductTemplate.chartTemplate.attributeNameOptions + : 'id' + : 'value' + }} + {{ 'labels.inputs.Condition Type' | translate }} + {{ + incentive.conditionType + | find + : recurringDepositProductTemplate.chartTemplate.conditionTypeOptions + : 'id' + : 'value' + }} + {{ 'labels.inputs.Attribute Value' | translate }} + @switch (incentive.attributeName) { + @case (2) { + {{ + incentive.attributeValue + | find: recurringDepositProductTemplate.chartTemplate.genderOptions : 'id' : 'name' + }} + } + @case (3) { + {{ incentive.attributeValue }} + } + @case (4) { + {{ + incentive.attributeValue + | find + : recurringDepositProductTemplate.chartTemplate.clientTypeOptions + : 'id' + : 'name' + }} + } + @case (5) { + {{ + incentive.attributeValue + | find + : recurringDepositProductTemplate.chartTemplate.clientClassificationOptions + : 'id' + : 'name' + }} + } + } + {{ 'labels.inputs.Incentive Type' | translate }} + {{ + incentive.incentiveType + | find + : recurringDepositProductTemplate.chartTemplate.incentiveTypeOptions + : 'id' + : 'value' + }} + {{ 'labels.inputs.Amount' | translate }}{{ incentive.amount }}
+
+
+
+
- } - @if (recurringDepositProduct.feeToIncomeAccountMappings?.length) { -
-

{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}

- - - - - - - - - - - -
{{ 'labels.inputs.Fee/Charge' | translate }}{{ feesIncome.charge.name }}{{ 'labels.inputs.Income Account' | translate }}{{ feesIncome.incomeAccount.name }}
-
- } - @if (recurringDepositProduct.penaltyToIncomeAccountMappings?.length) { -
-

{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}

- - - - - - - - - - - -
{{ 'labels.inputs.Penalty' | translate }}{{ penaltyIncome.charge.name }}{{ 'labels.inputs.Income Account' | translate }}{{ penaltyIncome.incomeAccount.name }}
-
- } -
- } +
+ } + + + @if (recurringDepositProduct.charges.length) { +
+

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

+ + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Name' | translate }}{{ charge.name }}{{ 'labels.inputs.Type' | translate }}{{ charge.chargeCalculationType.value }}{{ 'labels.inputs.Amount' | translate }}{{ charge.amount | formatNumber }}{{ 'labels.inputs.Collected On' | translate }}{{ charge.chargeTimeType.value }}
+
+ } + + +

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

+ + + + + + @if ( + recurringDepositProduct.paymentChannelToFundSourceMappings?.length || + recurringDepositProduct.feeToIncomeAccountMappings?.length || + recurringDepositProduct.penaltyToIncomeAccountMappings?.length + ) { +
+

{{ 'labels.inputs.Advanced Accounting Rules' | translate }}

+ + @if (recurringDepositProduct.paymentChannelToFundSourceMappings?.length) { +
+

{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}

+ + + + + + + + + + + +
{{ 'labels.inputs.Payment Type' | translate }}{{ paymentFundSource.paymentType.name }}{{ 'labels.inputs.Fund Source' | translate }}{{ paymentFundSource.fundSourceAccount.name }}
+
+ } + @if (recurringDepositProduct.feeToIncomeAccountMappings?.length) { +
+

{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}

+ + + + + + + + + + + +
{{ 'labels.inputs.Fee/Charge' | translate }}{{ feesIncome.charge.name }}{{ 'labels.inputs.Income Account' | translate }}{{ feesIncome.incomeAccount.name }}
+
+ } + @if (recurringDepositProduct.penaltyToIncomeAccountMappings?.length) { +
+

{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}

+ + + + + + + + + + + +
{{ 'labels.inputs.Penalty' | translate }}{{ penaltyIncome.charge.name }}{{ 'labels.inputs.Income Account' | translate }}{{ penaltyIncome.incomeAccount.name }}
+
+ } +
+ } +
diff --git a/src/app/products/recurring-deposit-products/view-recurring-deposit-product/recurring-deposit-general-tab/recurring-deposit-general-tab.component.scss b/src/app/products/recurring-deposit-products/view-recurring-deposit-product/recurring-deposit-general-tab/recurring-deposit-general-tab.component.scss index 15b7c6e8b..cdee63517 100644 --- a/src/app/products/recurring-deposit-products/view-recurring-deposit-product/recurring-deposit-general-tab/recurring-deposit-general-tab.component.scss +++ b/src/app/products/recurring-deposit-products/view-recurring-deposit-product/recurring-deposit-general-tab/recurring-deposit-general-tab.component.scss @@ -8,6 +8,15 @@ @use 'assets/styles/helper'; @use 'assets/styles/colours' as *; +@use 'assets/styles/product-view' as pv; + +@include pv.product-view-styles; + +/* RD-specific purple accent */ +:host { + --pv-accent: #6d28d9; + --pv-accent-l: #ede9fe; +} .container { width: 100%; diff --git a/src/app/products/saving-products/saving-products.component.html b/src/app/products/saving-products/saving-products.component.html index 874d9891d..2ec3107a5 100644 --- a/src/app/products/saving-products/saving-products.component.html +++ b/src/app/products/saving-products/saving-products.component.html @@ -6,44 +6,174 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
-
- +
+ +
+
+
+
+

Savings Products

+

Manage the savings products available to clients — interest rates, balances and accounting rules.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'product' : 'products' }} +
+ +
-
- -
-
- - {{ 'labels.inputs.Filter' | translate }} - - - + + +
+
+
+
+
{{ totalCount | number }}
+
Total Savings Products
+
+
+
+
+
+
{{ currencyCount | number }}
+
{{ currencyCount === 1 ? 'Currency' : 'Currencies' }} in use
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
-
- - - - - + +
+
+ +
+ + @if (totalCount === 0) { +
+
+

No savings products yet

+

Create your first savings product to make it available for client accounts.

+ +
+ } @else { +
+
{{ 'labels.inputs.Name' | translate }}{{ savingProduct.name }}
+ + + + + + + + + + + + + + + + + + + - - - - + + +
{{ 'labels.inputs.Name' | translate }} +
+
+
+
{{ p.name }}
+ @if (p.currency?.code) { +
+ {{ p.currency.code }}·{{ p.currency.displayLabel || p.currency.name || p.currency.code }} +
+ } +
+
+
{{ 'labels.inputs.Short Name' | translate }} + {{ p.shortName }} + Currency + @if (p.currency?.code) { + {{ p.currency.code }} + } @else { + — + } + Actions + + + {{ 'labels.inputs.Short Name' | translate }}{{ savingProduct.shortName }}
- - - + @if (filteredCount === 0 && totalCount > 0) { +
+ + No products match your filter. +
+ } - + +
+ }
+

{{ 'labels.text.Create Savings Product' | translate }}

{{ 'labels.text.This option allows you to create new savings product.' | translate }}

diff --git a/src/app/products/saving-products/saving-products.component.scss b/src/app/products/saving-products/saving-products.component.scss index 567dcb2f8..a379b31c9 100644 --- a/src/app/products/saving-products/saving-products.component.scss +++ b/src/app/products/saving-products/saving-products.component.scss @@ -6,10 +6,540 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -table { - width: 100%; +:host { + --sp-navy: #0b1e3d; + --sp-blue: #1d4ed8; + --sp-blue-l: #eff6ff; + --sp-teal: #0e7e6b; + --sp-teal-l: #ccfbf1; + --sp-purple: #6d28d9; + --sp-purple-l: #ede9fe; + --sp-amber: #b45309; + --sp-amber-l: #fef3c7; + --sp-green: #15803d; + --sp-green-l: #dcfce7; + --sp-red: #c0392b; + --sp-red-l: #fee2e2; + --sp-g50: #f8fafc; + --sp-g100: #f1f5f9; + --sp-g200: #e2e8f0; + --sp-g500: #64748b; + --sp-g700: #334155; + --sp-g900: #0f172a; - .select-row:hover { - cursor: pointer; + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + background: var(--sp-g50); + color: var(--sp-g900); + font-size: 13.5px; +} + +.sp-root { + padding: 18px 22px; + max-width: 1400px; + margin: 0 auto; +} + +/* ───────── HEADER ───────── */ +.sp-header { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.sp-header-left { + display: flex; + align-items: center; + gap: 14px; + flex: 1; + min-width: 320px; +} + +.sp-header-ic { + width: 48px; + height: 48px; + border-radius: 14px; + background: linear-gradient(135deg, var(--sp-blue) 0%, var(--sp-navy) 100%); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; + box-shadow: + 0 6px 16px rgb(29 78 216 / 25%), + inset 0 1px 0 rgb(255 255 255 / 22%); +} + +.sp-header-text { + h2 { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--sp-g900); + letter-spacing: 0.01em; + } + + p { + margin: 3px 0 0; + font-size: 12px; + color: var(--sp-g500); + line-height: 1.4; } } + +.sp-header-right { + display: flex; + align-items: center; + gap: 12px; +} + +.sp-count-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 14px 6px 12px; + border-radius: 999px; + background: var(--sp-blue-l); + color: var(--sp-blue); + border: 1px solid #dbeafe; + font-size: 12px; + font-weight: 600; + + strong { + font-size: 14px; + font-weight: 700; + color: var(--sp-navy); + } + + span { + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 10.5px; + } +} + +.sp-count-chip-ic { + font-size: 11px; +} + +.sp-create-btn { + display: inline-flex; + align-items: center; + gap: 8px; + height: 38px; + padding: 0 16px; + border-radius: 10px; + background: linear-gradient(135deg, var(--sp-blue) 0%, var(--sp-navy) 100%); + color: #fff; + border: 0; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + box-shadow: + 0 6px 16px rgb(29 78 216 / 30%), + inset 0 1px 0 rgb(255 255 255 / 18%); + transition: all 0.16s ease; + font-family: inherit; + + fa-icon { + font-size: 12px; + } + + &:hover { + transform: translateY(-1px); + box-shadow: + 0 10px 22px rgb(29 78 216 / 38%), + inset 0 1px 0 rgb(255 255 255 / 22%); + } + + &:active { + transform: translateY(0); + } +} + +/* ───────── KPI STRIP ───────── */ +.sp-kpis { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 14px; +} + +@media (width <= 720px) { + .sp-kpis { + grid-template-columns: 1fr; + } +} + +.sp-kpi { + background: #fff; + border: 1px solid var(--sp-g200); + border-radius: 12px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 14px; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); + border-left-width: 4px; +} + +.sp-kpi-ic { + width: 40px; + height: 40px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} + +.sp-kpi-n { + font-size: 20px; + font-weight: 700; + color: var(--sp-g900); + line-height: 1.1; +} + +.sp-kpi-l { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--sp-g500); + margin-top: 4px; +} + +.sp-kpi--blue { + border-left-color: var(--sp-blue); + + .sp-kpi-ic { + background: var(--sp-blue-l); + color: var(--sp-blue); + } +} + +.sp-kpi--teal { + border-left-color: var(--sp-teal); + + .sp-kpi-ic { + background: var(--sp-teal-l); + color: var(--sp-teal); + } +} + +.sp-kpi--purple { + border-left-color: var(--sp-purple); + + .sp-kpi-ic { + background: var(--sp-purple-l); + color: var(--sp-purple); + } +} + +/* ───────── CARD + TOOLBAR ───────── */ +.sp-card { + background: #fff; + border: 1px solid var(--sp-g200); + border-radius: 14px; + overflow: hidden; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); +} + +.sp-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: #fff; + border-bottom: 1px solid var(--sp-g200); +} + +.sp-search { + position: relative; + flex: 1; + max-width: 480px; + + input { + width: 100%; + box-sizing: border-box; + padding: 9px 14px 9px 36px; + border: 1px solid var(--sp-g200); + border-radius: 10px; + background: var(--sp-g50); + font-size: 13px; + color: var(--sp-g900); + font-family: inherit; + + &::placeholder { + color: #94a3b8; + } + + &:focus { + outline: none; + border-color: var(--sp-blue); + background: #fff; + box-shadow: 0 0 0 3px rgb(29 78 216 / 14%); + } + } +} + +.sp-search-ic { + position: absolute; + top: 50%; + left: 13px; + transform: translateY(-50%); + color: #94a3b8; + font-size: 13px; + pointer-events: none; +} + +/* ───────── TABLE ───────── */ +.sp-table-wrap { + overflow-x: auto; +} + +.sp-table { + width: 100%; + background: #fff; + + th.mat-mdc-header-cell { + background: var(--sp-g50); + color: var(--sp-g700); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 12px 16px; + border-bottom: 1px solid var(--sp-g200); + white-space: nowrap; + + &.num { + text-align: right; + } + } + + td.mat-mdc-cell { + padding: 12px 16px; + border-bottom: 1px solid var(--sp-g100); + font-size: 13px; + color: var(--sp-g900); + vertical-align: middle; + + &.num { + text-align: right; + } + } +} + +.sp-row { + cursor: pointer; + transition: background 0.14s ease; + + &:hover { + background: var(--sp-blue-l); + } +} + +/* Product Name cell */ +.sp-product-cell { + display: flex; + align-items: center; + gap: 12px; +} + +.sp-product-icon { + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--sp-blue-l); + color: var(--sp-blue); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; + transition: all 0.14s ease; +} + +.sp-row:hover .sp-product-icon { + background: var(--sp-blue); + color: #fff; + transform: scale(1.04); +} + +.sp-product-meta { + display: flex; + flex-direction: column; + line-height: 1.25; +} + +.sp-product-name { + font-size: 13.5px; + font-weight: 600; + color: var(--sp-g900); +} + +.sp-product-sub { + font-size: 11px; + color: var(--sp-g500); + margin-top: 2px; + + span { + margin: 0 4px; + } +} + +/* Short Name pill */ +.sp-tag { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 3px 9px; + border-radius: 999px; + background: var(--sp-g100); + color: var(--sp-g700); + font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; +} + +/* Currency pill */ +.sp-currency-pill { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 700; + padding: 3px 9px; + border-radius: 999px; + background: var(--sp-teal-l); + color: var(--sp-teal); + border: 1px solid #99f6e4; + font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; +} + +.muted { + color: var(--sp-g500); +} + +/* Action buttons */ +.sp-action-btn { + display: inline-flex; + align-items: center; + gap: 5px; + height: 30px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid; + font-size: 11.5px; + font-weight: 600; + cursor: pointer; + text-decoration: none; + font-family: inherit; + margin-left: 6px; + transition: all 0.14s ease; + + fa-icon { + font-size: 11px; + } + + &:first-child { + margin-left: 0; + } + + &--view { + background: var(--sp-blue-l); + color: var(--sp-blue); + border-color: #dbeafe; + + &:hover { + background: var(--sp-blue); + color: #fff; + border-color: var(--sp-blue); + } + } + + &--edit { + background: var(--sp-amber-l); + color: var(--sp-amber); + border-color: #fde68a; + + &:hover { + background: var(--sp-amber); + color: #fff; + border-color: var(--sp-amber); + } + } +} + +/* No match strip */ +.sp-no-match { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px 16px; + background: var(--sp-g50); + color: var(--sp-g500); + font-size: 13px; + font-weight: 500; + border-top: 1px dashed var(--sp-g200); + + fa-icon { + font-size: 13px; + } +} + +/* ───────── EMPTY STATE ───────── */ +.sp-empty { + text-align: center; + padding: 50px 24px; +} + +.sp-empty-ic { + width: 64px; + height: 64px; + margin: 0 auto 16px; + border-radius: 16px; + background: var(--sp-blue-l); + color: var(--sp-blue); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 26px; +} + +.sp-empty h3 { + margin: 0 0 8px; + font-size: 16px; + font-weight: 700; + color: var(--sp-g900); +} + +.sp-empty p { + margin: 0 0 20px; + font-size: 13px; + color: var(--sp-g500); + line-height: 1.5; + max-width: 420px; + margin-left: auto; + margin-right: auto; +} + +/* ───────── Paginator polish ───────── */ +::ng-deep .sp-table-wrap .mat-mdc-paginator { + background: var(--sp-g50); + border-top: 1px solid var(--sp-g200); + font-family: inherit; +} diff --git a/src/app/products/saving-products/saving-products.component.ts b/src/app/products/saving-products/saving-products.component.ts index 2a69b19ce..8c0afbbb0 100644 --- a/src/app/products/saving-products/saving-products.component.ts +++ b/src/app/products/saving-products/saving-products.component.ts @@ -24,6 +24,7 @@ import { MatRow } from '@angular/material/table'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { MatTooltip } from '@angular/material/tooltip'; /* Custom Services */ import { PopoverService } from '../../configuration-wizard/popover/popover.service'; @@ -50,7 +51,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatHeaderRow, MatRowDef, MatRow, - MatPaginator + MatPaginator, + MatTooltip ] }) export class SavingProductsComponent implements OnInit, AfterViewInit { @@ -59,13 +61,33 @@ export class SavingProductsComponent implements OnInit, AfterViewInit { private configurationWizardService = inject(ConfigurationWizardService); private popoverService = inject(PopoverService); - savingProductsData: any; + savingProductsData: any[] = []; displayedColumns: string[] = [ 'name', - 'shortName' + 'shortName', + 'currency', + 'actions' ]; dataSource: MatTableDataSource; + /** Cached count of rows after the current filter is applied. */ + filteredCount = 0; + + /** Total products as returned by the resolver (filter-independent). */ + get totalCount(): number { + return this.savingProductsData?.length || 0; + } + + /** Number of distinct currencies in the product list. */ + get currencyCount(): number { + const set = new Set(); + for (const p of this.savingProductsData || []) { + const code = p?.currency?.code; + if (code) set.add(code); + } + return set.size; + } + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; @ViewChild(MatSort, { static: true }) sort: MatSort; @@ -94,10 +116,13 @@ export class SavingProductsComponent implements OnInit, AfterViewInit { this.dataSource = new MatTableDataSource(this.savingProductsData); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; + this.filteredCount = this.totalCount; + // Keep the filtered-row count chip live as the user types. + this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); } applyFilter(filterValue: string) { - this.dataSource.filter = filterValue.trim().toLowerCase(); + this.dataSource.filter = (filterValue || '').trim().toLowerCase(); } /** diff --git a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.html b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.html index a36665ab5..b54d8f010 100644 --- a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.html +++ b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.html @@ -1,4 +1,4 @@ - -
- -
- -
-

{{ savingProduct.name }}

- - -

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

- - -
- {{ 'labels.inputs.Short Name' | translate }}: - {{ savingProduct.shortName }} +
+ +
+
+
+
+
Savings Product
+

{{ savingProduct.name }}

+
+ {{ savingProduct.shortName }} + @if (savingProduct.currency?.code) { + {{ savingProduct.currency.code }} + } + @if (savingProduct.accountType) { + {{ savingProduct.accountType }} + } + + + {{ savingProduct.endDate ? 'Inactive' : 'Active' }} + +
+ @if (savingProduct.description) { +

{{ savingProduct.description }}

+ } +
+
+
- @if (savingProduct.description) { -
- {{ 'labels.inputs.Description' | translate }}: - {{ savingProduct.description }} + +
+
+
+

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

- } +
+
+ {{ 'labels.inputs.Short Name' | translate }} + {{ savingProduct.shortName }} +
+ @if (savingProduct.description) { +
+ {{ 'labels.inputs.Description' | translate }} + {{ savingProduct.description }} +
+ } +
+
+ @if ( savingProduct.accountType || savingProduct.customerSegment || @@ -47,513 +80,599 @@ savingProduct.introducerRequired !== undefined || savingProduct.shareLinkingEnabled !== undefined ) { -

Product Classification

- - - - @if (savingProduct.accountType) { -
- Account Type: - {{ savingProduct.accountType }} +
+
+
+

Product Classification

- } - - @if (savingProduct.customerSegment) { -
- Customer Segment: - {{ savingProduct.customerSegment }} -
- } - - @if (savingProduct.glAccountId) { -
- GL Account (Liability): - {{ savingProduct.glAccountName || savingProduct.glAccountId }} -
- } - - @if (savingProduct.effectiveDate) { -
- Effective Date: - {{ savingProduct.effectiveDate | dateFormat }} -
- } - - @if (savingProduct.effectiveTo) { -
- Effective To: - {{ savingProduct.effectiveTo | dateFormat }} -
- } - - @if (savingProduct.endDate) { -
- End Date: - {{ savingProduct.endDate | dateFormat }} -
- } - - @if (savingProduct.introducerRequired !== undefined) { -
- Introducer Required: - {{ - savingProduct.introducerRequired === true || savingProduct.introducerRequired === 'Yes' ? 'Yes' : 'No' - }} -
- } - - @if (savingProduct.shareLinkingEnabled !== undefined) { -
- Share Linking Enabled: - {{ savingProduct.shareLinkingEnabled | yesNo }} -
- } - } - -

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

- - - -
- {{ 'labels.inputs.Currency' | translate }}: - {{ savingProduct.currency.name }} -
- -
- {{ 'labels.inputs.Decimal Places' | translate }}: - {{ savingProduct.currency.decimalPlaces }} -
- - @if (savingProduct.currency.inMultiplesOf !== undefined && savingProduct.currency.inMultiplesOf !== null) { -
- {{ 'labels.inputs.Currency in multiples of' | translate }}: - {{ savingProduct.currency.inMultiplesOf }} -
- } - - @if (savingProduct.dormancyAlert !== undefined) { -
- Dormancy Alert: - {{ savingProduct.dormancyAlert | yesNo }} -
- } - - @if (savingProduct.nominationFacility !== undefined) { -
- Nomination Facility: - {{ savingProduct.nominationFacility | yesNo }} -
- } - -

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

- - - -
- {{ 'labels.inputs.Nominal Annual Interest' | translate }}: - {{ savingProduct.nominalAnnualInterestRate | formatNumber }} % -
- -
- {{ 'labels.inputs.Interest Compounding Period' | translate }}: - {{ savingProduct.interestCompoundingPeriodType.value | translateKey: 'catalogs' }} -
- -
- {{ 'labels.inputs.Interest Posting Period' | translate }}: - {{ savingProduct.interestPostingPeriodType.value | translateKey: 'catalogs' }} -
- - @if (savingProduct.interestCalculationTypeName) { -
- {{ 'labels.inputs.Interest Calculation Type' | translate }}: - {{ savingProduct.interestCalculationTypeName }} -
- } - -
- {{ 'labels.inputs.Interest Calculated using' | translate }}: - {{ savingProduct.interestCalculationType.value | translateKey: 'catalogs' }} -
- -
- {{ 'labels.inputs.Days in Year' | translate }}: - {{ savingProduct.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }} -
- - @if (savingProduct.effectiveDate) { -
- Effective Date: - {{ savingProduct.effectiveDate | dateFormat }} -
- } - -
- Interest Calculate: - {{ savingProduct.interestCalculationType?.value | translateKey: 'catalogs' }} -
- - @if (savingProduct.averagePeriod) { -
- Average Period: - {{ savingProduct.averagePeriod }} -
- } - -
- Auto Post: - {{ savingProduct.autoPost | yesNo }} -
- - @if (savingProduct.frequencyFixDay) { -
- Frequency Fix Day: - {{ savingProduct.frequencyFixDay }} -
- } - - @if (savingProduct.lastInterestDate) { -
- Last Interest Date: - {{ savingProduct.lastInterestDate | dateFormat }} -
- } - -

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

- - - - @if (savingProduct.minRequiredOpeningBalance) { -
- {{ 'labels.inputs.Minimum Opening Balance' | translate }}: - {{ savingProduct.minRequiredOpeningBalance | formatNumber }} -
- } - - @if (savingProduct.lockinPeriodFrequency) { -
- {{ 'labels.inputs.Lock-in Period' | translate }}: - {{ - savingProduct.lockinPeriodFrequency + ' ' + savingProduct.lockinPeriodFrequencyType.value - }} -
- } - -
- {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}: - {{ savingProduct.withdrawalFeeForTransfers | yesNo }} -
- - @if (savingProduct.balanceFrequency) { -
- Balance Frequency: - {{ savingProduct.balanceFrequency }} -
- } - -
- Enable Lock-in Period: - {{ savingProduct.enableLockinPeriod | yesNo }} -
- - @if (savingProduct.minBalanceForInterestCalculation) { -
- {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}: - {{ savingProduct.minBalanceForInterestCalculation | formatNumber }} -
- } - -
- {{ 'labels.inputs.Enforce Minimum Balance' | translate }}: - {{ savingProduct.enforceMinRequiredBalance | yesNo }} -
- - @if (savingProduct.minRequiredBalance) { -
- {{ 'labels.inputs.Minimum Balance' | translate }}: - {{ savingProduct.minRequiredBalance | formatNumber }} -
- } - -
- {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}: - {{ savingProduct.withHoldTax | yesNo }} -
- - @if (savingProduct.withHoldTax) { -
- {{ 'labels.inputs.Tax Group' | translate }}: - {{ savingProduct.taxGroup.name }} -
- } - -
- {{ 'labels.inputs.Is Overdraft Allowed' | translate }}: - {{ savingProduct.allowOverdraft | yesNo }} -
- -
- Account Closed Zero Balance in Day Closing: - {{ savingProduct.accountClosedZeroBalanceInDayClosing | yesNo }} -
- - @if (savingProduct.minOverdraftForInterestCalculation) { -
- {{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}: - {{ savingProduct.minOverdraftForInterestCalculation | formatNumber }} -
- } - - @if (savingProduct.nominalAnnualInterestRateOverdraft) { -
- {{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}: - {{ savingProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % -
- } - - @if (savingProduct.overdraftLimit) { -
- {{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}: - {{ savingProduct.overdraftLimit | formatNumber }} -
- } - -
- {{ 'labels.inputs.Enable Dormancy Tracking' | translate }}: - {{ savingProduct.isDormancyTrackingActive | yesNo }} -
- - @if (savingProduct.isDormancyTrackingActive) { -
- {{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}: - {{ savingProduct.daysToInactive }} - {{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}: - {{ savingProduct.daysToDormancy }} - {{ 'labels.inputs.Number of Days to Escheat' | translate }}: - {{ savingProduct.daysToEscheat }} -
- } - -

Account Facilities

- - - @if (savingProduct.accountFacilityConfig) { -
- @for (feature of accountFacilityFeatureItems; track feature.key) { - @if (savingProduct.accountFacilityConfig[feature.key] !== undefined) { -
- {{ feature.label }}: - {{ savingProduct.accountFacilityConfig[feature.key] | yesNo }} +
+ @if (savingProduct.accountType) { +
+ Account Type + {{ savingProduct.accountType }}
} + @if (savingProduct.customerSegment) { +
+ Customer Segment + {{ savingProduct.customerSegment }} +
+ } + @if (savingProduct.glAccountId) { +
+ GL Account (Liability) + {{ savingProduct.glAccountName || savingProduct.glAccountId }} +
+ } + @if (savingProduct.effectiveDate) { +
+ Effective Date + {{ savingProduct.effectiveDate | dateFormat }} +
+ } + @if (savingProduct.effectiveTo) { +
+ Effective To + {{ savingProduct.effectiveTo | dateFormat }} +
+ } + @if (savingProduct.endDate) { +
+ End Date + {{ savingProduct.endDate | dateFormat }} +
+ } + @if (savingProduct.introducerRequired !== undefined) { +
+ Introducer Required + + + {{ + savingProduct.introducerRequired === true || savingProduct.introducerRequired === 'Yes' ? 'Yes' : 'No' + }} + + +
+ } + @if (savingProduct.shareLinkingEnabled !== undefined) { +
+ Share Linking Enabled + + + {{ savingProduct.shareLinkingEnabled | yesNo }} + + +
+ } +
+
+ } + + +
+
+
+

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

+
+
+
+ {{ 'labels.inputs.Currency' | translate }} + {{ savingProduct.currency.name }} +
+
+ {{ 'labels.inputs.Decimal Places' | translate }} + {{ savingProduct.currency.decimalPlaces }} +
+ @if (savingProduct.currency.inMultiplesOf !== undefined && savingProduct.currency.inMultiplesOf !== null) { +
+ {{ 'labels.inputs.Currency in multiples of' | translate }} + {{ savingProduct.currency.inMultiplesOf }} +
+ } + @if (savingProduct.dormancyAlert !== undefined) { +
+ Dormancy Alert + {{ savingProduct.dormancyAlert | yesNo }} +
+ } + @if (savingProduct.nominationFacility !== undefined) { +
+ Nomination Facility + {{ savingProduct.nominationFacility | yesNo }} +
}
- } +
- @if (savingProduct.accountFacilityLimits?.length) { -
-

Account Facility Limits

- - - - - - - - - - - - - - - - - - - - -
Limit Type{{ row.limitType }}General Limit{{ row.generalLimit }}Minor Account Limit{{ row.minorAccountLimit }}Small Saving Limit{{ row.smallSavingLimit }}
+ +
+
+
+

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

- } - - @if (savingProduct.depositLimitKyc) { -
-

Deposit Limit (KYC)

-
- General: - {{ savingProduct.depositLimitKyc.general.kycType }} - {{ savingProduct.depositLimitKyc.general.limit }} +
+ {{ 'labels.inputs.Nominal Annual Interest' | translate }} + {{ savingProduct.nominalAnnualInterestRate | formatNumber }}%
-
- Minor: - {{ savingProduct.depositLimitKyc.minor.kycType }} - {{ savingProduct.depositLimitKyc.minor.limit }} +
+ {{ 'labels.inputs.Interest Compounding Period' | translate }} + {{ savingProduct.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}
-
- Small Saving: - {{ savingProduct.depositLimitKyc.smallSaving.kycType }} - - {{ savingProduct.depositLimitKyc.smallSaving.limit }} +
+ {{ 'labels.inputs.Interest Posting Period' | translate }} + {{ savingProduct.interestPostingPeriodType.value | translateKey: 'catalogs' }}
+ @if (savingProduct.interestCalculationTypeName) { +
+ {{ 'labels.inputs.Interest Calculation Type' | translate }} + {{ savingProduct.interestCalculationTypeName }} +
+ } +
+ {{ 'labels.inputs.Interest Calculated using' | translate }} + {{ savingProduct.interestCalculationType.value | translateKey: 'catalogs' }} +
+
+ {{ 'labels.inputs.Days in Year' | translate }} + {{ + savingProduct.interestCalculationDaysInYearType.value | translateKey: 'catalogs' + }} +
+ @if (savingProduct.effectiveDate) { +
+ Effective Date + {{ savingProduct.effectiveDate | dateFormat }} +
+ } + @if (savingProduct.averagePeriod) { +
+ Average Period + {{ savingProduct.averagePeriod }} +
+ } +
+ Auto Post + + + {{ savingProduct.autoPost | yesNo }} + + +
+ @if (savingProduct.frequencyFixDay) { +
+ Frequency Fix Day + {{ savingProduct.frequencyFixDay }} +
+ } + @if (savingProduct.lastInterestDate) { +
+ Last Interest Date + {{ savingProduct.lastInterestDate | dateFormat }} +
+ }
+
+ + +
+
+
+

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

+
+
+ @if (savingProduct.minRequiredOpeningBalance) { +
+ {{ 'labels.inputs.Minimum Opening Balance' | translate }} + {{ savingProduct.minRequiredOpeningBalance | formatNumber }} +
+ } + @if (savingProduct.lockinPeriodFrequency) { +
+ {{ 'labels.inputs.Lock-in Period' | translate }} + {{ + savingProduct.lockinPeriodFrequency + ' ' + savingProduct.lockinPeriodFrequencyType.value + }} +
+ } +
+ {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }} + + + {{ savingProduct.withdrawalFeeForTransfers | yesNo }} + + +
+ @if (savingProduct.balanceFrequency) { +
+ Balance Frequency + {{ savingProduct.balanceFrequency }} +
+ } +
+ Enable Lock-in Period + + + {{ savingProduct.enableLockinPeriod | yesNo }} + + +
+ @if (savingProduct.minBalanceForInterestCalculation) { +
+ {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }} + {{ savingProduct.minBalanceForInterestCalculation | formatNumber }} +
+ } +
+ {{ 'labels.inputs.Enforce Minimum Balance' | translate }} + + + {{ savingProduct.enforceMinRequiredBalance | yesNo }} + + +
+ @if (savingProduct.minRequiredBalance) { +
+ {{ 'labels.inputs.Minimum Balance' | translate }} + {{ savingProduct.minRequiredBalance | formatNumber }} +
+ } +
+ {{ 'labels.inputs.Withhold Tax is Applicable' | translate }} + + + {{ savingProduct.withHoldTax | yesNo }} + + +
+ @if (savingProduct.withHoldTax) { +
+ {{ 'labels.inputs.Tax Group' | translate }} + {{ savingProduct.taxGroup.name }} +
+ } +
+ {{ 'labels.inputs.Is Overdraft Allowed' | translate }} + + + {{ savingProduct.allowOverdraft | yesNo }} + + +
+
+ Account Closed Zero Balance in Day Closing + + + {{ savingProduct.accountClosedZeroBalanceInDayClosing | yesNo }} + + +
+ @if (savingProduct.minOverdraftForInterestCalculation) { +
+ {{ + 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate + }} + {{ savingProduct.minOverdraftForInterestCalculation | formatNumber }} +
+ } + @if (savingProduct.nominalAnnualInterestRateOverdraft) { +
+ {{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }} + {{ savingProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % +
+ } + @if (savingProduct.overdraftLimit) { +
+ {{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }} + {{ savingProduct.overdraftLimit | formatNumber }} +
+ } +
+ {{ 'labels.inputs.Enable Dormancy Tracking' | translate }} + + + {{ savingProduct.isDormancyTrackingActive | yesNo }} + + +
+ @if (savingProduct.isDormancyTrackingActive) { +
+ {{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }} + {{ savingProduct.daysToInactive }} +
+
+ {{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }} + {{ savingProduct.daysToDormancy }} +
+
+ {{ 'labels.inputs.Number of Days to Escheat' | translate }} + {{ savingProduct.daysToEscheat }} +
+ } +
+
+ + + @if ( + savingProduct.accountFacilityConfig || savingProduct.accountFacilityLimits?.length || savingProduct.depositLimitKyc + ) { +
+
+
+

Account Facilities

+
+ + @if (savingProduct.accountFacilityConfig) { +
+ @for (feature of accountFacilityFeatureItems; track feature.key) { + @if (savingProduct.accountFacilityConfig[feature.key] !== undefined) { +
+ + {{ feature.label }} +
+ } + } +
+ } + + @if (savingProduct.accountFacilityLimits?.length) { +

Account Facility Limits

+
+ + + + + + + + + + + + + + + + + + + +
Limit Type{{ row.limitType }}General Limit{{ row.generalLimit }}Minor Account Limit{{ row.minorAccountLimit }}Small Saving Limit{{ row.smallSavingLimit }}
+
+ } + + @if (savingProduct.depositLimitKyc) { +

Deposit Limit (KYC)

+
+
+ General + {{ savingProduct.depositLimitKyc.general.kycType }} · + {{ savingProduct.depositLimitKyc.general.limit }} +
+
+ Minor + {{ savingProduct.depositLimitKyc.minor.kycType }} · {{ savingProduct.depositLimitKyc.minor.limit }} +
+
+ Small Saving + {{ savingProduct.depositLimitKyc.smallSaving.kycType }} · + {{ savingProduct.depositLimitKyc.smallSaving.limit }} +
+
+ } +
} + @if (savingProduct.charges.length) { -
-

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

- - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Name' | translate }} - {{ charge.name + ', ' + charge.currency.displaySymbol }} - {{ 'labels.inputs.Type' | translate }} - {{ charge.chargeCalculationType.value | translateKey: 'catalogs' }} - {{ 'labels.inputs.Amount' | translate }} - {{ charge.amount | formatNumber }} - {{ 'labels.inputs.Collected On' | translate }} - {{ charge.chargeTimeType.value | translateKey: 'catalogs' }} -
-
+
+
+
+

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

+
+
+ + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Name' | translate }}{{ charge.name + ', ' + charge.currency.displaySymbol }}{{ 'labels.inputs.Type' | translate }} + {{ charge.chargeCalculationType.value | translateKey: 'catalogs' }} + {{ 'labels.inputs.Amount' | translate }}{{ charge.amount | formatNumber }}{{ 'labels.inputs.Collected On' | translate }}{{ charge.chargeTimeType.value | translateKey: 'catalogs' }}
+
+
} -

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

- - - - - + +
+
+
+

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

+
+ +
+ @if (savingProduct.otherGlMappingsMap) { -
-

Other GL Mappings

- +
+
+
+

Other GL Mappings

+
@if (savingProduct.otherGlMappingsMap.assets?.length) { -

Assets

- - - - - - - - - - - -
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
+

Assets

+
+ + + + + + + + + + + +
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
+
} - @if (savingProduct.otherGlMappingsMap.liabilities?.length) { -

Liabilities

- - - - - - - - - - - -
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
+

Liabilities

+
+ + + + + + + + + + + +
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
+
} - @if (savingProduct.otherGlMappingsMap.expenses?.length) { -

Expenses

- - - - - - - - - - - -
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
+

Expenses

+
+ + + + + + + + + + + +
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
+
} - @if (savingProduct.otherGlMappingsMap.income?.length) { -

Income

- - - - - - - - - - - -
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
+

Income

+
+ + + + + + + + + + + +
Account Name{{ row.accountName }}GL Account{{ row.glCode || row.glAccountId }}
+
} -
+ } + @if ( savingProduct.paymentChannelToFundSourceMappings?.length || savingProduct.feeToIncomeAccountMappings?.length || savingProduct.penaltyToIncomeAccountMappings?.length ) { -
-

{{ 'labels.inputs.Advanced Accounting Rules' | translate }}

- +
+
+
+

{{ 'labels.inputs.Advanced Accounting Rules' | translate }}

+
+ @if (savingProduct.paymentChannelToFundSourceMappings?.length) { -
-

{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}

- +

{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}

+
+
- + - + @@ -561,18 +680,16 @@ } @if (savingProduct.feeToIncomeAccountMappings?.length) { -
-

{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}

-
{{ 'labels.inputs.Payment Type' | translate }} - {{ paymentFundSource.paymentType.name }} - {{ p.paymentType.name }} {{ 'labels.inputs.Fund Source' | translate }} - ({{ paymentFundSource.fundSourceAccount.glCode }}) {{ paymentFundSource.fundSourceAccount.name }} - ({{ p.fundSourceAccount.glCode }}) {{ p.fundSourceAccount.name }}
+

{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}

+
+
- + - + @@ -580,30 +697,22 @@ } @if (savingProduct.penaltyToIncomeAccountMappings?.length) { -
-

{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}

-
{{ 'labels.inputs.Fee/Charge' | translate }} - {{ fee.charge.name }} - {{ f.charge.name }} {{ 'labels.inputs.Income Account' | translate }}({{ fee.incomeAccount.glCode }}) {{ fee.incomeAccount.name }}({{ f.incomeAccount.glCode }}) {{ f.incomeAccount.name }}
+

{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}

+
+
- + - +
{{ 'labels.inputs.Penalty' | translate }} - {{ penalty.charge.name }} - {{ p.charge.name }} {{ 'labels.inputs.Income Account' | translate }} - ({{ penalty.incomeAccount.glCode }}) {{ penalty.incomeAccount.name }} - ({{ p.incomeAccount.glCode }}) {{ p.incomeAccount.name }}
} -
+ }
diff --git a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.scss b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.scss index 84df78f65..87d33be07 100644 --- a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.scss +++ b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.scss @@ -6,160 +6,443 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -@use 'assets/styles/helper'; -@use 'assets/styles/colours' as *; +:host { + --vsp-navy: #0b1e3d; + --vsp-blue: #1d4ed8; + --vsp-blue-l: #eff6ff; + --vsp-teal: #0e7e6b; + --vsp-teal-l: #ccfbf1; + --vsp-purple: #6d28d9; + --vsp-purple-l: #ede9fe; + --vsp-indigo: #3730a3; + --vsp-indigo-l: #e0e7ff; + --vsp-amber: #b45309; + --vsp-amber-l: #fef3c7; + --vsp-green: #15803d; + --vsp-green-l: #dcfce7; + --vsp-red: #c0392b; + --vsp-red-l: #fee2e2; + --vsp-pink: #be185d; + --vsp-pink-l: #fce7f3; + --vsp-g50: #f8fafc; + --vsp-g100: #f1f5f9; + --vsp-g200: #e2e8f0; + --vsp-g500: #64748b; + --vsp-g700: #334155; + --vsp-g900: #0f172a; -.tab-container { - padding: 1%; - margin: 1%; + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: var(--vsp-g900); + font-size: 13.5px; } -.mat-h3 { - margin-top: 10px !important; - font-weight: 300; - color: $status-approved; -} - -.interestCarts { - float: right; -} - -h2, -h3, -h4 { - margin: 0; - font-weight: 500; -} - -table { - width: 100%; - - tr.incentives-row { - height: 0; - - > td { - padding: 0; - } - - .incentives { - overflow: hidden; - } - - .mat-card { - border-radius: 0; - - > h4 { - font-weight: 500; - margin: 0.5em 0 0; - } - } - } -} - -.mat-elevation-z1 { - margin: 1em 0 1.5em; -} - -span { - margin: 0.5em 0; -} - -mat-divider { - margin: 0 0 0.5em; -} - -.margin-t { - margin-top: 1em; -} - -.spa-gl-summary, -.spa-other-gl-summary { +.vsp-root { + padding: 4px 4px 24px; display: flex; flex-direction: column; - gap: 0.9rem; - margin-top: 1.25rem; + gap: 14px; } -.spa-gl-summary__header { +/* ───────── HERO ───────── */ +.vsp-hero { + background: linear-gradient(135deg, var(--vsp-navy) 0%, var(--vsp-blue) 100%); + border-radius: 14px; + padding: 22px 22px 22px 18px; display: flex; - align-items: center; - justify-content: space-between; - padding: 0.25rem 0 0.5rem; -} - -.spa-gl-section { - display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 1rem 1.15rem; - border: 1px solid #e3e7f2; - border-radius: 12px; - background: #fff; -} - -.spa-gl-section h5 { - font-size: 0.9rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: #91a0bc; -} - -.spa-gl-row { - display: grid; - grid-template-columns: minmax(220px, 40%) 1fr; - gap: 1rem; - align-items: baseline; - padding: 0.2rem 0; -} - -.spa-gl-row__label { - color: #1f2f4a; - font-weight: 500; -} - -.spa-gl-row__value { - color: #101828; - font-weight: 600; - word-break: break-word; -} - -.spa-other-gl-table { - margin-top: 0.5rem; - margin-bottom: 0.75rem; - table-layout: fixed; - width: 100%; -} - -.spa-other-gl-table:first-of-type { - margin-top: 0.25rem; -} - -.spa-other-gl-summary .mat-elevation-z1 { - border-radius: 10px; + align-items: flex-start; + gap: 18px; + box-shadow: + 0 10px 24px rgb(11 30 61 / 18%), + 0 4px 10px rgb(11 30 61 / 12%); + color: #fff; + position: relative; overflow: hidden; } -.spa-other-gl-table th.mat-mdc-header-cell, -.spa-other-gl-table td.mat-mdc-cell { - padding-left: 1.25rem; - padding-right: 1.25rem; +.vsp-hero::before { + content: ''; + position: absolute; + top: -60px; + right: -60px; + width: 240px; + height: 240px; + border-radius: 50%; + background: radial-gradient(circle, rgb(255 255 255 / 8%) 0%, transparent 70%); + pointer-events: none; } -.spa-other-gl-table th.mat-mdc-header-cell:first-child, -.spa-other-gl-table td.mat-mdc-cell:first-child { - width: 38%; +.vsp-hero-left { + display: flex; + align-items: flex-start; + gap: 16px; + flex: 1; + min-width: 0; } -.spa-other-gl-table th.mat-mdc-header-cell:last-child, -.spa-other-gl-table td.mat-mdc-cell:last-child { - width: 62%; - word-break: break-word; +.vsp-hero-icon { + width: 56px; + height: 56px; + border-radius: 14px; + background: rgb(255 255 255 / 16%); + border: 1px solid rgb(255 255 255 / 22%); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 24px; + flex-shrink: 0; } -@media (width <= 768px) { - .spa-gl-row { +.vsp-hero-text { + flex: 1; + min-width: 0; +} + +.vsp-hero-eyebrow { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: rgb(255 255 255 / 70%); + margin-bottom: 4px; +} + +.vsp-hero-title { + margin: 0 0 10px; + font-size: 22px; + font-weight: 700; + letter-spacing: 0.01em; + color: #fff; + line-height: 1.2; +} + +.vsp-hero-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.vsp-hero-desc { + margin: 12px 0 0; + font-size: 13px; + color: rgb(255 255 255 / 82%); + line-height: 1.5; + max-width: 720px; +} + +.vsp-edit-btn { + display: inline-flex; + align-items: center; + gap: 8px; + height: 38px; + padding: 0 16px; + border-radius: 10px; + background: rgb(255 255 255 / 14%); + color: #fff; + border: 1px solid rgb(255 255 255 / 28%); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + font-family: inherit; + flex-shrink: 0; + transition: all 0.16s ease; + + fa-icon { + font-size: 12px; + } + + &:hover { + background: rgb(255 255 255 / 24%); + border-color: rgb(255 255 255 / 50%); + } +} + +/* ───────── PILLS ───────── */ +.vsp-pill { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 4px 10px; + border-radius: 999px; + font-family: inherit; + + fa-icon { + font-size: 9.5px; + } + + &--code { + background: rgb(255 255 255 / 18%); + color: #fff; + border: 1px solid rgb(255 255 255 / 24%); + font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; + } + + &--teal { + background: var(--vsp-teal-l); + color: var(--vsp-teal); + border: 1px solid #99f6e4; + } + + &--blue { + background: var(--vsp-blue-l); + color: var(--vsp-blue); + border: 1px solid #dbeafe; + } + + &--green { + background: var(--vsp-green-l); + color: var(--vsp-green); + border: 1px solid #bbf7d0; + } + + &--amber { + background: var(--vsp-amber-l); + color: var(--vsp-amber); + border: 1px solid #fde68a; + } + + &--red { + background: var(--vsp-red-l); + color: var(--vsp-red); + border: 1px solid #fecaca; + } + + &--gray { + background: var(--vsp-g100); + color: var(--vsp-g700); + border: 1px solid var(--vsp-g200); + } +} + +/* ───────── SECTION ───────── */ +.vsp-section { + background: #fff; + border: 1px solid var(--vsp-g200); + border-radius: 12px; + padding: 16px 20px; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); +} + +.vsp-section-h { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px dashed var(--vsp-g200); + + h3 { + margin: 0; + font-size: 14px; + font-weight: 700; + color: var(--vsp-g900); + letter-spacing: 0.02em; + } +} + +.vsp-section-ic { + width: 36px; + height: 36px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; + + &--blue { + background: var(--vsp-blue-l); + color: var(--vsp-blue); + } + + &--teal { + background: var(--vsp-teal-l); + color: var(--vsp-teal); + } + + &--purple { + background: var(--vsp-purple-l); + color: var(--vsp-purple); + } + + &--indigo { + background: var(--vsp-indigo-l); + color: var(--vsp-indigo); + } + + &--amber { + background: var(--vsp-amber-l); + color: var(--vsp-amber); + } + + &--green { + background: var(--vsp-green-l); + color: var(--vsp-green); + } + + &--red { + background: var(--vsp-red-l); + color: var(--vsp-red); + } + + &--pink { + background: var(--vsp-pink-l); + color: var(--vsp-pink); + } +} + +.vsp-sub-h { + margin: 16px 0 10px; + font-size: 12px; + font-weight: 700; + color: var(--vsp-g700); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +/* ───────── KEY/VALUE GRID ───────── */ +.vsp-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 24px; +} + +.vsp-grid--3 { + grid-template-columns: repeat(3, 1fr); +} + +@media (width <= 900px) { + .vsp-grid, + .vsp-grid--3 { + grid-template-columns: 1fr 1fr; + } +} + +@media (width <= 600px) { + .vsp-grid, + .vsp-grid--3 { grid-template-columns: 1fr; - gap: 0.35rem; + } +} + +.vsp-kv { + display: flex; + flex-direction: column; + gap: 3px; + padding: 8px 0; + border-bottom: 1px dashed var(--vsp-g100); + + &--span { + grid-column: 1 / -1; + } +} + +.vsp-k { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--vsp-g500); +} + +.vsp-v { + font-size: 13.5px; + color: var(--vsp-g900); + font-weight: 500; + word-break: break-word; + + strong { + font-weight: 700; + } +} + +.vsp-v--accent { + font-size: 18px; + font-weight: 700; + color: var(--vsp-blue); + letter-spacing: 0.01em; +} + +.vsp-suffix { + font-size: 12px; + font-weight: 600; + color: var(--vsp-g500); + margin-left: 4px; +} + +/* ───────── FEATURE GRID (account facilities) ───────── */ +.vsp-feature { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + background: var(--vsp-g50); + border: 1px solid var(--vsp-g200); + font-size: 12.5px; + font-weight: 600; + color: var(--vsp-g700); + + fa-icon { + font-size: 12px; + color: var(--vsp-g500); + } + + &.is-on { + background: var(--vsp-green-l); + border-color: #bbf7d0; + color: var(--vsp-green); + + fa-icon { + color: var(--vsp-green); + } + } +} + +/* ───────── TABLES ───────── */ +.vsp-table-wrap { + overflow-x: auto; + border: 1px solid var(--vsp-g200); + border-radius: 10px; + margin-top: 4px; +} + +.vsp-table { + width: 100%; + background: #fff; + + th.mat-mdc-header-cell { + background: var(--vsp-g50); + color: var(--vsp-g700); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 11px 14px; + border-bottom: 1px solid var(--vsp-g200); + white-space: nowrap; + } + + td.mat-mdc-cell { + padding: 10px 14px; + border-bottom: 1px solid var(--vsp-g100); + font-size: 12.5px; + color: var(--vsp-g900); + } + + tbody tr:last-child td.mat-mdc-cell { + border-bottom: 0; } } diff --git a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.ts b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.ts index 8e7b1efa1..f74717eb9 100644 --- a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.ts +++ b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.ts @@ -10,7 +10,6 @@ import { Component, inject } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { Accounting } from 'app/core/utils/accounting'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { MatDivider } from '@angular/material/divider'; import { MatTable, MatColumnDef, @@ -36,7 +35,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, - MatDivider, MatTable, MatColumnDef, MatHeaderCellDef, diff --git a/src/app/products/share-products/share-products.component.html b/src/app/products/share-products/share-products.component.html index 11730928b..c44b89651 100644 --- a/src/app/products/share-products/share-products.component.html +++ b/src/app/products/share-products/share-products.component.html @@ -6,51 +6,164 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
-
- -
-
- -
-
- - {{ 'labels.inputs.Filter' | translate }} - - - +
+
+
+
+
+

{{ 'labels.heading.Share Products' | translate }}

+

Manage share / equity products — capital pools and dividend rules.

+
+
+
+
+ + {{ totalCount }} + {{ totalCount === 1 ? 'product' : 'products' }} +
+ +
-
- - - - - +
+
+
+
+
{{ totalCount | number }}
+
Total Share Products
+
+
+
+
+
+
{{ totalSharesAggregate | number }}
+
Total Shares (all products)
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
- - - - +
+
+ +
- -
- - + @if (totalCount === 0) { +
+
+

No share products yet

+

Create your first share product to enable share-capital subscriptions.

+ +
+ } @else { +
+
{{ 'labels.inputs.Name' | translate }}{{ shareProduct.name }}{{ 'labels.inputs.Short Name' | translate }}{{ shareProduct.shortName }}{{ 'labels.inputs.Total Shares' | translate }}{{ shareProduct.totalShares }}
+ + + + - - -
{{ 'labels.inputs.Name' | translate }} +
+
+
+
{{ p.name }}
+ @if (p.currency?.code) { +
{{ p.currency.code }}
+ } +
+
+
+ + {{ 'labels.inputs.Short Name' | translate }} + + {{ p.shortName }} + + - + + + {{ 'labels.inputs.Total Shares' | translate }} + + + {{ p.totalShares | number }} + + + + + Actions + + + + + + + + + + + @if (filteredCount === 0 && totalCount > 0) { +
+ + No products match your filter. +
+ } + + +
+ }
-

{{ 'labels.buttons.Create Share Product' | translate }}

+

{{ 'labels.heading.Create Share Product' | translate }}

{{ 'labels.text.This option allows you to create new share product.' | translate }}

- -
- -
- - -

{{ shareProduct.name }}

- - - -

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

- - - -
- {{ 'labels.inputs.Short Name' | translate }}: - {{ shareProduct.shortName }} -
- - @if (shareProduct.description) { -
- {{ 'labels.inputs.Description' | translate }}: - {{ shareProduct.description }} +
+ +
+
+
+
+
Share Product
+

{{ shareProduct.name }}

+
+ {{ shareProduct.shortName }} + @if (shareProduct.currency?.code) { + {{ shareProduct.currency.code }} + } + @if (shareProduct.totalShares !== undefined && shareProduct.totalShares !== null) { + {{ shareProduct.totalShares | number }} shares + } +
+ @if (shareProduct.description) { +

{{ shareProduct.description }}

+ } +
- } - -

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

- - - -
- {{ 'labels.inputs.Currency' | translate }}: - {{ shareProduct.currency.name }} -
- -
- {{ 'labels.inputs.Decimal Places' | translate }}: - {{ shareProduct.currency.decimalPlaces }} -
- -
- {{ 'labels.inputs.Currency in multiples of' | translate }}: - {{ shareProduct.currency.inMultiplesOf }} -
- -

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

- - - -
- {{ 'labels.inputs.Total Number of Shares' | translate }}: - {{ shareProduct.totalShares | formatNumber }} -
- -
- {{ 'labels.inputs.Shares to be Issued' | translate }}: - {{ shareProduct.totalSharesIssued | formatNumber }} -
- -
- {{ 'labels.inputs.Nominal/Unit Price' | translate }}: - {{ shareProduct.unitPrice | formatNumber }} -
- -
- {{ 'labels.inputs.Capital Value' | translate }}: - {{ shareProduct.shareCapital | formatNumber }} -
- -

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

- - - -
- {{ 'labels.inputs.Shares per Client' | translate }}: - {{ - shareProduct.nominalShares + - ' (Min: ' + - (shareProduct.minimumShares ? shareProduct.minimumShares : shareProduct.nominalShares) + - ', Max: ' + - (shareProduct.maximumShares ? shareProduct.maximumShares : shareProduct.nominalShares) + - ')' - }} -
- - @if (shareProduct.minimumActivePeriod) { -
- {{ 'labels.inputs.Minimum Active Period' | translate }}: - {{ shareProduct.minimumActivePeriod }} - {{ shareProduct.minimumActivePeriodForDividendsTypeEnum.value | translateKey: 'catalogs' }} + +
- } +
+ +
+

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

+ + - @if (shareProduct.lockinPeriod) {
- {{ 'labels.inputs.Lock-in Period' | translate }}: - {{ shareProduct.lockinPeriod }} {{ shareProduct.lockPeriodTypeEnum.value | translateKey: 'catalogs' }} + {{ 'labels.inputs.Short Name' | translate }}: + {{ shareProduct.shortName }}
- } -
- {{ 'labels.inputs.Allow dividends for inactive clients' | translate }}: - {{ shareProduct.allowDividendCalculationForInactiveClients | yesNo }} + @if (shareProduct.description) { +
+ {{ 'labels.inputs.Description' | translate }}: + {{ shareProduct.description }} +
+ } + +

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

+ + + +
+ {{ 'labels.inputs.Currency' | translate }}: + {{ shareProduct.currency.name }} +
+ +
+ {{ 'labels.inputs.Decimal Places' | translate }}: + {{ shareProduct.currency.decimalPlaces }} +
+ +
+ {{ 'labels.inputs.Currency in multiples of' | translate }}: + {{ shareProduct.currency.inMultiplesOf }} +
+ +

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

+ + + +
+ {{ 'labels.inputs.Total Number of Shares' | translate }}: + {{ shareProduct.totalShares | formatNumber }} +
+ +
+ {{ 'labels.inputs.Shares to be Issued' | translate }}: + {{ shareProduct.totalSharesIssued | formatNumber }} +
+ +
+ {{ 'labels.inputs.Nominal/Unit Price' | translate }}: + {{ shareProduct.unitPrice | formatNumber }} +
+ +
+ {{ 'labels.inputs.Capital Value' | translate }}: + {{ shareProduct.shareCapital | formatNumber }} +
+ +

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

+ + + +
+ {{ 'labels.inputs.Shares per Client' | translate }}: + {{ + shareProduct.nominalShares + + ' (Min: ' + + (shareProduct.minimumShares ? shareProduct.minimumShares : shareProduct.nominalShares) + + ', Max: ' + + (shareProduct.maximumShares ? shareProduct.maximumShares : shareProduct.nominalShares) + + ')' + }} +
+ + @if (shareProduct.minimumActivePeriod) { +
+ {{ 'labels.inputs.Minimum Active Period' | translate }}: + {{ shareProduct.minimumActivePeriod }} + {{ shareProduct.minimumActivePeriodForDividendsTypeEnum.value | translateKey: 'catalogs' }} +
+ } + + @if (shareProduct.lockinPeriod) { +
+ {{ 'labels.inputs.Lock-in Period' | translate }}: + {{ shareProduct.lockinPeriod }} {{ shareProduct.lockPeriodTypeEnum.value | translateKey: 'catalogs' }} +
+ } + +
+ {{ 'labels.inputs.Allow dividends for inactive clients' | translate }}: + {{ shareProduct.allowDividendCalculationForInactiveClients | yesNo }} +
+ + @if (shareProduct.marketPrice?.length) { +
+

{{ 'labels.inputs.Market Price Period' | translate }}

+ + + + + + + + + + + + +
{{ 'labels.inputs.From Date' | translate }} + {{ marketPrice.fromDate | dateFormat }} + {{ 'labels.inputs.Nominal/Unit Price' | translate }} + {{ marketPrice.shareValue | formatNumber }} +
+
+ } + + @if (shareProduct.charges.length) { +
+

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

+ + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Name' | translate }} + {{ charge.name + ', ' + charge.currency.displaySymbol }} + {{ 'labels.inputs.Type' | translate }} + {{ charge.chargeCalculationType.value | translateKey: 'catalogs' }} + {{ 'labels.inputs.Amount' | translate }} + {{ charge.amount | numberFormat }} + {{ 'labels.inputs.Collected On' | translate }} + {{ charge.chargeTimeType.value | translateKey: 'catalogs' }} +
+
+ } + +

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

+ + + +
+ {{ 'labels.inputs.Type' | translate }}: + {{ + 'labels.accounting.' + getAccountingRuleName(shareProduct.accountingRule.value) | translate + }} +
+ + @if (shareProduct.accountingRule.id === 2) { +
+

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

+
+ + +
+

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

+
+ + +
+

{{ 'labels.heading.Share Equity' | translate }}

+
+ + +
+

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

+
+ + +
+
+ }
- - @if (shareProduct.marketPrice?.length) { -
-

{{ 'labels.inputs.Market Price Period' | translate }}

- - - - - - - - - - - - -
{{ 'labels.inputs.From Date' | translate }} - {{ marketPrice.fromDate | dateFormat }} - {{ 'labels.inputs.Nominal/Unit Price' | translate }} - {{ marketPrice.shareValue | formatNumber }} -
-
- } - - @if (shareProduct.charges.length) { -
-

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

- - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Name' | translate }} - {{ charge.name + ', ' + charge.currency.displaySymbol }} - {{ 'labels.inputs.Type' | translate }} - {{ charge.chargeCalculationType.value | translateKey: 'catalogs' }} - {{ 'labels.inputs.Amount' | translate }} - {{ charge.amount | numberFormat }} - {{ 'labels.inputs.Collected On' | translate }} - {{ charge.chargeTimeType.value | translateKey: 'catalogs' }} -
-
- } - -

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

- - - -
- {{ 'labels.inputs.Type' | translate }}: - {{ - 'labels.accounting.' + getAccountingRuleName(shareProduct.accountingRule.value) | translate - }} -
- - @if (shareProduct.accountingRule.id === 2) { -
-

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

-
- - -
-

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

-
- - -
-

{{ 'labels.heading.Share Equity' | translate }}

-
- - -
-

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

-
- - -
-
- } - -
diff --git a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss index b927a75f6..c6be20032 100644 --- a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss +++ b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss @@ -8,6 +8,15 @@ @use 'assets/styles/helper'; @use 'assets/styles/colours' as *; +@use 'assets/styles/product-view' as pv; + +@include pv.product-view-styles; + +/* Share-specific pink accent */ +:host { + --pv-accent: #be185d; + --pv-accent-l: #fce7f3; +} .tab-container { padding: 1%; diff --git a/src/app/shared/icons.module.ts b/src/app/shared/icons.module.ts index 85e048995..84225dd03 100644 --- a/src/app/shared/icons.module.ts +++ b/src/app/shared/icons.module.ts @@ -176,6 +176,11 @@ import { faStamp, faSpinner, faPhone, + faTruck, + faTruckFast, + faCommentDots, + faEllipsisV, + faEllipsisH, faHashtag, faMapMarkerAlt, faHourglassHalf, @@ -193,7 +198,10 @@ import { faRobot, faMicrochip, faMagic, - faQrcode + faQrcode, + faRedo, + faSignature, + faPercentage } from '@fortawesome/free-solid-svg-icons'; /** @@ -376,6 +384,11 @@ export class IconsModule { faStamp, faSpinner, faPhone, + faTruck, + faTruckFast, + faCommentDots, + faEllipsisV, + faEllipsisH, faHashtag, faMapMarkerAlt, faHourglassHalf, @@ -393,7 +406,10 @@ export class IconsModule { faRobot, faMicrochip, faMagic, - faQrcode + faQrcode, + faRedo, + faSignature, + faPercentage ); } } diff --git a/src/assets/styles/_client-action.scss b/src/assets/styles/_client-action.scss new file mode 100644 index 000000000..95478681c --- /dev/null +++ b/src/assets/styles/_client-action.scss @@ -0,0 +1,266 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Shared "client action" page styling — used by Activate / Close / Withdraw / + * Reject / Reactivate etc. screens. Consumer overrides --ca-accent on :host + * to colour the hero card per action. + */ + +@mixin client-action-styles { + :host { + --ca-navy: #0b1e3d; + --ca-blue: #1d4ed8; + --ca-blue-l: #eff6ff; + --ca-green: #15803d; + --ca-green-l: #dcfce7; + --ca-amber: #b45309; + --ca-amber-l: #fef3c7; + --ca-red: #c0392b; + --ca-red-l: #fee2e2; + --ca-g50: #f8fafc; + --ca-g100: #f1f5f9; + --ca-g200: #e2e8f0; + --ca-g500: #64748b; + --ca-g700: #334155; + --ca-g900: #0f172a; + --ca-accent: var(--ca-blue); + --ca-accent-l: var(--ca-blue-l); + --ca-accent-fg: #fff; + + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: var(--ca-g900); + font-size: 13.5px; + } + + .ca-root { + padding: 18px 22px; + max-width: 720px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 16px; + } + + /* ───────── HERO ───────── */ + .ca-hero { + background: linear-gradient(135deg, var(--ca-navy) 0%, var(--ca-accent) 100%); + border-radius: 14px; + padding: 22px 24px; + display: flex; + align-items: center; + gap: 18px; + box-shadow: + 0 10px 24px rgb(11 30 61 / 18%), + 0 4px 10px rgb(11 30 61 / 12%); + color: #fff; + position: relative; + overflow: hidden; + } + + .ca-hero::before { + content: ''; + position: absolute; + top: -60px; + right: -60px; + width: 220px; + height: 220px; + border-radius: 50%; + background: radial-gradient(circle, rgb(255 255 255 / 8%) 0%, transparent 70%); + pointer-events: none; + } + + .ca-hero-icon { + width: 56px; + height: 56px; + border-radius: 14px; + background: rgb(255 255 255 / 16%); + border: 1px solid rgb(255 255 255 / 22%); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 26px; + flex-shrink: 0; + } + + .ca-hero-text { + flex: 1; + min-width: 0; + } + + .ca-hero-eyebrow { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: rgb(255 255 255 / 70%); + margin-bottom: 4px; + } + + .ca-hero-title { + margin: 0; + font-size: 20px; + font-weight: 700; + letter-spacing: 0.01em; + color: #fff; + line-height: 1.2; + } + + .ca-hero-sub { + margin: 6px 0 0; + font-size: 13px; + color: rgb(255 255 255 / 82%); + line-height: 1.5; + max-width: 540px; + } + + /* ───────── CARD ───────── */ + .ca-card { + background: #fff; + border: 1px solid var(--ca-g200); + border-radius: 14px; + overflow: hidden; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); + } + + .ca-card-body { + padding: 22px 24px; + display: flex; + flex-direction: column; + gap: 14px; + } + + .ca-form-row { + display: flex; + flex-direction: column; + gap: 4px; + + mat-form-field { + width: 100%; + } + } + + .ca-row-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--ca-g500); + margin-bottom: 2px; + } + + /* Notice / warning strip below form for destructive actions */ + .ca-notice { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + border-radius: 10px; + background: var(--ca-accent-l); + border: 1px solid; + border-color: color-mix(in srgb, var(--ca-accent) 30%, transparent); + color: var(--ca-g700); + font-size: 12.5px; + line-height: 1.5; + + fa-icon { + color: var(--ca-accent); + font-size: 14px; + margin-top: 1px; + flex-shrink: 0; + } + + strong { + color: var(--ca-accent); + font-weight: 700; + } + } + + /* ───────── FOOTER ACTIONS ───────── */ + .ca-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 14px 24px; + background: var(--ca-g50); + border-top: 1px solid var(--ca-g200); + } + + .ca-btn { + display: inline-flex; + align-items: center; + gap: 8px; + height: 38px; + padding: 0 18px; + border-radius: 10px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + font-family: inherit; + transition: all 0.16s ease; + border: 1px solid transparent; + + fa-icon { + font-size: 12px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.55; + } + } + + .ca-btn--cancel { + background: #fff; + color: var(--ca-g700); + border-color: var(--ca-g200); + + &:hover { + background: var(--ca-g100); + border-color: var(--ca-g500); + } + } + + .ca-btn--confirm { + background: linear-gradient(135deg, var(--ca-accent) 0%, var(--ca-navy) 100%); + color: var(--ca-accent-fg); + box-shadow: + 0 6px 16px color-mix(in srgb, var(--ca-accent) 30%, transparent), + inset 0 1px 0 rgb(255 255 255 / 18%); + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + 0 10px 22px color-mix(in srgb, var(--ca-accent) 40%, transparent), + inset 0 1px 0 rgb(255 255 255 / 22%); + } + + &:active { + transform: translateY(0); + } + } + + /* Material form-field polish */ + ::ng-deep .ca-card mat-form-field { + width: 100%; + + .mat-mdc-form-field-infix { + padding-top: 10px; + padding-bottom: 10px; + } + } +} diff --git a/src/assets/styles/_product-list.scss b/src/assets/styles/_product-list.scss new file mode 100644 index 000000000..4706b574b --- /dev/null +++ b/src/assets/styles/_product-list.scss @@ -0,0 +1,605 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Shared "product list" page styling — used by Saving / Loan / FD / RD / Share / + * Current Account product list screens. Components @use this partial so the + * card / KPI / search / table / action-button look is identical across all + * product types. + * + * Theming hook: each consumer may override `--pl-accent` and `--pl-accent-l` + * on the host element to tint the header/icon accent per product type. + */ + +@mixin product-list-styles { + :host { + --pl-navy: #0b1e3d; + --pl-blue: #1d4ed8; + --pl-blue-l: #eff6ff; + --pl-teal: #0e7e6b; + --pl-teal-l: #ccfbf1; + --pl-purple: #6d28d9; + --pl-purple-l: #ede9fe; + --pl-amber: #b45309; + --pl-amber-l: #fef3c7; + --pl-green: #15803d; + --pl-green-l: #dcfce7; + --pl-red: #c0392b; + --pl-red-l: #fee2e2; + --pl-g50: #f8fafc; + --pl-g100: #f1f5f9; + --pl-g200: #e2e8f0; + --pl-g500: #64748b; + --pl-g700: #334155; + --pl-g900: #0f172a; + + /* Default accent — consumer can override on :host */ + --pl-accent: var(--pl-blue); + --pl-accent-l: var(--pl-blue-l); + + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + background: var(--pl-g50); + color: var(--pl-g900); + font-size: 13.5px; + } + + .pl-root { + padding: 18px 22px; + max-width: 1400px; + margin: 0 auto; + } + + /* ───────── HEADER ───────── */ + .pl-header { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 14px; + } + + .pl-header-left { + display: flex; + align-items: center; + gap: 14px; + flex: 1; + min-width: 320px; + } + + .pl-header-ic { + width: 48px; + height: 48px; + border-radius: 14px; + background: linear-gradient(135deg, var(--pl-accent) 0%, var(--pl-navy) 100%); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; + box-shadow: + 0 6px 16px rgb(29 78 216 / 25%), + inset 0 1px 0 rgb(255 255 255 / 22%); + } + + .pl-header-text { + h2 { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--pl-g900); + letter-spacing: 0.01em; + } + + p { + margin: 3px 0 0; + font-size: 12px; + color: var(--pl-g500); + line-height: 1.4; + } + } + + .pl-header-right { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + + .pl-count-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 14px 6px 12px; + border-radius: 999px; + background: var(--pl-accent-l); + color: var(--pl-accent); + border: 1px solid #dbeafe; + font-size: 12px; + font-weight: 600; + + strong { + font-size: 14px; + font-weight: 700; + color: var(--pl-navy); + } + + span { + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 10.5px; + } + + fa-icon { + font-size: 11px; + } + } + + .pl-create-btn, + .pl-secondary-btn { + display: inline-flex; + align-items: center; + gap: 8px; + height: 38px; + padding: 0 16px; + border-radius: 10px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + font-family: inherit; + transition: all 0.16s ease; + border: 0; + + fa-icon { + font-size: 12px; + } + } + + .pl-create-btn { + background: linear-gradient(135deg, var(--pl-accent) 0%, var(--pl-navy) 100%); + color: #fff; + box-shadow: + 0 6px 16px rgb(29 78 216 / 30%), + inset 0 1px 0 rgb(255 255 255 / 18%); + + &:hover { + transform: translateY(-1px); + box-shadow: + 0 10px 22px rgb(29 78 216 / 38%), + inset 0 1px 0 rgb(255 255 255 / 22%); + } + + &:active { + transform: translateY(0); + } + } + + .pl-secondary-btn { + background: #fff; + color: var(--pl-accent); + border: 1px solid var(--pl-g200); + + &:hover { + background: var(--pl-accent-l); + border-color: var(--pl-accent); + } + } + + /* ───────── KPI STRIP ───────── */ + .pl-kpis { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 14px; + } + + @media (width <= 720px) { + .pl-kpis { + grid-template-columns: 1fr; + } + } + + .pl-kpi { + background: #fff; + border: 1px solid var(--pl-g200); + border-radius: 12px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 14px; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); + border-left-width: 4px; + } + + .pl-kpi-ic { + width: 40px; + height: 40px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; + } + + .pl-kpi-n { + font-size: 20px; + font-weight: 700; + color: var(--pl-g900); + line-height: 1.1; + } + + .pl-kpi-l { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--pl-g500); + margin-top: 4px; + } + + .pl-kpi--blue { + border-left-color: var(--pl-blue); + + .pl-kpi-ic { + background: var(--pl-blue-l); + color: var(--pl-blue); + } + } + + .pl-kpi--teal { + border-left-color: var(--pl-teal); + + .pl-kpi-ic { + background: var(--pl-teal-l); + color: var(--pl-teal); + } + } + + .pl-kpi--purple { + border-left-color: var(--pl-purple); + + .pl-kpi-ic { + background: var(--pl-purple-l); + color: var(--pl-purple); + } + } + + /* ───────── CARD + TOOLBAR ───────── */ + .pl-card { + background: #fff; + border: 1px solid var(--pl-g200); + border-radius: 14px; + overflow: hidden; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); + } + + .pl-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: #fff; + border-bottom: 1px solid var(--pl-g200); + } + + .pl-search { + position: relative; + flex: 1; + max-width: 480px; + + input { + width: 100%; + box-sizing: border-box; + padding: 9px 14px 9px 36px; + border: 1px solid var(--pl-g200); + border-radius: 10px; + background: var(--pl-g50); + font-size: 13px; + color: var(--pl-g900); + font-family: inherit; + + &::placeholder { + color: #94a3b8; + } + + &:focus { + outline: none; + border-color: var(--pl-accent); + background: #fff; + box-shadow: 0 0 0 3px rgb(29 78 216 / 14%); + } + } + } + + .pl-search-ic { + position: absolute; + top: 50%; + left: 13px; + transform: translateY(-50%); + color: #94a3b8; + font-size: 13px; + pointer-events: none; + } + + /* ───────── TABLE ───────── */ + .pl-table-wrap { + overflow-x: auto; + } + + .pl-table { + width: 100%; + background: #fff; + + th.mat-mdc-header-cell { + background: var(--pl-g50); + color: var(--pl-g700); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 12px 16px; + border-bottom: 1px solid var(--pl-g200); + white-space: nowrap; + + &.num { + text-align: right; + } + } + + td.mat-mdc-cell { + padding: 12px 16px; + border-bottom: 1px solid var(--pl-g100); + font-size: 13px; + color: var(--pl-g900); + vertical-align: middle; + + &.num { + text-align: right; + } + } + } + + .pl-row { + cursor: pointer; + transition: background 0.14s ease; + + &:hover { + background: var(--pl-accent-l); + } + } + + .pl-product-cell { + display: flex; + align-items: center; + gap: 12px; + } + + .pl-product-icon { + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--pl-accent-l); + color: var(--pl-accent); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; + transition: all 0.14s ease; + } + + .pl-row:hover .pl-product-icon { + background: var(--pl-accent); + color: #fff; + transform: scale(1.04); + } + + .pl-product-meta { + display: flex; + flex-direction: column; + line-height: 1.25; + } + + .pl-product-name { + font-size: 13.5px; + font-weight: 600; + color: var(--pl-g900); + } + + .pl-product-sub { + font-size: 11px; + color: var(--pl-g500); + margin-top: 2px; + + span { + margin: 0 4px; + } + } + + /* Short Name pill */ + .pl-tag { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 3px 9px; + border-radius: 999px; + background: var(--pl-g100); + color: var(--pl-g700); + font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; + } + + /* Currency pill */ + .pl-currency-pill { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 700; + padding: 3px 9px; + border-radius: 999px; + background: var(--pl-teal-l); + color: var(--pl-teal); + border: 1px solid #99f6e4; + font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; + } + + /* Status pill */ + .pl-status-pill { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 3px 10px; + border-radius: 999px; + + fa-icon { + font-size: 9.5px; + } + + &--active { + background: var(--pl-green-l); + color: var(--pl-green); + border: 1px solid #bbf7d0; + } + + &--inactive { + background: var(--pl-g100); + color: var(--pl-g700); + border: 1px solid var(--pl-g200); + } + } + + .muted { + color: var(--pl-g500); + } + + /* Action buttons */ + .pl-action-btn { + display: inline-flex; + align-items: center; + gap: 5px; + height: 30px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid; + font-size: 11.5px; + font-weight: 600; + cursor: pointer; + text-decoration: none; + font-family: inherit; + margin-left: 6px; + transition: all 0.14s ease; + + fa-icon { + font-size: 11px; + } + + &:first-child { + margin-left: 0; + } + + &--view { + background: var(--pl-blue-l); + color: var(--pl-blue); + border-color: #dbeafe; + + &:hover { + background: var(--pl-blue); + color: #fff; + border-color: var(--pl-blue); + } + } + + &--edit { + background: var(--pl-amber-l); + color: var(--pl-amber); + border-color: #fde68a; + + &:hover { + background: var(--pl-amber); + color: #fff; + border-color: var(--pl-amber); + } + } + } + + /* No-match strip */ + .pl-no-match { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px 16px; + background: var(--pl-g50); + color: var(--pl-g500); + font-size: 13px; + font-weight: 500; + border-top: 1px dashed var(--pl-g200); + + fa-icon { + font-size: 13px; + } + } + + /* ───────── EMPTY STATE ───────── */ + .pl-empty { + text-align: center; + padding: 50px 24px; + } + + .pl-empty-ic { + width: 64px; + height: 64px; + margin: 0 auto 16px; + border-radius: 16px; + background: var(--pl-accent-l); + color: var(--pl-accent); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 26px; + } + + .pl-empty h3 { + margin: 0 0 8px; + font-size: 16px; + font-weight: 700; + color: var(--pl-g900); + } + + .pl-empty p { + margin: 0 0 20px; + font-size: 13px; + color: var(--pl-g500); + line-height: 1.5; + max-width: 420px; + margin-left: auto; + margin-right: auto; + } + + /* Paginator polish */ + ::ng-deep .pl-table-wrap .mat-mdc-paginator { + background: var(--pl-g50); + border-top: 1px solid var(--pl-g200); + font-family: inherit; + } +} diff --git a/src/assets/styles/_product-view.scss b/src/assets/styles/_product-view.scss new file mode 100644 index 000000000..e6cb3780f --- /dev/null +++ b/src/assets/styles/_product-view.scss @@ -0,0 +1,575 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Shared "product view" styling — used by the general-tab views of + * Saving / Loan / FD / RD / Share / Current product types. + * + * Theming hook: each consumer may override `--pv-accent` / `--pv-accent-l` + * on the host to tint the hero gradient per product type. + */ + +@mixin product-view-styles { + :host { + --pv-navy: #0b1e3d; + --pv-blue: #1d4ed8; + --pv-blue-l: #eff6ff; + --pv-teal: #0e7e6b; + --pv-teal-l: #ccfbf1; + --pv-purple: #6d28d9; + --pv-purple-l: #ede9fe; + --pv-indigo: #3730a3; + --pv-indigo-l: #e0e7ff; + --pv-amber: #b45309; + --pv-amber-l: #fef3c7; + --pv-green: #15803d; + --pv-green-l: #dcfce7; + --pv-red: #c0392b; + --pv-red-l: #fee2e2; + --pv-pink: #be185d; + --pv-pink-l: #fce7f3; + --pv-g50: #f8fafc; + --pv-g100: #f1f5f9; + --pv-g200: #e2e8f0; + --pv-g500: #64748b; + --pv-g700: #334155; + --pv-g900: #0f172a; + --pv-accent: var(--pv-blue); + --pv-accent-l: var(--pv-blue-l); + + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: var(--pv-g900); + font-size: 13.5px; + } + + .pv-root { + padding: 4px 4px 24px; + display: flex; + flex-direction: column; + gap: 14px; + } + + /* ───────── HERO ───────── */ + .pv-hero { + background: linear-gradient(135deg, var(--pv-navy) 0%, var(--pv-accent) 100%); + border-radius: 14px; + padding: 22px 22px 22px 18px; + display: flex; + align-items: flex-start; + gap: 18px; + box-shadow: + 0 10px 24px rgb(11 30 61 / 18%), + 0 4px 10px rgb(11 30 61 / 12%); + color: #fff; + position: relative; + overflow: hidden; + } + + .pv-hero::before { + content: ''; + position: absolute; + top: -60px; + right: -60px; + width: 240px; + height: 240px; + border-radius: 50%; + background: radial-gradient(circle, rgb(255 255 255 / 8%) 0%, transparent 70%); + pointer-events: none; + } + + .pv-hero-left { + display: flex; + align-items: flex-start; + gap: 16px; + flex: 1; + min-width: 0; + } + + .pv-hero-icon { + width: 56px; + height: 56px; + border-radius: 14px; + background: rgb(255 255 255 / 16%); + border: 1px solid rgb(255 255 255 / 22%); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 24px; + flex-shrink: 0; + } + + .pv-hero-text { + flex: 1; + min-width: 0; + } + + .pv-hero-eyebrow { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: rgb(255 255 255 / 70%); + margin-bottom: 4px; + } + + .pv-hero-title { + margin: 0 0 10px; + font-size: 22px; + font-weight: 700; + letter-spacing: 0.01em; + color: #fff; + line-height: 1.2; + } + + .pv-hero-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .pv-hero-desc { + margin: 12px 0 0; + font-size: 13px; + color: rgb(255 255 255 / 82%); + line-height: 1.5; + max-width: 720px; + } + + .pv-edit-btn { + display: inline-flex; + align-items: center; + gap: 8px; + height: 38px; + padding: 0 16px; + border-radius: 10px; + background: rgb(255 255 255 / 14%); + color: #fff; + border: 1px solid rgb(255 255 255 / 28%); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + font-family: inherit; + flex-shrink: 0; + transition: all 0.16s ease; + + fa-icon { + font-size: 12px; + } + + &:hover { + background: rgb(255 255 255 / 24%); + border-color: rgb(255 255 255 / 50%); + } + } + + /* ───────── PILLS ───────── */ + .pv-pill { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 4px 10px; + border-radius: 999px; + font-family: inherit; + + fa-icon { + font-size: 9.5px; + } + + &--code { + background: rgb(255 255 255 / 18%); + color: #fff; + border: 1px solid rgb(255 255 255 / 24%); + font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; + } + + &--teal { + background: var(--pv-teal-l); + color: var(--pv-teal); + border: 1px solid #99f6e4; + } + + &--blue { + background: var(--pv-blue-l); + color: var(--pv-blue); + border: 1px solid #dbeafe; + } + + &--green { + background: var(--pv-green-l); + color: var(--pv-green); + border: 1px solid #bbf7d0; + } + + &--amber { + background: var(--pv-amber-l); + color: var(--pv-amber); + border: 1px solid #fde68a; + } + + &--red { + background: var(--pv-red-l); + color: var(--pv-red); + border: 1px solid #fecaca; + } + + &--gray { + background: var(--pv-g100); + color: var(--pv-g700); + border: 1px solid var(--pv-g200); + } + } + + /* ───────── SECTION ───────── */ + .pv-section { + background: #fff; + border: 1px solid var(--pv-g200); + border-radius: 12px; + padding: 16px 20px; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); + } + + .pv-section-h { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px dashed var(--pv-g200); + + h3 { + margin: 0; + font-size: 14px; + font-weight: 700; + color: var(--pv-g900); + letter-spacing: 0.02em; + } + } + + .pv-section-ic { + width: 36px; + height: 36px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; + + &--blue { + background: var(--pv-blue-l); + color: var(--pv-blue); + } + + &--teal { + background: var(--pv-teal-l); + color: var(--pv-teal); + } + + &--purple { + background: var(--pv-purple-l); + color: var(--pv-purple); + } + + &--indigo { + background: var(--pv-indigo-l); + color: var(--pv-indigo); + } + + &--amber { + background: var(--pv-amber-l); + color: var(--pv-amber); + } + + &--green { + background: var(--pv-green-l); + color: var(--pv-green); + } + + &--red { + background: var(--pv-red-l); + color: var(--pv-red); + } + + &--pink { + background: var(--pv-pink-l); + color: var(--pv-pink); + } + } + + .pv-sub-h { + margin: 16px 0 10px; + font-size: 12px; + font-weight: 700; + color: var(--pv-g700); + text-transform: uppercase; + letter-spacing: 0.06em; + } + + /* ───────── KEY/VALUE GRID ───────── */ + .pv-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 24px; + } + + .pv-grid--3 { + grid-template-columns: repeat(3, 1fr); + } + + @media (width <= 900px) { + .pv-grid, + .pv-grid--3 { + grid-template-columns: 1fr 1fr; + } + } + + @media (width <= 600px) { + .pv-grid, + .pv-grid--3 { + grid-template-columns: 1fr; + } + } + + .pv-kv { + display: flex; + flex-direction: column; + gap: 3px; + padding: 8px 0; + border-bottom: 1px dashed var(--pv-g100); + + &--span { + grid-column: 1 / -1; + } + } + + .pv-k { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--pv-g500); + } + + .pv-v { + font-size: 13.5px; + color: var(--pv-g900); + font-weight: 500; + word-break: break-word; + + strong { + font-weight: 700; + } + } + + .pv-v--accent { + font-size: 18px; + font-weight: 700; + color: var(--pv-accent); + letter-spacing: 0.01em; + } + + .pv-suffix { + font-size: 12px; + font-weight: 600; + color: var(--pv-g500); + margin-left: 4px; + } + + /* ───────── FEATURE GRID ───────── */ + .pv-feature { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + background: var(--pv-g50); + border: 1px solid var(--pv-g200); + font-size: 12.5px; + font-weight: 600; + color: var(--pv-g700); + + fa-icon { + font-size: 12px; + color: var(--pv-g500); + } + + &.is-on { + background: var(--pv-green-l); + border-color: #bbf7d0; + color: var(--pv-green); + + fa-icon { + color: var(--pv-green); + } + } + } + + /* ───────── LEGACY-MARKUP POLISH ───────── + Polishes the existing Mifos label/value layout (.flex-100.layout-row > + .flex-40 + .flex-60) and section headings (.mat-h3) when wrapped in + .pv-legacy, so view templates can be retro-fitted with the new look + without rewriting every row. */ + .pv-legacy { + display: flex; + flex-direction: column; + gap: 14px; + + > h2.mat-h2, + .pv-section > h2.mat-h2 { + margin: 0 0 12px; + font-size: 16px; + font-weight: 700; + color: var(--pv-g900); + } + + /* Section heading -> behaves like .pv-section-h */ + .mat-h3 { + margin: 0 0 12px; + padding: 10px 16px; + font-size: 13px; + font-weight: 700; + color: var(--pv-accent); + letter-spacing: 0.04em; + text-transform: uppercase; + background: var(--pv-accent-l); + border-left: 3px solid var(--pv-accent); + border-radius: 8px; + } + + .mat-h4 { + margin: 12px 0 8px; + font-size: 12px; + font-weight: 700; + color: var(--pv-g700); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + /* Section card wrapper: every consecutive group of (h3 + rows) is + essentially a section. We wrap each whole content block in a card-like + container by styling .responsive-column / .layout-row-wrap.flex-100 */ + .layout-row-wrap.flex-100, + > .flex-100.layout-row-wrap, + > .responsive-column { + background: #fff; + border: 1px solid var(--pv-g200); + border-radius: 12px; + padding: 14px 18px; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); + } + + /* Label / value row */ + .flex-100.layout-row, + .layout-row.flex-100 { + display: grid; + grid-template-columns: minmax(220px, 42%) 1fr; + gap: 14px; + align-items: baseline; + padding: 8px 0; + border-bottom: 1px dashed var(--pv-g100); + + &:last-child { + border-bottom: 0; + } + + > .flex-40 { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--pv-g500); + margin: 0; + flex: none; + } + + > .flex-60 { + font-size: 13.5px; + color: var(--pv-g900); + font-weight: 500; + word-break: break-word; + margin: 0; + flex: none; + + strong { + font-weight: 700; + } + } + } + + /* Dividers between sections can be removed since cards already separate */ + mat-divider { + display: none; + } + + /* Inline tables in legacy content */ + .mat-elevation-z1 { + border-radius: 10px; + overflow: hidden; + margin-top: 6px; + box-shadow: none; + border: 1px solid var(--pv-g200); + } + + @media (width <= 600px) { + .flex-100.layout-row, + .layout-row.flex-100 { + grid-template-columns: 1fr; + gap: 4px; + } + } + } + + /* ───────── TABLES ───────── */ + .pv-table-wrap { + overflow-x: auto; + border: 1px solid var(--pv-g200); + border-radius: 10px; + margin-top: 4px; + } + + .pv-table { + width: 100%; + background: #fff; + + th.mat-mdc-header-cell { + background: var(--pv-g50); + color: var(--pv-g700); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 11px 14px; + border-bottom: 1px solid var(--pv-g200); + white-space: nowrap; + } + + td.mat-mdc-cell { + padding: 10px 14px; + border-bottom: 1px solid var(--pv-g100); + font-size: 12.5px; + color: var(--pv-g900); + } + + tbody tr:last-child td.mat-mdc-cell { + border-bottom: 0; + } + } +} diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 21b460bcf..cbb635d0c 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260515', - 'hash': 'g595054b0b' + 'version': '260518', + 'hash': 'g4322f37c6' }, 'allow_switching_backend_instance': true }; diff --git a/src/index.html b/src/index.html index 6eaa4572e..b56579749 100644 --- a/src/index.html +++ b/src/index.html @@ -27,6 +27,7 @@ rel="stylesheet" /> +