diff --git a/Dockerfile b/Dockerfile index 5e5750925..26bedcd4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 ############### ### STAGE 1: Build app ############### @@ -9,10 +10,13 @@ ARG NPM_REGISTRY_URL=https://registry.npmjs.org/ ARG BUILD_ENVIRONMENT_OPTIONS="--configuration production" ARG PUPPETEER_DOWNLOAD_HOST_ARG=https://storage.googleapis.com ARG PUPPETEER_CHROMIUM_REVISION_ARG=1011831 -ARG PUPPETEER_SKIP_DOWNLOAD_ARG +# Build ko Chromium nahi chahiye — skip (warna npm ci Chromium download par atak jata hai). +ARG PUPPETEER_SKIP_DOWNLOAD_ARG=true # Set the environment variable to increase Node.js memory limit -ENV NODE_OPTIONS="--max-old-space-size=4096" +# (Mifos web-app ka production build memory-heavy hai; upstream build:prod +# 16384 use karta hai. 4096 par 'ng build' OOM ho jata hai.) +ENV NODE_OPTIONS="--max-old-space-size=8192" RUN apk add --no-cache git @@ -25,14 +29,22 @@ ENV PUPPETEER_DOWNLOAD_HOST=$PUPPETEER_DOWNLOAD_HOST_ARG ENV PUPPETEER_CHROMIUM_REVISION=$PUPPETEER_CHROMIUM_REVISION_ARG ENV PUPPETEER_SKIP_DOWNLOAD=$PUPPETEER_SKIP_DOWNLOAD_ARG +# Test/e2e tooling ke bhaari binary downloads skip karo — build inhe use nahi +# karta, aur inke bina npm ci turant complete ho jata hai. +# Cypress -> ~250MB binary (CI me sabse zyada atakta hai) +# Playwright-> browser binaries +ENV CYPRESS_INSTALL_BINARY=0 +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 + COPY ./ /usr/src/app/ -RUN npm cache clear --force +RUN npm config set fetch-retry-maxtimeout 120000 \ + && npm config set registry $NPM_REGISTRY_URL --location=global -RUN npm config set fetch-retry-maxtimeout 120000 -RUN npm config set registry $NPM_REGISTRY_URL --location=global - -RUN npm ci +# BuildKit cache mount: npm ka download cache builds ke beech persist hota hai, +# isliye har build par saare packages dobara download nahi hote (bahut fast). +# 'npm cache clear --force' hata diya — wo har build par cache wipe kar deta tha. +RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund RUN sh -c "ng build --output-path=/dist $BUILD_ENVIRONMENT_OPTIONS" @@ -41,6 +53,14 @@ RUN sh -c "ng build --output-path=/dist $BUILD_ENVIRONMENT_OPTIONS" ############### FROM $NGINX_IMAGE +# envsubst (gettext) alpine-slim me nahi hota — runtime env.js generation ke +# liye zaroori hai, isliye explicitly install kar rahe hain. +RUN apk add --no-cache gettext + +# Static-only nginx config (SPA fallback, no API proxy). Stock default.conf +# ko replace karta hai taaki deep-link refresh par 404 na aaye. +COPY nginx.spa.conf /etc/nginx/conf.d/default.conf + COPY --from=builder /dist/browser /usr/share/nginx/html EXPOSE 80 diff --git a/angular.json b/angular.json index 341741725..9da18bf60 100644 --- a/angular.json +++ b/angular.json @@ -69,8 +69,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "15mb", - "maximumError": "17mb" + "maximumWarning": "18mb", + "maximumError": "20mb" }, { "type": "anyComponentStyle", diff --git a/nginx.spa.conf b/nginx.spa.conf new file mode 100644 index 000000000..86dae6530 --- /dev/null +++ b/nginx.spa.conf @@ -0,0 +1,45 @@ +# Static-only nginx config for the CBSNexora Angular UI. +# NOTE: No API reverse-proxy here by design — the browser calls the Fineract +# backend directly via FINERACT_API_URL (env.js). nginx sirf static files +# serve karta hai + Angular SPA routing handle karta hai. + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # ---- Compression ---- + gzip on; + gzip_comp_level 6; + gzip_min_length 1024; + gzip_proxied any; + gzip_types text/plain text/css application/javascript application/json + application/xml image/svg+xml font/woff font/woff2; + + # ---- Runtime env: env.js aur index.html kabhi cache na ho ---- + # (env.js har container start par envsubst se regenerate hoti hai; + # index.html hashed asset filenames point karti hai) + location = /assets/env.js { + add_header Cache-Control "no-store, no-cache, must-revalidate"; + expires -1; + } + + location = /index.html { + add_header Cache-Control "no-store, no-cache, must-revalidate"; + expires -1; + } + + # ---- Hashed static assets: lambe time cache ---- + location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 7d; + add_header Cache-Control "public"; + try_files $uri =404; + } + + # ---- Angular SPA fallback: har route index.html par ---- + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/package.json b/package.json index 3893f2fb0..4e0057794 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "playwright:headed": "playwright test --headed", "playwright:debug": "playwright test --debug", "headers:check": "node scripts/check-file-headers.js", - "headers:add": "node scripts/add-file-headers.js" + "headers:add": "node scripts/add-file-headers.js", + "mock:dip": "node \"../cbsnexora-backend/scripts/mock-dip-lookup-service.js\"" }, "dependencies": { "@angular/animations": "20.3.16", diff --git a/src/app/accounting/accounting-routing.module.ts b/src/app/accounting/accounting-routing.module.ts index 2a254c1ed..928b95931 100644 --- a/src/app/accounting/accounting-routing.module.ts +++ b/src/app/accounting/accounting-routing.module.ts @@ -25,6 +25,7 @@ import { CreateFinancialActivityMappingComponent } from './financial-activity-ma import { ViewFinancialActivityMappingComponent } from './financial-activity-mappings/view-financial-activity-mapping/view-financial-activity-mapping.component'; import { EditFinancialActivityMappingComponent } from './financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component'; import { MigrateOpeningBalancesComponent } from './migrate-opening-balances/migrate-opening-balances.component'; +import { MigrateCashDenominationComponent } from './migrate-cash-denomination/migrate-cash-denomination.component'; import { ChartOfAccountsComponent } from './chart-of-accounts/chart-of-accounts.component'; import { CreateGlAccountComponent } from './chart-of-accounts/create-gl-account/create-gl-account.component'; import { ViewGlAccountComponent } from './chart-of-accounts/view-gl-account/view-gl-account.component'; @@ -71,6 +72,7 @@ import { ViewJournalEntryTransactionComponent } from 'app/shared/accounting/view import { JournalEntryTransactionResolver } from './common-resolvers/journal-entry-transaction.resolver'; import { ExternalAssetOwnerJournalEntryResolver } from 'app/loans/common-resolvers/external-asset-owner-journal-entry.resolver'; import { ExternalAssetConfigurationResolver } from './common-resolvers/external-asset-configuration.resolver'; +import { accountingFeatureEnabledGuard } from './disabled-features'; /** Accounting Routes */ const routes: Routes = [ @@ -98,7 +100,8 @@ const routes: Routes = [ { path: 'frequent-postings', component: FrequentPostingsComponent, - data: { title: 'Frequent Postings', breadcrumb: 'Frequent Postings' }, + canActivate: [accountingFeatureEnabledGuard], + data: { title: 'Frequent Postings', breadcrumb: 'Frequent Postings', feature: 'frequentPostings' }, resolve: { offices: OfficesResolver, accountingRules: AccountingRulesAssociationsResolver, @@ -109,7 +112,8 @@ const routes: Routes = [ { path: 'create', component: CreateJournalEntryComponent, - data: { title: 'Create Journal Entry', breadcrumb: 'Create' }, + canActivate: [accountingFeatureEnabledGuard], + data: { title: 'Create Journal Entry', breadcrumb: 'Create', feature: 'createJournalEntries' }, resolve: { offices: OfficesResolver, currencies: CurrenciesResolver, @@ -185,6 +189,19 @@ const routes: Routes = [ currencies: CurrenciesResolver } }, + { + path: 'migrate-cash-denomination', + component: MigrateCashDenominationComponent, + data: { title: 'Migrate Cash Denomination', breadcrumb: 'Migrate Cash Denomination' }, + resolve: { + offices: OfficesResolver + } + }, + { + path: 'balance-sheet', + loadComponent: () => import('./balance-sheet/balance-sheet.component').then((c) => c.BalanceSheetComponent), + data: { title: 'Balance Sheet', breadcrumb: 'Balance Sheet' } + }, { path: 'chart-of-accounts', data: { title: 'Chart of Accounts', breadcrumb: 'Chart of Accounts' }, @@ -235,7 +252,8 @@ const routes: Routes = [ }, { path: 'closing-entries', - data: { title: 'Accounting Closures', breadcrumb: 'Closing Entries' }, + canActivate: [accountingFeatureEnabledGuard], + data: { title: 'Accounting Closures', breadcrumb: 'Closing Entries', feature: 'closingEntries' }, children: [ { path: '', @@ -276,7 +294,8 @@ const routes: Routes = [ }, { path: 'accounting-rules', - data: { title: 'Accounting Rules', breadcrumb: 'Accounting Rules' }, + canActivate: [accountingFeatureEnabledGuard], + data: { title: 'Accounting Rules', breadcrumb: 'Accounting Rules', feature: 'accountingRules' }, children: [ { path: '', @@ -323,11 +342,17 @@ const routes: Routes = [ { path: 'periodic-accruals', component: PeriodicAccrualsComponent, - data: { title: 'Periodic Accrual Accounting', breadcrumb: 'Execute Periodic Accrual Accounting' } + canActivate: [accountingFeatureEnabledGuard], + data: { + title: 'Periodic Accrual Accounting', + breadcrumb: 'Execute Periodic Accrual Accounting', + feature: 'accruals' + } }, { path: 'provisioning-entries', - data: { title: 'Provisioning Entries', breadcrumb: 'Provisioning Entries' }, + canActivate: [accountingFeatureEnabledGuard], + data: { title: 'Provisioning Entries', breadcrumb: 'Provisioning Entries', feature: 'provisioningEntries' }, children: [ { path: '', diff --git a/src/app/accounting/accounting.component.html b/src/app/accounting/accounting.component.html index eeb29f912..8685bea9a 100644 --- a/src/app/accounting/accounting.component.html +++ b/src/app/accounting/accounting.component.html @@ -17,35 +17,39 @@
- - -
-
- {{ 'labels.heading.Frequent Postings' | translate }} - {{ 'labels.text.These are predefined postings' | translate }} -
-
+ + @if (!disabledFeatures.frequentPostings) { + +
+
+ {{ 'labels.heading.Frequent Postings' | translate }} + {{ 'labels.text.These are predefined postings' | translate }} +
+
+ } - - -
-
- {{ 'labels.heading.Create Journal Entries' | translate }} - {{ - 'labels.text.Manual journal entry transactions recorded in a journal' | translate - }} -
-
+ + @if (!disabledFeatures.createJournalEntries) { + +
+
+ {{ 'labels.heading.Create Journal Entries' | translate }} + {{ + 'labels.text.Manual journal entry transactions recorded in a journal' | translate + }} +
+
+ } @@ -88,6 +92,22 @@
+ + +
+
+ Migrate Cash Denomination + + Count the vault opening cash into notes and coins, and open the first business day + +
+
+
@@ -97,43 +117,64 @@
- - -
+ +
+
- {{ 'labels.heading.Closing Entries' | translate }} - {{ - 'labels.text.Journal entries made at the end of accounting period' | translate - }} + Balance Sheet + Header/Detail balances as on any date
- - -
-
- {{ 'labels.heading.Accounting Rules' | translate }} - {{ 'labels.text.List of all accounting rules' | translate }} -
-
+ + @if (!disabledFeatures.closingEntries) { + +
+
+ {{ 'labels.heading.Closing Entries' | translate }} + {{ + 'labels.text.Journal entries made at the end of accounting period' | translate + }} +
+
+ } - - -
-
- {{ 'labels.heading.Accruals' | translate }} - {{ 'labels.text.Accrues income, expenses and liabilities' | translate }} -
-
+ + @if (!disabledFeatures.accountingRules) { + +
+
+ {{ 'labels.heading.Accounting Rules' | translate }} + {{ 'labels.text.List of all accounting rules' | translate }} +
+
+ } - - -
-
- {{ 'labels.heading.Provisioning Entries' | translate }} - {{ 'labels.text.Create provisioning entries' | translate }} -
-
+ + @if (!disabledFeatures.accruals) { + +
+
+ {{ 'labels.heading.Accruals' | translate }} + {{ 'labels.text.Accrues income, expenses and liabilities' | translate }} +
+
+ } + + + @if (!disabledFeatures.provisioningEntries) { + +
+
+ {{ 'labels.heading.Provisioning Entries' | translate }} + {{ 'labels.text.Create provisioning entries' | translate }} +
+
+ } diff --git a/src/app/accounting/accounting.component.ts b/src/app/accounting/accounting.component.ts index cf9771660..c1318e154 100644 --- a/src/app/accounting/accounting.component.ts +++ b/src/app/accounting/accounting.component.ts @@ -18,6 +18,7 @@ import { MatIcon } from '@angular/material/icon'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatLine } from '@angular/material/grid-list'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { DISABLED_ACCOUNTING_FEATURES } from './disabled-features'; /** * Accounting component. @@ -63,6 +64,9 @@ export class AccountingComponent implements AfterViewInit { // Initialize an array of 10 boolean values, all set to false arrowBooleans: boolean[] = new Array(10).fill(false); + /** Which accounting tiles are switched off for this installation — see disabled-features.ts. */ + disabledFeatures = DISABLED_ACCOUNTING_FEATURES; + /** * Popover function * @param template TemplateRef. @@ -104,12 +108,15 @@ export class AccountingComponent implements AfterViewInit { ); }); } - if (this.configurationWizardService.showClosingEntries) { + // Closing Entries and Create Journal Entries can be switched off (see disabled-features.ts), in + // which case their tiles never render and these refs stay undefined — the wizard simply has no + // step to point at, rather than throwing on a missing element. + if (this.configurationWizardService.showClosingEntries && this.closingEntries) { setTimeout(() => { this.showPopover(this.templateClosingEntries, this.closingEntries.nativeElement, 'bottom', true); }); } - if (this.configurationWizardService.showCreateJournalEntries) { + if (this.configurationWizardService.showCreateJournalEntries && this.createJournalEntries) { setTimeout(() => { this.showPopover(this.templateCreateJournalEntries, this.createJournalEntries.nativeElement, 'bottom', true); }); diff --git a/src/app/accounting/accounting.service.ts b/src/app/accounting/accounting.service.ts index 9d2f8825d..2143fbd22 100644 --- a/src/app/accounting/accounting.service.ts +++ b/src/app/accounting/accounting.service.ts @@ -44,6 +44,26 @@ export class AccountingService { return this.http.get('/accounting/profitandloss', { params: httpParams }); } + /** + * How much profit a share dividend may actually be declared out of — net profit reduced by the + * statutory appropriations that legally precede a dividend (Statutory Reserve Fund, education + * fund) and by dividends already declared for other share products over an overlapping period. + * @param {string} fromDate Period start, yyyy-MM-dd. + * @param {string} toDate Period end, yyyy-MM-dd. + * @param {string} excludeProductId Leave this product's own declarations out of `alreadyDeclared` + * (its overlapping declaration is separately blocked outright, so counting it would double up). + * @returns {Observable} `{ netProfit, statutoryReserveAmount, educationFundAmount, + * distributableProfit, alreadyDeclared, availableForDeclaration, maximumRatePercentage, + * existingDeclarations[] }`. + */ + getDistributableProfit(fromDate: string, toDate: string, excludeProductId?: string): Observable { + let httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate); + if (excludeProductId) { + httpParams = httpParams.set('excludeProductId', excludeProductId); + } + return this.http.get('/accounting/distributable-profit', { params: httpParams }); + } + /** * Total share dividend actually paid to members in a date range — derived from the Dividend * Payable GL account's own ledger (no separate "Dividend Paid" account needed). @@ -96,6 +116,22 @@ export class AccountingService { return this.http.get(`/glaccounts`, { params: httpParams }); } + /** + * As-on-date balance sheet, grouped by Header/Detail hierarchy — see BalanceSheetApiResource. + * @param asOnDate yyyy-MM-dd. Defaults to today on the backend when omitted. + * @param officeId Optional — scopes every balance to one office instead of the whole organization. + */ + getBalanceSheet(asOnDate?: string, officeId?: number): Observable { + let httpParams = new HttpParams(); + if (asOnDate) { + httpParams = httpParams.set('asOnDate', asOnDate); + } + if (officeId != null) { + httpParams = httpParams.set('officeId', officeId.toString()); + } + return this.http.get('/accounting/balancesheet', { params: httpParams }); + } + /** * @param transactionId Transaction ID of journal entries. * @returns {Observable} Journal Entries. diff --git a/src/app/accounting/balance-sheet/balance-sheet-pdf.ts b/src/app/accounting/balance-sheet/balance-sheet-pdf.ts new file mode 100644 index 000000000..954ec2e52 --- /dev/null +++ b/src/app/accounting/balance-sheet/balance-sheet-pdf.ts @@ -0,0 +1,363 @@ +/** + * 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/. + */ + +/* + * Builds the Consolidated Balance Sheet PDF, laid out like the bank's printed report: liabilities + * on the left, assets on the right, each top-level group a named block with its accounts listed + * under it and a subtotal in the TOTAL column. + * + * Drawn directly with jsPDF rather than printed from the DOM: window.print() carries the whole app + * shell (sidenav, toolbar, ticker) into the output and gives no control over where a page breaks, + * which is what made the printed sheet unreadable. Here each column is paginated on its own, the + * way the printed report does it — the left side can run onto page 2 while the right side is still + * on page 1 — and every page repeats the heading. + */ + +import { jsPDF } from 'jspdf'; + +export interface BalanceSheetPdfNode { + name: string; + glCode: string; + usage: 'HEADER' | 'DETAIL'; + balance: number; + children: BalanceSheetPdfNode[]; +} + +export interface BalanceSheetPdfSide { + label: string; + total: number; + groups: { classification: string; total: number; nodes: BalanceSheetPdfNode[] }[]; +} + +export interface BalanceSheetPdfInput { + bankName: string; + officeLabel: string; + /** yyyy-MM-dd */ + asOnDate: string; + creditSide: BalanceSheetPdfSide; + debitSide: BalanceSheetPdfSide; +} + +// ── Page geometry, in mm on A4 portrait ───────────────────────────────────── +const PAGE_W = 210; +const PAGE_H = 297; +const MARGIN = 8; +const COL_W = (PAGE_W - MARGIN * 2) / 2; +const COL_X = [ + MARGIN, + MARGIN + COL_W +]; +/** Right edges of the BALANCE and TOTAL columns, relative to a column's left edge. */ +const BALANCE_R = COL_W - 26; +const TOTAL_R = COL_W - 2; +/** Name text wraps within this width so it never runs under the BALANCE column. */ +const NAME_W = COL_W - 30; + +const BODY_TOP = 36; +/** Leaves room under the body for the sign-off row and the print-date footer. */ +const BODY_BOTTOM = PAGE_H - 21; +const LINE_H = 3.1; + +const INK: [number, number, number] = [ + 20, + 20, + 20 +]; +const MUTED: [number, number, number] = [ + 90, + 90, + 90 +]; + +type Line = + | { kind: 'block'; text: string } + | { kind: 'subhead'; text: string; depth: number } + | { kind: 'detail'; lines: string[]; amount: string; depth: number } + | { kind: 'subsubtotal'; amount: string } + | { kind: 'subtotal'; amount: string } + | { kind: 'standalone'; lines: string[]; amount: string } + | { kind: 'grand'; amount: string } + | { kind: 'gap' }; + +function money(value: number): string { + return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0); +} + +function ddmmyyyy(iso: string): string { + if (!iso) { + return ''; + } + const [ + y, + m, + d + ] = iso.split('-'); + return `${d}/${m}/${y}`; +} + +function heightOf(line: Line): number { + switch (line.kind) { + case 'block': + // Heading plus the short dashed rule beneath it. + return LINE_H + 1.8; + case 'detail': + case 'standalone': + return LINE_H * line.lines.length; + case 'gap': + return 2.4; + case 'grand': + // Rule above, the figure, and the double rule beneath it. + return LINE_H + 6; + default: + return LINE_H; + } +} + +/** + * Flattens one side into the exact sequence of printed lines. Done up front so pagination is a + * matter of measuring heights, with no recursion happening mid-page. + */ +function flatten(doc: jsPDF, side: BalanceSheetPdfSide): Line[] { + const out: Line[] = []; + // splitTextToSize measures with whatever font is currently set, so it has to match the font the + // detail lines are actually drawn in — otherwise names wrap at the wrong width. + doc.setFont('helvetica', 'normal').setFontSize(6.8); + + const pushNode = (node: BalanceSheetPdfNode, depth: number): void => { + if (node.usage === 'DETAIL') { + const indent = depth * 2; + const wrapped: string[] = doc.splitTextToSize(node.name, NAME_W - indent); + out.push({ kind: 'detail', lines: wrapped, amount: money(node.balance), depth }); + return; + } + out.push({ kind: 'subhead', text: node.name, depth }); + node.children.forEach((child) => pushNode(child, depth + 1)); + out.push({ kind: 'subsubtotal', amount: money(node.balance) }); + }; + + for (const group of side.groups) { + for (const root of group.nodes) { + if (root.usage === 'DETAIL') { + // A Detail account sitting under no group at all: a group of one, printed on a single line + // carrying both its balance and its total, rather than a heading and a figure on separate + // rows with the BALANCE column left empty. + out.push({ + kind: 'standalone', + lines: doc.splitTextToSize(root.name.toUpperCase(), NAME_W), + amount: money(root.balance) + }); + out.push({ kind: 'gap' }); + continue; + } + out.push({ kind: 'block', text: root.name }); + root.children.forEach((child) => pushNode(child, 0)); + out.push({ kind: 'subtotal', amount: money(root.balance) }); + out.push({ kind: 'gap' }); + } + } + // Carried as a line of its own so pagination reserves room for it, rather than squeezing it + // against the bottom margin under the last block. + out.push({ kind: 'grand', amount: money(side.total) }); + return out; +} + +function dottedLine(doc: jsPDF, x1: number, y: number, x2: number): void { + doc.setLineDashPattern( + [ + 0.4, + 0.6 + ], + 0 + ); + doc.setLineWidth(0.15); + doc.line(x1, y, x2, y); + doc.setLineDashPattern([], 0); +} + +function drawPageFrame(doc: jsPDF, input: BalanceSheetPdfInput, page: number, pageCount: number): void { + doc.setTextColor(...INK); + + doc.setFont('helvetica', 'bold').setFontSize(9.5); + doc.text(`${input.bankName.toUpperCase()} , HEAD OFFICE`, PAGE_W / 2, 11, { align: 'center' }); + + doc.setFontSize(8); + doc.text(`CONSOLIDATED BALANCE SHEET (SUB GROUP WISE) AS ON : ${ddmmyyyy(input.asOnDate)}`, PAGE_W / 2, 16, { + align: 'center' + }); + doc.setFont('helvetica', 'normal').setFontSize(7); + doc.text(`Page ${page} of ${pageCount}`, PAGE_W - MARGIN, 16, { align: 'right' }); + doc.text(`FOR BRANCHES : ${input.officeLabel}`, PAGE_W / 2, 20, { align: 'center' }); + + dottedLine(doc, MARGIN, 23, PAGE_W - MARGIN); + + // Side titles and column headings, drawn per column. + const titles = [ + 'LIABILITIES ( CREDIT )', + 'ASSETS ( DEBIT )' + ]; + for (let c = 0; c < 2; c++) { + const x = COL_X[c]; + doc.setFont('helvetica', 'bold').setFontSize(7.5); + doc.text(titles[c], x + 1, 27.5); + doc.setFontSize(6.5); + doc.text('ACCOUNT NAME', x + 1, 32); + doc.text('BALANCE', x + BALANCE_R, 32, { align: 'right' }); + doc.text('TOTAL', x + TOTAL_R, 32, { align: 'right' }); + dottedLine(doc, x + 1, 33.4, x + COL_W - 2); + } + + // The divider between the two sides. + doc.setLineWidth(0.15); + doc.setDrawColor(150, 150, 150); + doc.line(COL_X[1], 24, COL_X[1], BODY_BOTTOM + 2); + doc.setDrawColor(0, 0, 0); + + // Footer. + dottedLine(doc, MARGIN, BODY_BOTTOM + 6, PAGE_W - MARGIN); + doc + .setFont('helvetica', 'normal') + .setFontSize(6.5) + .setTextColor(...MUTED); + const now = new Date(); + const stamp = + `${`${now.getDate()}`.padStart(2, '0')}/${`${now.getMonth() + 1}`.padStart(2, '0')}/${now.getFullYear()} ` + + `${`${now.getHours()}`.padStart(2, '0')}:${`${now.getMinutes()}`.padStart(2, '0')}:${`${now.getSeconds()}`.padStart(2, '0')}`; + doc.text(`Print Date : ${stamp}`, MARGIN, BODY_BOTTOM + 10); + doc.text('Consolidated Balance Sheet (Sub Group Wise)', PAGE_W - MARGIN, BODY_BOTTOM + 10, { align: 'right' }); + doc.setTextColor(...INK); +} + +/** Draws one line into a column at y, and returns the y the next line starts at. */ +function drawLine(doc: jsPDF, line: Line, x: number, y: number): number { + switch (line.kind) { + case 'block': { + doc.setFont('helvetica', 'bold').setFontSize(7); + doc.text(line.text.toUpperCase(), x + 1, y); + dottedLine(doc, x + 1, y + 1.1, x + 34); + return y + heightOf(line); + } + case 'subhead': { + doc.setFont('helvetica', 'bold').setFontSize(6.8); + doc.text(line.text, x + 1 + line.depth * 2, y); + return y + heightOf(line); + } + case 'detail': { + doc.setFont('helvetica', 'normal').setFontSize(6.8); + const indent = x + 2 + line.depth * 2; + line.lines.forEach((text, i) => doc.text(text, indent, y + i * LINE_H)); + // The amount sits on the first line of a wrapped name, as the printed report has it. + doc.text(line.amount, x + BALANCE_R, y, { align: 'right' }); + return y + heightOf(line); + } + case 'subsubtotal': { + doc.setFont('helvetica', 'bold').setFontSize(6.8); + dottedLine(doc, x + BALANCE_R - 22, y - 2.2, x + BALANCE_R); + doc.text(line.amount, x + BALANCE_R, y, { align: 'right' }); + return y + heightOf(line); + } + case 'subtotal': { + doc.setFont('helvetica', 'bold').setFontSize(7); + doc.text(line.amount, x + TOTAL_R, y, { align: 'right' }); + return y + heightOf(line); + } + case 'standalone': { + doc.setFont('helvetica', 'bold').setFontSize(7); + line.lines.forEach((text, i) => doc.text(text, x + 1, y + i * LINE_H)); + doc.text(line.amount, x + BALANCE_R, y, { align: 'right' }); + doc.text(line.amount, x + TOTAL_R, y, { align: 'right' }); + return y + heightOf(line); + } + case 'grand': { + const top = y + 1.5; + doc.setLineWidth(0.3); + doc.line(x + 1, top, x + COL_W - 2, top); + doc.setFont('helvetica', 'bold').setFontSize(7.5); + doc.text(line.amount, x + TOTAL_R, top + 3.6, { align: 'right' }); + doc.setLineWidth(0.2); + doc.line(x + 1, top + 5, x + COL_W - 2, top + 5); + doc.line(x + 1, top + 5.7, x + COL_W - 2, top + 5.7); + return y + heightOf(line); + } + default: + return y + heightOf(line); + } +} + +/** Splits one side's lines into per-page chunks that fit between BODY_TOP and BODY_BOTTOM. */ +function paginate(lines: Line[]): Line[][] { + const pages: Line[][] = []; + let current: Line[] = []; + let y = BODY_TOP; + + for (const line of lines) { + const h = heightOf(line); + if (y + h > BODY_BOTTOM) { + pages.push(current); + current = []; + y = BODY_TOP; + } + current.push(line); + y += h; + } + // Keep the trailing chunk only if it has something on it, but always return at least one page so + // a side with no balances still gets its heading and a zero total. + if (current.length > 0 || pages.length === 0) { + pages.push(current); + } + return pages; +} + +export function buildBalanceSheetPdf(input: BalanceSheetPdfInput): jsPDF { + const doc = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4', compress: true }); + + const creditPages = paginate(flatten(doc, input.creditSide)); + const debitPages = paginate(flatten(doc, input.debitSide)); + // Both sides print side by side, so the sheet runs as long as the longer of the two, plus a + // final page for the grand totals when they would not fit under the last block. + const pageCount = Math.max(creditPages.length, debitPages.length); + + for (let p = 0; p < pageCount; p++) { + if (p > 0) { + doc.addPage(); + } + drawPageFrame(doc, input, p + 1, pageCount); + + const sides = [ + creditPages[p] ?? [], + debitPages[p] ?? [] + ]; + + for (let c = 0; c < 2; c++) { + const x = COL_X[c]; + let y = BODY_TOP; + for (const line of sides[c]) { + y = drawLine(doc, line, x, y); + } + } + + // Sign-off row, on the closing page only. + if (p === pageCount - 1) { + doc + .setFont('helvetica', 'normal') + .setFontSize(6.5) + .setTextColor(...MUTED); + const roles = [ + 'CLERK', + 'ACCOUNTANT / OFFICER', + 'ASST. GEN MANAGER', + 'GENERAL MANAGER', + 'CHAIRMAN' + ]; + const step = (PAGE_W - MARGIN * 2) / roles.length; + roles.forEach((role, i) => doc.text(role, MARGIN + step * i + step / 2, BODY_BOTTOM + 2, { align: 'center' })); + doc.setTextColor(...INK); + } + } + + return doc; +} diff --git a/src/app/accounting/balance-sheet/balance-sheet.component.html b/src/app/accounting/balance-sheet/balance-sheet.component.html new file mode 100644 index 000000000..883d4bcb5 --- /dev/null +++ b/src/app/accounting/balance-sheet/balance-sheet.component.html @@ -0,0 +1,182 @@ + + +
+ +
+ + + + + @if (!loading && debitSide && creditSide) { + + {{ tallied ? 'Balanced' : 'Out of balance by ' + fmt(difference) }} + + } + + +
+ + @if (error) { +
{{ error }}
+ } + + @if (loading) { +
Loading balance sheet…
+ } + + @if (!loading && debitSide && creditSide) { + +
+
+

CONSOLIDATED BALANCE SHEET (SUB GROUP WISE)

+

AS ON : {{ displayDate(asOnDateLoaded) }}

+

FOR BRANCHES : {{ officeLabel }}

+
+ +
+ +
+
LIABILITIES ( CREDIT )
+
+ ACCOUNT NAME + BALANCE + TOTAL +
+ @for (group of creditSide.groups; track group.classification) { + @for (root of group.nodes; track root.id) { + + } + } + @if (creditSide.groups.length === 0) { +
No liability, equity or income balances as on this date.
+ } +
+ + + {{ fmt(creditSide.total) }} +
+
+ +
+
ASSETS ( DEBIT )
+
+ ACCOUNT NAME + BALANCE + TOTAL +
+ @for (group of debitSide.groups; track group.classification) { + @for (root of group.nodes; track root.id) { + + } + } + @if (debitSide.groups.length === 0) { +
No asset or expense balances as on this date.
+ } +
+ + + {{ fmt(debitSide.total) }} +
+
+
+ +
+ CLERK + ACCOUNTANT / OFFICER + ASST. GEN MANAGER + GENERAL MANAGER + CHAIRMAN +
+
+ } +
+ + + + @if (root.usage === 'DETAIL') { + +
+
+ {{ root.name }} + {{ fmt(root.balance) }} + {{ fmt(root.balance) }} +
+
+ } @else { +
+
{{ root.name }}
+
+ @for (child of root.children; track child.id) { + + } + @if (root.children.length === 0) { +
+ No accounts under this group + + +
+ } +
+ + + {{ fmt(root.balance) }} +
+
+ } +
+ + + + @if (node.usage === 'DETAIL') { +
+ {{ node.name }} + {{ fmt(node.balance) }} + +
+ } @else { +
+ {{ node.name }} + + +
+ @for (child of node.children; track child.id) { + + } +
+ + {{ fmt(node.balance) }} + +
+ } +
diff --git a/src/app/accounting/balance-sheet/balance-sheet.component.scss b/src/app/accounting/balance-sheet/balance-sheet.component.scss new file mode 100644 index 000000000..3bbfe893b --- /dev/null +++ b/src/app/accounting/balance-sheet/balance-sheet.component.scss @@ -0,0 +1,372 @@ +/** + * 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/. + */ + +$ink: #111827; +$ink-soft: #374151; +$ink-faint: #9ca3af; +$rule: #cbd5e1; +$border: #e2e8f0; +$accent: #1d4ed8; +$ok: #15803d; +$ok-soft: #f0fdf4; +$danger: #b91c1c; +$danger-soft: #fef2f2; + +// Amount columns share one width so BALANCE and TOTAL line up down the whole sheet. +$amt-col: 128px; + +.bs-page { + padding: 20px 24px 40px; + background: #f1f5f9; + min-height: 100%; +} + +// ── Controls (screen only) ──────────────────────────────────────────────── +.bs-controls { + display: flex; + align-items: flex-end; + gap: 14px; + flex-wrap: wrap; + margin-bottom: 18px; + + &__spacer { + flex: 1 1 auto; + } +} + +.bs-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $ink-soft; + + input, + select { + height: 36px; + padding: 0 10px; + border: 1px solid $border; + border-radius: 6px; + background: #fff; + font-size: 13px; + font-weight: 500; + text-transform: none; + letter-spacing: normal; + color: $ink; + } +} + +.bs-check { + display: flex; + align-items: center; + gap: 6px; + height: 36px; + font-size: 12.5px; + font-weight: 600; + color: $ink-soft; + cursor: pointer; + user-select: none; + + input { + width: 15px; + height: 15px; + cursor: pointer; + accent-color: $accent; + } +} + +.bs-btn { + height: 36px; + padding: 0 16px; + border: 1px solid $border; + border-radius: 6px; + background: #fff; + color: $ink-soft; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; + + &--primary { + background: $ink; + border-color: $ink; + color: #fff; + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.bs-tally { + padding: 6px 12px; + border-radius: 999px; + background: $ok-soft; + color: $ok; + font-size: 12px; + font-weight: 700; + + &--off { + background: $danger-soft; + color: $danger; + } +} + +.bs-alert { + padding: 10px 14px; + border-radius: 6px; + background: $danger-soft; + color: $danger; + border: 1px solid #fecaca; + font-size: 13px; + margin-bottom: 14px; +} + +.bs-loading { + padding: 48px 0; + text-align: center; + color: $ink-faint; + font-size: 13.5px; +} + +// ── The printed sheet ───────────────────────────────────────────────────── +.bs-sheet { + background: #fff; + border: 1px solid $border; + padding: 22px 26px 18px; + // The ledger reads as a printed report, so the whole sheet is set in a mono face with + // figures on tabular numerals — columns stay aligned however long the names run. + font-family: 'DejaVu Sans Mono', 'Liberation Mono', Consolas, 'Courier New', monospace; + font-variant-numeric: tabular-nums; + color: $ink; + + &__head { + text-align: center; + border-bottom: 1px dotted $rule; + padding-bottom: 10px; + margin-bottom: 12px; + + h2 { + margin: 0; + font-size: 13.5px; + font-weight: 700; + letter-spacing: 0.04em; + } + + p { + margin: 4px 0 0; + font-size: 11.5px; + color: $ink-soft; + } + } + + &__branches { + letter-spacing: 0.05em; + } + + &__body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + } + + &__foot { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 26px; + padding-top: 10px; + border-top: 1px dotted $rule; + font-size: 10px; + letter-spacing: 0.05em; + color: $ink-soft; + } +} + +.bs-col { + padding: 0 16px; + min-width: 0; + + &:first-child { + border-right: 1px dotted $rule; + padding-left: 0; + } + + &:last-child { + padding-right: 0; + } + + &__title { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; + padding-bottom: 6px; + } +} + +// ── Rows: one grid so name / BALANCE / TOTAL align across every block ────── +.bs-row { + display: grid; + grid-template-columns: minmax(0, 1fr) $amt-col $amt-col; + gap: 8px; + align-items: baseline; + font-size: 11.5px; + line-height: 1.55; + + > span:nth-child(2), + > span:nth-child(3) { + text-align: right; + white-space: nowrap; + } + + &--colhead { + font-weight: 700; + font-size: 10.5px; + letter-spacing: 0.05em; + border-bottom: 1px dotted $rule; + padding-bottom: 4px; + margin-bottom: 6px; + color: $ink-soft; + } + + &--line { + color: $ink-soft; + } + + // A nested Header inside a block: its own named sub-group. + &--subhead { + font-weight: 700; + color: $ink; + padding-top: 3px; + } + + // Sub-group subtotal sits in the BALANCE column, so it never collides with the + // block's own figure in TOTAL. + &--subsubtotal { + border-top: 1px dotted $rule; + font-weight: 700; + color: $accent; + } + + // Top-level block subtotal, in the TOTAL column. + &--subtotal { + font-weight: 700; + color: $ink; + padding-top: 2px; + } + + // A Header-less Detail account: its own one-line group. + &--standalone { + font-weight: 700; + color: $ink; + + .bs-name { + text-transform: uppercase; + letter-spacing: 0.03em; + } + } + + &--grand { + margin-top: 14px; + padding-top: 8px; + border-top: 1px solid $ink; + border-bottom: 3px double $ink; + font-weight: 700; + font-size: 12.5px; + } +} + +.bs-name { + overflow-wrap: anywhere; + + &--muted { + color: $ink-faint; + font-style: italic; + } +} + +.bs-amt { + font-variant-numeric: tabular-nums; +} + +.bs-block { + margin-bottom: 14px; + + &--single { + margin-bottom: 10px; + } + + &__name { + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + // The short dashed rule the printed sheet puts under every group heading. + &__rule { + width: 130px; + border-top: 1px dotted $ink-faint; + margin: 2px 0 4px; + } +} + +.bs-empty { + padding: 14px 0; + font-size: 11.5px; + color: $ink-faint; +} + +// ── Narrow screens: stack the two sides instead of squeezing the columns ─── +@media (width <= 1100px) { + .bs-sheet__body { + grid-template-columns: 1fr; + } + + .bs-col { + padding: 0; + + &:first-child { + border-right: none; + border-bottom: 1px dotted $rule; + padding-bottom: 16px; + margin-bottom: 16px; + } + } +} + +// ── Print: only the sheet, at report proportions ─────────────────────────── +@media print { + .bs-controls, + .bs-alert, + .bs-loading { + display: none !important; + } + + .bs-page { + padding: 0; + background: #fff; + } + + .bs-sheet { + border: none; + padding: 0; + font-size: 10px; + } + + .bs-sheet__body { + grid-template-columns: 1fr 1fr; + } + + .bs-block { + break-inside: avoid; + } +} diff --git a/src/app/accounting/balance-sheet/balance-sheet.component.ts b/src/app/accounting/balance-sheet/balance-sheet.component.ts new file mode 100644 index 000000000..fff932b9e --- /dev/null +++ b/src/app/accounting/balance-sheet/balance-sheet.component.ts @@ -0,0 +1,218 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { AccountingService } from '../accounting.service'; +import { OrganizationService } from '../../organization/organization.service'; +import { buildBalanceSheetPdf } from './balance-sheet-pdf'; + +interface BalanceSheetNode { + id: number; + name: string; + glCode: string; + usage: 'HEADER' | 'DETAIL'; + balance: number; + children: BalanceSheetNode[]; +} + +interface BalanceSheetGroup { + classification: string; + total: number; + nodes: BalanceSheetNode[]; +} + +interface BalanceSheetSide { + label: string; + total: number; + groups: BalanceSheetGroup[]; +} + +/** + * Consolidated Balance Sheet (sub-group wise), laid out the way the bank's own printed balance + * sheet reads: liabilities on the left, assets on the right, each top-level Header account a named + * block whose Detail accounts are listed under it and subtotalled in the TOTAL column. + * + * Balances come from BalanceSheetApiResource, which reads the live ledger - a Detail account's own + * postings, and a Header's as the recursive sum of its children (a Header never carries a posting + * of its own). A Detail account that sits under no Header at all is shown as its own one-line block + * rather than being dropped. + */ +@Component({ + selector: 'mifosx-balance-sheet', + standalone: true, + imports: [ + CommonModule, + FormsModule + ], + templateUrl: './balance-sheet.component.html', + styleUrl: './balance-sheet.component.scss' +}) +export class BalanceSheetComponent implements OnInit { + private accountingService = inject(AccountingService); + private organizationService = inject(OrganizationService); + + /** Printed at the head of the PDF. Falls back to a neutral title if the profile is not set up. */ + bankName = 'Bank'; + + asOnDate: string = new Date().toISOString().slice(0, 10); + officeId: number | null = null; + offices: { id: number; name: string }[] = []; + + loading = false; + error = ''; + asOnDateLoaded = ''; + + /** What the server returned, kept whole so the zero-balance toggle never needs a refetch. */ + private rawDebitSide: BalanceSheetSide | null = null; + private rawCreditSide: BalanceSheetSide | null = null; + + debitSide: BalanceSheetSide | null = null; + creditSide: BalanceSheetSide | null = null; + + /** + * A working chart of accounts carries far more accounts than a bank actually operates, and a + * printed balance sheet lists only the ones carrying a balance — leaving every nil line in makes + * the sheet unreadable. On by default for that reason; switch it off to audit the full chart. + */ + hideZeroBalances = true; + + /** Both sides are derived from the same double-entry ledger, so they can only disagree if a + * posting is missing a leg — worth surfacing rather than hiding. */ + get tallied(): boolean { + if (!this.debitSide || !this.creditSide) { + return true; + } + return Math.abs(this.debitSide.total - this.creditSide.total) < 0.01; + } + + get difference(): number { + if (!this.debitSide || !this.creditSide) { + return 0; + } + return Math.abs(this.debitSide.total - this.creditSide.total); + } + + get officeLabel(): string { + if (this.officeId == null) { + return 'ALL BRANCHES'; + } + return (this.offices.find((o) => o.id === this.officeId)?.name ?? '').toUpperCase(); + } + + ngOnInit(): void { + this.accountingService.getOffices().subscribe({ + next: (offices) => (this.offices = offices || []), + error: () => (this.offices = []) + }); + this.organizationService.getInstitutionProfile().subscribe({ + next: (profile: any) => { + this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName; + }, + // A missing institution profile must not stop the sheet from loading — the PDF just carries + // the fallback title. + error: () => undefined + }); + this.load(); + } + + load(): void { + this.loading = true; + this.error = ''; + this.accountingService.getBalanceSheet(this.asOnDate, this.officeId ?? undefined).subscribe({ + next: (res: any) => { + this.rawDebitSide = res.debitSide; + this.rawCreditSide = res.creditSide; + this.asOnDateLoaded = res.asOnDate; + this.applyZeroFilter(); + this.loading = false; + }, + error: () => { + this.error = 'Could not load the balance sheet for this date.'; + this.loading = false; + } + }); + } + + /** + * Rebuilds the displayed sides from what the server sent, dropping nil lines when asked. The + * totals are deliberately left as the server computed them — hiding a nil line must never change + * a figure, and a group total is unaffected by removing children that summed to nothing. + */ + applyZeroFilter(): void { + this.debitSide = this.filterSide(this.rawDebitSide); + this.creditSide = this.filterSide(this.rawCreditSide); + } + + private filterSide(side: BalanceSheetSide | null): BalanceSheetSide | null { + if (!side) { + return null; + } + if (!this.hideZeroBalances) { + return side; + } + const groups = side.groups + .map((group) => ({ + ...group, + nodes: group.nodes + .map((node) => this.pruneZeros(node)) + .filter((node): node is BalanceSheetNode => node !== null) + })) + .filter((group) => group.nodes.length > 0); + return { ...side, groups }; + } + + /** Drops a node whose balance is nil, and any Header left with nothing under it. */ + private pruneZeros(node: BalanceSheetNode): BalanceSheetNode | null { + if (Math.abs(node.balance) < 0.005) { + return null; + } + const children = node.children + .map((child) => this.pruneZeros(child)) + .filter((child): child is BalanceSheetNode => child !== null); + return { ...node, children }; + } + + /** Indian grouping with two decimals, matching the printed report (2,76,92,608.00). */ + fmt(value: number): string { + return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0); + } + + /** dd/MM/yyyy, as the printed report dates it. */ + displayDate(iso: string): string { + if (!iso) { + return ''; + } + const [ + y, + m, + d + ] = iso.split('-'); + return `${d}/${m}/${y}`; + } + + /** + * Builds the report as a real PDF rather than printing the page: window.print() takes the whole + * app shell with it and breaks pages wherever they happen to fall, which is what made the earlier + * output unusable. + */ + downloadPdf(): void { + if (!this.debitSide || !this.creditSide) { + return; + } + const doc = buildBalanceSheetPdf({ + bankName: this.bankName, + officeLabel: this.officeLabel, + asOnDate: this.asOnDateLoaded || this.asOnDate, + creditSide: this.creditSide, + debitSide: this.debitSide + }); + doc.save(`balance-sheet-${this.asOnDateLoaded || this.asOnDate}.pdf`); + } +} diff --git a/src/app/accounting/chart-of-accounts/create-gl-account/create-gl-account.component.html b/src/app/accounting/chart-of-accounts/create-gl-account/create-gl-account.component.html index 438df32b8..abff82456 100644 --- a/src/app/accounting/chart-of-accounts/create-gl-account/create-gl-account.component.html +++ b/src/app/accounting/chart-of-accounts/create-gl-account/create-gl-account.component.html @@ -119,7 +119,7 @@ + + + } @else { + +
+ account_balance +
+
Cash GL Account
+
({{ glStatus.glCode }}) {{ glStatus.glAccountName }}
+ @if (glHasBalance) { +
+ Already holds + ₹{{ glTarget.toLocaleString('en-IN') }} + — count the drawer to exactly this amount. +
+ } @else { +
+ No opening balance posted yet — whatever you count below becomes this account's opening balance. +
+ } +
+
+ + + + +
+
Denomination
+
Count
+
Amount
+
+ @for (d of allDenoms; track d.id) { +
+
+ {{ d.label }} {{ d.kind }} +
+
+ + + +
+
₹{{ subtotalFor(d).toLocaleString('en-IN') }}
+
+ } +
+
+ + +
+
+ Counted Total + ₹{{ countedTotal.toLocaleString('en-IN') }} +
+ @if (glHasBalance) { +
+ GL Target + ₹{{ glTarget.toLocaleString('en-IN') }} +
+ } +
+ @if (countedTotal <= 0) { + edit_note + Count each denomination above + } @else if (isReconciled) { + check_circle + {{ glHasBalance ? 'Matches the GL balance' : 'Will define the GL opening balance' }} + } @else { + error + Off by ₹{{ + (countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString( + 'en-IN' + ) + }} + } +
+
+ + @if (needsDifferenceReason) { + + + + Why does the count differ from the GL balance? + + + + + } + + @if (errorMsg) { +
{{ errorMsg }}
+ } + + @if (successMsg) { +
+ check_circle +
+ {{ successMsg }} +
+ +
+
+
+ } + + @if (!successMsg) { +
+ +
+ } + } + } + diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss new file mode 100644 index 000000000..c68d75abd --- /dev/null +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss @@ -0,0 +1,316 @@ +/** + * 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/. + */ + +.mcd { + max-width: 760px; +} + +.mcd-card { + margin-bottom: 14px; + border-radius: 8px; + + &--flush mat-card-content { + padding: 0; + } +} + +.mcd-intro { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 14px; + margin-bottom: 14px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 8px; + color: #1e40af; + font-size: 12px; + line-height: 1.5; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + flex: none; + } +} + +.mcd-row { + display: flex; + align-items: flex-start; + gap: 16px; + flex-wrap: wrap; +} + +.mcd-office { + flex: 1 1 280px; +} + +.mcd-field { + flex: 1 1 200px; +} + +.mcd-loading { + padding: 10px 14px; + margin-bottom: 14px; + font-size: 12px; + color: #64748b; +} + +.mcd-gl { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 14px; + margin-bottom: 16px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-left: 4px solid #0ea5e9; + border-radius: 8px; + + mat-icon { + color: #0ea5e9; + margin-top: 2px; + } + + &--fresh { + border-left-color: #f59e0b; + + mat-icon { + color: #f59e0b; + } + } + + &--error { + border-left-color: #dc2626; + background: #fef2f2; + + mat-icon { + color: #dc2626; + } + } + + &__label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + color: #64748b; + } + + &__value { + font-size: 13px; + font-weight: 600; + color: #0d1b2a; + margin: 2px 0 4px; + } + + &__hint { + font-size: 11.5px; + color: #64748b; + line-height: 1.4; + } + + button { + margin-top: 10px; + } +} + +.mcd-grid { + display: grid; + grid-template-columns: 1fr 160px 120px; + align-items: center; + gap: 8px; + padding: 10px 14px; + + &--head { + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + color: #64748b; + } + + &--row { + border-bottom: 1px solid #f1f5f9; + } + + &__denom { + font-size: 13px; + font-weight: 600; + color: #0d1b2a; + } + + &__kind { + font-size: 10px; + font-weight: 500; + color: #94a3b8; + margin-left: 6px; + text-transform: uppercase; + } + + &__count { + display: flex; + align-items: center; + gap: 2px; + + button { + width: 28px; + height: 28px; + line-height: 28px; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + } + } + + &__input { + /* Width is set inline per row (see countInputWidthCh()), growing with the digit count typed — + min-width here only covers the fallback before Angular has bound anything. */ + min-width: 48px; + max-width: 96px; + transition: width 0.1s ease-out; + text-align: center; + font-variant-numeric: tabular-nums; + border: 1px solid #e2e8f0; + border-radius: 4px; + padding: 4px 2px; + font-size: 13px; + } + + &__amt { + font-variant-numeric: tabular-nums; + font-weight: 600; + font-size: 13px; + text-align: right; + color: #0d1b2a; + } +} + +.mcd-balance { + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; + padding: 12px 16px; + margin-bottom: 14px; + background: #fef2f2; + border: 1px solid #fecaca; + border-left: 4px solid #dc2626; + border-radius: 8px; + + &--ok { + background: #f0fdf4; + border-color: #bbf7d0; + border-left-color: #16a34a; + } + + &__figure { + display: flex; + flex-direction: column; + gap: 2px; + } + + &__label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.02em; + color: #64748b; + } + + &__amt { + font-variant-numeric: tabular-nums; + font-weight: 700; + font-size: 16px; + white-space: nowrap; + color: #0d1b2a; + } + + &__status { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + font-size: 13px; + font-weight: 600; + color: #b91c1c; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + + &--ok &__status { + color: #15803d; + } +} + +.mcd-reason { + width: 100%; +} + +.mcd-error { + padding: 10px 14px; + margin-bottom: 14px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #b91c1c; + font-size: 12.5px; +} + +.mcd-success { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 14px 16px; + margin-bottom: 14px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 8px; + color: #14532d; + font-size: 13px; + + mat-icon { + color: #16a34a; + margin-top: 1px; + } + + &__actions { + margin-top: 10px; + } +} + +.mcd-actions { + display: flex; + justify-content: flex-end; + padding-bottom: 24px; +} + +@media (width <= 560px) { + .mcd-grid { + grid-template-columns: 1fr auto; + row-gap: 4px; + + &--head { + display: none; + } + + &__amt { + grid-column: 1 / -1; + text-align: left; + } + } +} diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts new file mode 100644 index 000000000..2509a0fce --- /dev/null +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts @@ -0,0 +1,262 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { ActivatedRoute, Router } from '@angular/router'; +import { catchError, of } from 'rxjs'; +import { CashierService } from '../../cashier/cashier.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +interface Denom { + id: number; + value: number; + kind: 'note' | 'coin'; + label: string; +} + +/** + * Migrate Cash Denomination — the vault-side counterpart to Migrate Opening Balances. + * + * A freshly set-up institution's chart of accounts carries a "Cash at Vault" GL balance (defined via + * Migrate Opening Balances, or not yet defined at all), but that figure is meaningless to a cashier + * standing at the drawer until it is broken into actual notes and coins, and the branch has nowhere + * to track that breakdown until its first business day is open. This screen is the one place both of + * those happen together: count the drawer, and it either defines the cash GL's opening balance from + * that count (nothing posted to it yet) or checks the count against what is already posted, then + * opens the business day with that same breakdown - so a new bank goes from "GL seeded" straight to + * "vault countable" without a separate trip through Manage Tellers or Open Business Day first. + */ +@Component({ + selector: 'mifosx-migrate-cash-denomination', + templateUrl: './migrate-cash-denomination.component.html', + styleUrls: ['./migrate-cash-denomination.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + ...STANDALONE_SHARED_IMPORTS + ] +}) +export class MigrateCashDenominationComponent implements OnInit { + private service = inject(CashierService); + private settingsService = inject(SettingsService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + officeData: any[] = []; + officeId: number | null = null; + /** Bound to the datepicker as a Date, converted to a strict yyyy-MM-dd string only when + * submitting — the branchday endpoints parse this with LocalDate.parse() directly, with no + * locale/dateFormat hint alongside it, so a locale-formatted string (what dateUtils.formatDate + * produces) is rejected outright. */ + businessDate: Date = new Date(); + maxDate = new Date(); + + glLoading = false; + glStatus: any = null; + glError = ''; + + /** Set once per office change: whether TODAY's business day is already open for this office. This + * screen is a one-time "day 1" tool (see the class doc) - once a day has actually been opened, + * reopening the same one is exactly what BranchDayRegisterApiResource#migrateCashDenomination + * itself refuses (its own openDayInternal call rejects a day that is already open), so this is + * checked here too, before the user counts an entire drawer only to have the submit rejected. */ + todayAlreadyOpen = false; + todayOpenInfo: any = null; + + notes: Denom[] = []; + coins: Denom[] = []; + counts: Record = {}; + + submitting = false; + errorMsg = ''; + successMsg = ''; + result: any = null; + + /** Only asked for when the counted total disagrees with an already-posted GL balance — mirrors + * the same "explain the gap" gate Open Business Day applies. */ + differenceReason = ''; + + get allDenoms(): Denom[] { + return [ + ...this.notes, + ...this.coins + ]; + } + + ngOnInit(): void { + this.maxDate = this.settingsService.businessDate; + this.businessDate = this.maxDate; + + this.route.data.subscribe((data: { offices: any }) => { + this.officeData = data.offices || []; + }); + + this.service.getActiveDenominations().subscribe({ + next: (list) => { + const rows: Denom[] = (list || []).map((x: any) => ({ + id: x.id, + value: x.denomination, + kind: x.kind, + label: '₹' + x.denomination + })); + this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value); + this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value); + }, + error: () => { + this.notes = []; + this.coins = []; + } + }); + } + + onOfficeChange(): void { + this.glStatus = null; + this.glError = ''; + this.result = null; + this.successMsg = ''; + this.errorMsg = ''; + this.todayAlreadyOpen = false; + this.todayOpenInfo = null; + if (!this.officeId) return; + + this.glLoading = true; + this.service + .cashGlStatus(this.officeId) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.glLoading = false; + this.glStatus = res; + if (res && res.configured === false) { + this.glError = res.message || 'No cash GL account is configured for this tenant yet.'; + } + }); + + this.service + .branchDayRegister(this.officeId) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + const openDay = res?.openDay; + this.todayAlreadyOpen = !!openDay && openDay.businessDate === res?.today; + this.todayOpenInfo = this.todayAlreadyOpen ? openDay : null; + }); + } + + countFor(d: Denom): number { + return this.counts[d.id] || 0; + } + + subtotalFor(d: Denom): number { + return this.countFor(d) * d.value; + } + + onCountChange(d: Denom, raw: any): void { + this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0)); + } + + bumpCount(d: Denom, delta: number): void { + this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta); + } + + /** Widens the count box as the typed count grows more digits, so a large count (hundreds, + * thousands) is never clipped or hard to read inside a field sized for one or two digits. */ + countInputWidthCh(d: Denom): number { + return Math.max(3, String(this.countFor(d)).length + 2); + } + + get countedTotal(): number { + return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0); + } + + /** Whether this office's GL already carries a balance the count has to match, vs. an empty + * account the count itself will define. */ + get glHasBalance(): boolean { + return !!this.glStatus?.hasBalance; + } + + get glTarget(): number { + return Number(this.glStatus?.currentBalance ?? 0); + } + + get isReconciled(): boolean { + if (!this.glStatus || this.glStatus.configured === false) return false; + if (this.countedTotal <= 0) return false; + return !this.glHasBalance || this.countedTotal === this.glTarget; + } + + get needsDifferenceReason(): boolean { + return ( + this.glHasBalance && this.countedTotal > 0 && this.countedTotal !== this.glTarget && !this.differenceReason.trim() + ); + } + + get canSubmit(): boolean { + return ( + !!this.officeId && + !!this.businessDate && + !this.glError && + this.countedTotal > 0 && + !this.needsDifferenceReason && + !this.submitting + ); + } + + private denominationPayload() { + return this.allDenoms + .filter((d) => this.countFor(d) > 0) + .map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) })); + } + + /** Strict yyyy-MM-dd, independent of the viewer's locale/date-format setting — see the comment on + * `businessDate` for why this can't go through dateUtils.formatDate(). */ + private toIsoDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + } + + submit(): void { + if (!this.canSubmit || !this.officeId) return; + this.submitting = true; + this.errorMsg = ''; + this.successMsg = ''; + this.service + .migrateCashDenomination( + this.officeId, + this.toIsoDate(this.businessDate), + this.denominationPayload(), + this.differenceReason.trim() || undefined + ) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.message ?? + 'Could not migrate cash denomination.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.submitting = false; + if (res) { + this.result = res; + this.successMsg = res.glOpeningBalancePosted + ? `Cash GL opening balance defined at ₹${this.countedTotal.toLocaleString('en-IN')} and business day ${res.businessDate} opened.` + : `Business day ${res.businessDate} opened — count matched the existing GL balance.`; + } + }); + } + + goToBranchDay(): void { + this.router.navigate(['/branch-ops/day-register']); + } +} diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html index 339dfc4bb..ac06c467a 100644 --- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html +++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html @@ -6,48 +6,67 @@ 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 (openingBalancesForm.controls.officeId.hasError('required')) { - - {{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - -
- -
- -
+
+ info + + Bring a branch's existing balances into this system's ledger, one time, as a single balanced entry against + the Opening Balances Contra account below. +
+
+ + {{ 'labels.inputs.Office' | translate }} + + @for (office of officeData; track office) { + + {{ office.name }} + + } + + @if (openingBalancesForm.controls.officeId.hasError('required')) { + + {{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + +
+ + - @if (openingBalancesData) { -
- - - + @if (openingBalancesData) { + + + +
+ account_balance +
+
Opening Balances Contra Account
+
+ +
+
+ Every debit and credit below posts against this one account, so the whole entry always balances to zero. +
+
+
+ +
+ {{ 'labels.inputs.Currency' | translate }} @for (currency of currencyData; track currency) { @@ -63,7 +82,7 @@ } - + {{ 'labels.inputs.Opening Balances Date' | translate }} } -
-
- {{ 'labels.inputs.Type' | translate }} -
-
- {{ 'labels.inputs.GL Code' | translate }} -
-
- {{ 'labels.inputs.GL Name' | translate }} - {{ 'labels.inputs.Account' | translate }} -
-
- {{ 'labels.inputs.Total Debit' | translate }}: - {{ debitsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} -
-
- {{ 'labels.inputs.Total Credit' | translate }}: - {{ creditsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} -
+
+ + + + +
+
+ {{ 'labels.inputs.Total Debit' | translate }} + {{ + debitsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2' + }} +
+
+ {{ 'labels.inputs.Total Credit' | translate }} + {{ + creditsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2' + }} +
+
+ @if (debitsSum === creditsSum && debitsSum > 0) { + check_circle + Balanced — ready to submit + } @else if (debitsSum === 0 && creditsSum === 0) { + edit_note + Enter each account's opening balance below + } @else { + error + Out of balance by {{ debitsSum - creditsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} + } +
+
+ + + + +
+
{{ 'labels.inputs.GL Code' | translate }}
+
+ {{ 'labels.inputs.GL Name' | translate }} {{ 'labels.inputs.Account' | translate }}
- @for (credit of glAccountEntries.controls; track credit; let i = $index) { -
-
-
- {{ - openingBalancesData.glAccounts[i].glAccountType.value !== - openingBalancesData.glAccounts[i - 1]?.glAccountType.value - ? glAccountTypeLabel(openingBalancesData.glAccounts[i].glAccountType.value) - : '' - }} -
-
- {{ openingBalancesData.glAccounts[i].glAccountCode }} -
-
- {{ openingBalancesData.glAccounts[i].glAccountName }} -
-
- - {{ 'labels.inputs.Debit' | translate }} - - -
-
- - {{ 'labels.inputs.Credit' | translate }} - - -
-
+
{{ 'labels.inputs.Debit' | translate }}
+
{{ 'labels.inputs.Credit' | translate }}
+
+ + @for (credit of glAccountEntries.controls; track credit; let i = $index) { + @if ( + openingBalancesData.glAccounts[i].glAccountType.value !== + openingBalancesData.glAccounts[i - 1]?.glAccountType.value + ) { +
+ {{ glAccountTypeLabel(openingBalancesData.glAccounts[i].glAccountType.value) }}
} -
- } -
+
+
+
{{ openingBalancesData.glAccounts[i].glAccountCode }}
+
+ {{ openingBalancesData.glAccounts[i].glAccountName }} + @if (isDenominationManaged(openingBalancesData.glAccounts[i].glAccountId)) { + + lock + Migrate Cash Denomination + + } +
+
+ + + +
+
+ + + +
+
+
+ } + +
- @if (openingBalancesData) { - - - - - } - - +
+ + + @if (!(debitsSum === creditsSum && debitsSum > 0)) { + Total Debit must equal Total Credit before this can be submitted + } + +
+ } +
diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss index f31f93b1e..578f9a0ee 100644 --- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss +++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss @@ -6,14 +6,326 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.content { - padding-top: 20px; +.mob { + max-width: 960px; +} - div { - word-wrap: break-word; +.mob-card { + margin-bottom: 14px; + border-radius: 8px; - &.header { - font-weight: 500; + &--flush mat-card-content { + padding: 0; + } +} + +.mob-intro { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 14px; + margin-bottom: 14px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 8px; + color: #1e40af; + font-size: 12px; + line-height: 1.5; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + flex: none; + } +} + +.mob-row { + display: flex; + align-items: flex-start; + gap: 16px; + flex-wrap: wrap; +} + +.mob-office { + flex: 1 1 320px; +} + +.mob-field { + flex: 1 1 240px; +} + +.mob-contra { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 14px; + margin-bottom: 16px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-left: 4px solid #0ea5e9; + border-radius: 8px; + + mat-icon { + color: #0ea5e9; + margin-top: 2px; + } + + &__label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + color: #64748b; + } + + &__value { + font-size: 13px; + font-weight: 600; + color: #0d1b2a; + margin: 2px 0 4px; + } + + &__hint { + font-size: 11px; + color: #64748b; + line-height: 1.4; + } +} + +/* The one figure that decides whether this can be submitted — kept visually separate from the + cards around it so it reads as a status, not just another form section. */ +.mob-balance { + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; + padding: 12px 16px; + margin-bottom: 14px; + background: #fef2f2; + border: 1px solid #fecaca; + border-left: 4px solid #dc2626; + border-radius: 8px; + + &--ok { + background: #f0fdf4; + border-color: #bbf7d0; + border-left-color: #16a34a; + } + + &__figure { + display: flex; + flex-direction: column; + gap: 2px; + } + + &__label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.02em; + color: #64748b; + } + + &__amt { + font-variant-numeric: tabular-nums; + font-weight: 700; + font-size: 16px; + white-space: nowrap; + + &--dr { + color: #b91c1c; + } + + &--cr { + color: #15803d; + } + } + + &__status { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + font-size: 13px; + font-weight: 600; + color: #b91c1c; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + + &--ok &__status { + color: #15803d; + } +} + +/* Account entry grid: a header row, a section label per account type, then one row per account. */ +.mob-grid { + display: grid; + grid-template-columns: 90px 1fr 160px 160px; + align-items: center; + gap: 8px; + padding: 0 14px; + + &--head { + padding: 12px 14px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + color: #64748b; + } + + &__section { + padding: 8px 14px 4px; + margin-top: 6px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #0ea5e9; + border-top: 1px solid #e2e8f0; + + &:first-of-type { + border-top: none; + margin-top: 0; + } + } + + &--data { + padding-top: 4px; + padding-bottom: 4px; + border-bottom: 1px solid #f1f5f9; + } + + &__row { + display: contents; + } + + &__code { + font-variant-numeric: tabular-nums; + font-size: 12px; + color: #64748b; + } + + &__name { + font-size: 13px; + color: #0d1b2a; + word-break: break-word; + } + + /* Cash at Vault / Cash at Teller — locked, see MigrateOpeningBalancesComponent.isDenominationManaged. */ + &__lock { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 8px; + padding: 1px 8px 1px 6px; + border-radius: 999px; + background: #fff7ed; + color: #9a3412; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + vertical-align: middle; + + mat-icon { + font-size: 13px; + width: 13px; + height: 13px; + } + + a { + color: inherit; + text-decoration: underline; + } + } + + &__amt { + text-align: right; + } + + &__input { + width: 100%; + + ::ng-deep .mat-mdc-form-field-infix { + padding-top: 10px; + padding-bottom: 10px; + min-height: unset; + } + + ::ng-deep .mat-mdc-text-field-wrapper { + padding: 0 8px; + } + + input { + text-align: right; + font-variant-numeric: tabular-nums; + } + } +} + +.mob-actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + padding-bottom: 24px; +} + +.mob-actions__spacer { + flex: 1 1 auto; +} + +.mob-actions__hint { + font-size: 12px; + color: #b91c1c; +} + +@media (width <= 640px) { + .mob-grid { + grid-template-columns: 70px 1fr; + row-gap: 4px; + + &--head { + display: none; + } + + &__row { + display: grid; + grid-template-columns: 70px 1fr; + grid-template-areas: + 'code name' + 'debit debit' + 'credit credit'; + gap: 4px; + padding: 10px 0; + border-bottom: 1px solid #f1f5f9; + } + + &__code { + grid-area: code; + } + + &__name { + grid-area: name; + } + + &__amt:nth-of-type(1) { + grid-area: debit; + } + + &__amt:nth-of-type(2) { + grid-area: credit; + } + + &__amt { + text-align: left; } } } diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts index 923463829..0a4bcf291 100644 --- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts +++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts @@ -16,6 +16,8 @@ import { ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; /** Custom Services */ import { AccountingService } from '../accounting.service'; @@ -41,7 +43,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, GlAccountDisplayComponent, - CurrencyPipe + CurrencyPipe, + MatIconModule, + MatTooltipModule ] }) export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { @@ -72,6 +76,13 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { debitsSum = 0; /** Sum total of credits. */ creditsSum = 0; + /** GL account ids mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) — the + * account(s) Migrate Cash Denomination owns. Their opening balance is shown here for visibility + * but locked against editing: redefining it as a bare number here, instead of through a counted + * denomination breakdown, would let the GL figure drift away from what is physically in the till. + * The backend enforces the same rule independently (see defineOpeningBalance's + * validateNoDenominationManagedCashAccounts) — this is a convenience, not the source of truth. */ + denominationManagedGlAccountIds: number[] = []; /* Reference of search form */ @ViewChild('searchFormRef') searchFormRef: ElementRef; @@ -93,6 +104,22 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { this.officeData = data.offices; this.currencyData = data.currencies.selectedCurrencyOptions; }); + // Office-independent: which GL account(s) this tenant has mapped to Cash at Vault / Cash at + // Teller, so their row can be locked the moment the office's accounts are drawn. + this.accountingService.getFinancialActivityAccounts().subscribe((mappings: any[]) => { + this.denominationManagedGlAccountIds = (mappings || []) + .filter((mapping) => mapping.financialActivityData?.id === 101 || mapping.financialActivityData?.id === 102) + .map((mapping) => mapping.glAccountData?.id) + .filter((id) => id != null); + // Retrieve may already have run before this (fast) request came back — lock any row it + // missed rather than leaving it editable for the rest of this visit. + this.glAccountEntries?.controls.forEach((control) => { + const glAccountId = control.get('glAccountId')?.value; + if (this.isDenominationManaged(glAccountId) && control.enabled) { + control.disable(); + } + }); + }); } /** @@ -134,14 +161,31 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { * @returns {FormGroup} GL Account entry form. */ createGLAccountEntryForm(glAccount: any): UntypedFormGroup { - return this.formBuilder.group( + // A never-touched account arrives as a placeholder row (amount/entryType both null); one that + // already carries an opening balance arrives with its real, committed figure — show that + // instead of leaving the field looking blank. + const existingDebit = glAccount.entryType?.value === 'DEBIT' ? glAccount.amount : null; + const existingCredit = glAccount.entryType?.value === 'CREDIT' ? glAccount.amount : null; + const group = this.formBuilder.group( { glAccountId: [glAccount.glAccountId], - debit: [null], - credit: [null] + debit: [existingDebit], + credit: [existingCredit] }, { validator: onlyOneOfTheFieldsIsRequiredValidator } ); + if (this.isDenominationManaged(glAccount.glAccountId)) { + // Disabling excludes this row from the form's own .value, so it is never resubmitted here — + // the account keeps whatever Migrate Cash Denomination last set it to. + group.disable(); + } + return group; + } + + /** Whether this GL account is Cash at Vault / Cash at Teller — locked here, see + * denominationManagedGlAccountIds. */ + isDenominationManaged(glAccountId: number): boolean { + return this.denominationManagedGlAccountIds.includes(glAccountId); } /** @@ -174,17 +218,30 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { this.openingBalancesData = openingBalancesData; - entry.valueChanges.subscribe(() => { - this.debitsSum = 0; - this.creditsSum = 0; - entry.controls.forEach((value) => { - this.debitsSum += value.value.debit; - this.creditsSum += value.value.credit; - }); - }); + entry.valueChanges.subscribe(() => this.recomputeSums()); + // Runs once immediately too: valueChanges only fires on a later edit, and the locked cash + // row's own prefilled figure (set once, above, never through a further value change) would + // otherwise never be reflected in the banner at all. + this.recomputeSums(); }); } + /** Total Debit / Total Credit shown in the balance banner. A locked (denomination-managed) row is + * excluded here exactly as it is excluded from the form's own .value used at submit time — this + * screen only needs to balance the accounts it is itself about to (re)define, not the one + * Migrate Cash Denomination already settled and this form can never touch. */ + private recomputeSums(): void { + this.debitsSum = 0; + this.creditsSum = 0; + this.glAccountEntries.controls.forEach((control) => { + if (control.disabled) { + return; + } + this.debitsSum += control.value.debit || 0; + this.creditsSum += control.value.credit || 0; + }); + } + /** * Submits the opening balances form and defines opening balances, * if successful redirects to view created transaction. diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.html b/src/app/accounting/search-journal-entry/search-journal-entry.component.html index f8dacebff..6938ce4bd 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.html +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.html @@ -53,10 +53,13 @@ Refresh - + + @if (!disabledFeatures.createJournalEntries) { + + }
@@ -715,7 +718,10 @@ > - @if (totalEntries === 0) { + + @if (((dataSource?.entries$ | async) || []).length === 0) {
No entries found
@@ -724,7 +730,11 @@ }
- +
diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts index 39c8e1694..cf8330511 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts @@ -14,6 +14,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router'; import { merge } from 'rxjs'; import { tap, startWith, map, distinctUntilChanged, debounceTime } from 'rxjs/operators'; import { AccountingService } from '../accounting.service'; +import { DISABLED_ACCOUNTING_FEATURES } from '../disabled-features'; import { SettingsService } from 'app/settings/settings.service'; import { JournalEntriesDataSource } from './journal-entry.datasource'; import { Dates } from 'app/core/utils/dates'; @@ -90,6 +91,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView // ── Misc ────────────────────────────────────────────────────────────────── minDate = new Date(2000, 0, 1); maxDate = new Date(); + /** Accounting features switched off for this installation — see disabled-features.ts. */ + disabledFeatures = DISABLED_ACCOUNTING_FEATURES; // ── Filter controls ─────────────────────────────────────────────────────── officeName = new UntypedFormControl(); diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts index 59385a350..047fed18c 100644 --- a/src/app/aml/aml.service.ts +++ b/src/app/aml/aml.service.ts @@ -34,6 +34,17 @@ export class AmlService { return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes }); } + /** + * Decides named rules within one alert, leaving its other rules open. + * + *

+ * The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules + * leaves the alert open on the sixth. + */ + reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable { + return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes }); + } + // ═══ Recurring-alert review queue ═══ /** @@ -122,6 +133,15 @@ export class AmlService { return this.http.get('/aml/ctr-reports'); } + /** + * The reportable lines behind the generated CTRs, each resolved to the account, customer, branch + * and date a FIU-IND filing names. Omit `reportId` for every generated month. + */ + getCtrReportLines(reportId?: number): Observable { + const params = reportId ? new HttpParams().set('reportId', String(reportId)) : undefined; + return this.http.get('/aml/ctr-reports/lines', { params }); + } + /** month format: yyyy-MM */ generateCtrReport(month: string): Observable { return this.http.post('/aml/ctr-reports/generate', { month }); @@ -132,4 +152,38 @@ export class AmlService { getRiskCategoryHistory(clientId: number): Observable { return this.http.get(`/clients/${clientId}/risk-category-history`); } + + // ═══ Risk-category changes (maker-checker) ═══ + // + // The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so + // it moves only once a checker agrees rather than on one officer's say-so. + + /** @param requestedCategory 1 Low, 2 Medium, 3 High */ + requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable { + return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason }); + } + + getRiskChangeRequests(status?: string): Observable { + const params = status ? new HttpParams().set('status', status) : undefined; + return this.http.get('/aml/risk-change-requests', { params }); + } + + approveRiskChange(requestId: number): Observable { + return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {}); + } + + rejectRiskChange(requestId: number, rejectionReason: string): Observable { + return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason }); + } + + /** + * The latest behaviour window for each customer, as the nightly job computed it. + * + * One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report + * prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of + * transactions that happened to trip a rule. + */ + getBehaviourWindows(): Observable { + return this.http.get('/risk-review/behaviour-windows'); + } } diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index c2317b327..4cfde7def 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -13,6 +13,7 @@ import { Routes, RouterModule } from '@angular/router'; // Not Found Component import { NotFoundComponent } from './not-found/not-found.component'; import { CallbackComponent } from './zitadel/callback/callback.component'; +import { PlatformMenuAccessComponent } from './platform-admin/menu-access/menu-access.component'; /** * Fallback to this route when no prior route is matched. @@ -22,6 +23,12 @@ const routes: Routes = [ path: 'callback', component: CallbackComponent }, + { + // Platform-operator only — outside the normal login/tenant flow on purpose, see + // PlatformMenuAccessComponent's own doc comment for why that is safe. + path: 'platform-admin/menu-access', + component: PlatformMenuAccessComponent + }, { path: '**', component: NotFoundComponent diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 0dac0a1d2..4136ae258 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -62,6 +62,7 @@ import { RbiCircularsModule } from './rbi-circulars/rbi-circulars.module'; import { CfcfrmsModule } from './cfcfrms/cfcfrms.module'; import { BankCalendarModule } from './bank-calendar/bank-calendar.module'; import { DeafModule } from './deaf/deaf.module'; +import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module'; import { DicgcModule } from './dicgc/dicgc.module'; import { FiuModule } from './fiu/fiu.module'; import { GlReportsModule } from './gl-reports/gl-reports.module'; @@ -174,6 +175,7 @@ export function HttpLoaderFactory(http: HttpClient) { CfcfrmsModule, BankCalendarModule, DeafModule, + SavingsDailyInterestModule, DicgcModule, FiuModule, GlReportsModule, diff --git a/src/app/board-resolution/bor-report.component.html b/src/app/board-resolution/bor-report.component.html new file mode 100644 index 000000000..ec7b8bf32 --- /dev/null +++ b/src/app/board-resolution/bor-report.component.html @@ -0,0 +1,147 @@ + +

+
+
+ +
+

Board of Resolution Register — Share Capital

+

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

+
+
+ +
+ + @if (loading) { +
Loading the register…
+ } @else if (loadError) { +
{{ loadError }}
+ } + + @if (!loading && !loadError) { + + @if (missingResolutionCount > 0) { +
+ + {{ missingResolutionCount }} share account{{ missingResolutionCount === 1 ? '' : 's' }} carr{{ + missingResolutionCount === 1 ? 'ies' : 'y' + }} + no resolution number. Share capital allotted without a board resolution cannot be evidenced to the Registrar. +
+ } + +
+
+
{{ filtered.length | number }}
+
Share Accounts
+
+
+
{{ distinctResolutions | number }}
+
Resolutions
+
+
+
{{ totalShares | number }}
+
Shares Allotted
+
+
+
₹{{ totalValue | number: '1.0-0' }}
+
Share Value
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + @for (r of filtered; track r.shareAccountId) { + + + + + + + + + + + + + + + } @empty { + + + + } + +
Resolution No.Resolution DateShare A/c No.CIF No.Member NameBranchProductSharesShare ValueTranchesStatusActivated On
+ @if (r.resolutionNumber) { + {{ r.resolutionNumber }} + } @else { + Not recorded + } + {{ r.resolutionDate || '—' }}{{ r.accountNo }}{{ r.cifNumber || '—' }}{{ r.clientName }}{{ r.officeName || '—' }}{{ r.productName || '—' }}{{ r.approvedShares | number }}{{ r.shareValue | number: '1.2-2' }}{{ r.trancheCount }}{{ statusText(r.statusEnum) }}{{ r.activatedDate || '—' }}
No share account matches the filters.
+
+ } +
diff --git a/src/app/board-resolution/bor-report.component.scss b/src/app/board-resolution/bor-report.component.scss new file mode 100644 index 000000000..a21ca4608 --- /dev/null +++ b/src/app/board-resolution/bor-report.component.scss @@ -0,0 +1,313 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.bor-root { + padding: 20px 24px 40px; + background: #f8fafc; + min-height: 100%; +} + +.bor-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.bor-head-left { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.bor-head-ic { + font-size: 26px; + color: #0f4f3c; + margin-top: 2px; +} + +.bor-head-t { + margin: 0 0 3px; + font-size: 20px; + font-weight: 700; + color: #1e293b; +} + +.bor-head-s { + margin: 0; + max-width: 780px; + font-size: 13px; + line-height: 1.5; + color: #64748b; +} + +.bor-note { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 11px 14px; + margin-bottom: 14px; + border: 1px solid #bfdbfe; + border-radius: 9px; + background: #eff6ff; + color: #1e3a8a; + font-size: 12.5px; + line-height: 1.55; + + fa-icon { + flex: 0 0 auto; + margin-top: 1px; + } + + /* An allotment with no resolution behind it is the finding, not a detail. */ + &--warn { + border-color: #fde68a; + background: #fffbeb; + color: #92400e; + + fa-icon { + color: #d97706; + } + } +} + +.bor-kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.bor-kpi { + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #0f4f3c; + border-radius: 11px; + padding: 12px 16px; + + &--blue { + border-left-color: #2563eb; + } + + &--green { + border-left-color: #15803d; + } + + &--amber { + border-left-color: #d97706; + } +} + +.bor-kpi-n { + font-size: 22px; + font-weight: 700; + color: #1e293b; + line-height: 1.2; +} + +.bor-kpi-l { + font-size: 12px; + color: #64748b; + margin-top: 2px; +} + +.bor-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + padding: 14px 16px; + margin-bottom: 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 11px; +} + +.bor-filter { + display: flex; + flex-direction: column; + gap: 4px; + + label { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + } + + &--grow { + flex: 1 1 240px; + } +} + +.bor-inp { + height: 34px; + min-width: 170px; + padding: 0 10px; + border: 1px solid #cbd5e1; + border-radius: 7px; + font: inherit; + font-size: 13px; + color: #1e293b; + background: #fff; + + &:focus { + outline: 2px solid #2563eb; + outline-offset: -1px; + } +} + +.bor-check { + display: flex; + align-items: center; + gap: 6px; + height: 34px; + font-size: 12.5px; + color: #334155; + cursor: pointer; +} + +.bor-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 14px; + border: 1px solid #cbd5e1; + border-radius: 7px; + background: #fff; + font: inherit; + font-size: 12.5px; + font-weight: 600; + color: #334155; + cursor: pointer; + + &:hover:not(:disabled) { + background: #f1f5f9; + } + + &:disabled { + opacity: 0.55; + cursor: default; + } + + &--xls { + border-color: #15803d; + background: #16a34a; + color: #fff; + + &:hover:not(:disabled) { + background: #15803d; + } + } +} + +.bor-table-wrap { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 11px; + overflow-x: auto; +} + +.bor-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + padding: 10px; + background: #f1f5f9; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #475569; + white-space: nowrap; + border-bottom: 1px solid #e2e8f0; + } + + td { + padding: 11px 10px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + color: #334155; + } + + .num { + text-align: right; + font-family: 'DM Mono', monospace; + } + + .mono { + font-family: 'DM Mono', monospace; + } + + .strong { + font-weight: 700; + } + + tbody tr:hover { + background: #f8fafc; + } +} + +/* The row an auditor is looking for, marked in the table as well as counted above it. */ +.bor-row--missing { + background: #fffbeb; +} + +.bor-res { + color: #0f4f3c; + font-family: 'DM Mono', monospace; +} + +.bor-missing { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + background: #fef3c7; + color: #92400e; + font-size: 11px; + font-weight: 700; +} + +.bor-status { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: #f1f5f9; + color: #475569; + + &--active { + background: #dcfce7; + color: #15803d; + } + + &--submitted, + &--approved { + background: #fef3c7; + color: #b45309; + } + + &--rejected, + &--closed { + background: #fee2e2; + color: #b91c1c; + } +} + +.bor-empty { + padding: 30px; + text-align: center; + color: #94a3b8; +} diff --git a/src/app/board-resolution/bor-report.component.ts b/src/app/board-resolution/bor-report.component.ts new file mode 100644 index 000000000..776a4ad40 --- /dev/null +++ b/src/app/board-resolution/bor-report.component.ts @@ -0,0 +1,221 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { exportWorkbook } from '../cashier/cashier-export'; +import { BorRegisterRow, BorReportService } from './bor-report.service'; + +/** m_share_account.status_enum, as the register labels it. */ +const STATUS_LABEL: Record = { + 100: 'Submitted', + 200: 'Approved', + 300: 'Active', + 400: 'Rejected', + 500: 'Closed', + 600: 'Inactive' +}; + +/** + * The Board of Resolution register for share capital. + * + * A co-operative allots shares only under a board resolution, and this is the list the Registrar or + * an auditor asks for: every member's holding against the resolution that authorised it. The + * account with no resolution recorded is the point of the register, so it sorts to the top and is + * counted on its own rather than being left to be noticed. + */ +@Component({ + selector: 'mifosx-bor-report', + templateUrl: './bor-report.component.html', + styleUrls: ['./bor-report.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + FormsModule + ] +}) +export class BorReportComponent implements OnInit { + private borService = inject(BorReportService); + + readonly statusLabel = STATUS_LABEL; + + rows: BorRegisterRow[] = []; + loading = false; + loadError = ''; + + /** '' = every status. */ + filterStatus: number | '' = ''; + filterBranch = ''; + filterResolution = ''; + search = ''; + /** Only the accounts with no resolution on file — the ones that need chasing. */ + missingOnly = false; + + exporting = false; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.borService.getRegister().subscribe({ + next: (rows) => { + this.loading = false; + this.rows = rows; + }, + error: (err: any) => { + this.loading = false; + this.rows = []; + this.loadError = err?.error?.defaultUserMessage || 'Could not load the BOR register.'; + } + }); + } + + /** Branches present in the data, so the filter offers only what can actually be selected. */ + get branches(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get statuses(): number[] { + return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b); + } + + get filtered(): BorRegisterRow[] { + const needle = this.search.trim().toLowerCase(); + const resolutionNeedle = this.filterResolution.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.filterStatus !== '' && r.statusEnum !== this.filterStatus) { + return false; + } + if (this.filterBranch && r.officeName !== this.filterBranch) { + return false; + } + if (this.missingOnly && r.resolutionNumber) { + return false; + } + if (resolutionNeedle && !r.resolutionNumber.toLowerCase().includes(resolutionNeedle)) { + return false; + } + if ( + needle && + !( + r.clientName.toLowerCase().includes(needle) || + r.accountNo.toLowerCase().includes(needle) || + r.cifNumber.toLowerCase().includes(needle) + ) + ) { + return false; + } + return true; + }); + } + + get totalShares(): number { + return this.filtered.reduce((s, r) => s + r.approvedShares, 0); + } + + get totalValue(): number { + return this.filtered.reduce((s, r) => s + r.shareValue, 0); + } + + /** How many resolutions the filtered holdings were authorised under. */ + get distinctResolutions(): number { + return new Set(this.filtered.map((r) => r.resolutionNumber).filter(Boolean)).size; + } + + get missingResolutionCount(): number { + return this.rows.filter((r) => !r.resolutionNumber).length; + } + + statusText(statusEnum: number): string { + return STATUS_LABEL[statusEnum] || '—'; + } + + statusClass(statusEnum: number): string { + return 'bor-status bor-status--' + (STATUS_LABEL[statusEnum] || 'unknown').toLowerCase(); + } + + resetFilters(): void { + this.filterStatus = ''; + this.filterBranch = ''; + this.filterResolution = ''; + this.search = ''; + this.missingOnly = false; + } + + async exportExcel(): Promise { + this.exporting = true; + try { + const rows = this.filtered; + await exportWorkbook( + [ + { + sheetName: 'BOR Register', + title: 'Board of Resolution Register — Share Capital', + subtitle: `${rows.length} share account(s) · ${this.distinctResolutions} resolution(s) · ${this.totalShares} shares`, + headers: [ + 'Resolution No.', + 'Resolution Date', + 'Share A/c No.', + 'CIF No.', + 'Member Name', + 'Branch', + 'Product', + 'Shares', + 'Unit Price', + 'Share Value', + 'Tranches', + 'Status', + 'Activated On' + ], + widths: [ + 20, + 16, + 18, + 16, + 30, + 18, + 18, + 10, + 12, + 14, + 10, + 12, + 14 + ], + rows: rows.map((r) => [ + // Named rather than left blank, because an empty cell in a filing reads as an + // oversight in the export rather than a gap in the register. + r.resolutionNumber || 'NOT RECORDED', + r.resolutionDate || '—', + r.accountNo, + r.cifNumber, + r.clientName, + r.officeName, + r.productName, + r.approvedShares, + r.unitPrice, + r.shareValue, + r.trancheCount, + this.statusText(r.statusEnum), + r.activatedDate || '—' + ]) + } + ], + 'BOR-Register' + ); + } finally { + this.exporting = false; + } + } +} diff --git a/src/app/board-resolution/bor-report.service.ts b/src/app/board-resolution/bor-report.service.ts new file mode 100644 index 000000000..b75370e8c --- /dev/null +++ b/src/app/board-resolution/bor-report.service.ts @@ -0,0 +1,94 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; + +/** Custom Helpers */ +import { toIsoDate } from '../core/utils/api-date'; + +/** One share account and the board resolution that authorised it. */ +export interface BorRegisterRow { + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeId: number | null; + officeName: string; + productName: string; + currencyCode: string; + statusEnum: number; + approvedShares: number; + pendingShares: number; + unitPrice: number; + shareValue: number; + resolutionNumber: string; + /** ISO date, or '' where no meeting date was ever recorded against the allotment. */ + resolutionDate: string; + /** How many purchase tranches make up the holding. */ + trancheCount: number; + submittedDate: string; + approvedDate: string; + activatedDate: string; + closedDate: string; +} + +/** + * The BOR register. + * + * One call returns every account with its resolution. The share allotment report next door fetches + * each account separately and stops at two hundred, which is a limit on a register rather than on a + * page of one. + */ +@Injectable({ providedIn: 'root' }) +export class BorReportService { + private http = inject(HttpClient); + + getRegister(status?: number, officeId?: number): Observable { + let params = new HttpParams(); + if (status != null) { + params = params.set('status', String(status)); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http + .get('/shareaccounts/bor-register', { params }) + .pipe(map((rows) => (rows || []).map((r) => this.toRow(r)))); + } + + /** Dates arrive as [y, M, d] arrays; flattened to ISO so they sort and filter as strings. */ + private toRow(r: any): BorRegisterRow { + return { + shareAccountId: r.shareAccountId, + accountNo: r.accountNo || '', + clientId: r.clientId, + cifNumber: r.cifNumber || '', + clientName: r.clientName || '', + officeId: r.officeId ?? null, + officeName: r.officeName || '', + productName: r.productName || '', + currencyCode: r.currencyCode || 'INR', + statusEnum: Number(r.statusEnum) || 0, + approvedShares: Number(r.approvedShares) || 0, + pendingShares: Number(r.pendingShares) || 0, + unitPrice: Number(r.unitPrice) || 0, + shareValue: Number(r.shareValue) || 0, + resolutionNumber: (r.resolutionNumber || '').trim(), + resolutionDate: toIsoDate(r.resolutionDate), + trancheCount: Number(r.trancheCount) || 0, + submittedDate: toIsoDate(r.submittedDate), + approvedDate: toIsoDate(r.approvedDate), + activatedDate: toIsoDate(r.activatedDate), + closedDate: toIsoDate(r.closedDate) + }; + } +} diff --git a/src/app/cashier/account-register/account-register.component.html b/src/app/cashier/account-register/account-register.component.html new file mode 100644 index 000000000..d1a802313 --- /dev/null +++ b/src/app/cashier/account-register/account-register.component.html @@ -0,0 +1,169 @@ + + +
+
+
+
REPORTS · ACCOUNT REGISTER
+

Account Register

+

+ Every account as it stood on the chosen date — which ones opened and closed that day, the balance each held + then, and what it holds now. +

+
+
+ + + + + +
+
+ + +

Account Register as on {{ date }} · {{ section }}

+ +
+ + + + + +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + + @if (loading) { +
Loading…
+ } + + @if (!loading && loaded) { + +
+
+ Accounts opened on {{ date }} + {{ openedCount }} +
+
+ Accounts closed on {{ date }} + {{ closedCount }} +
+
+ Balance on {{ date }} + ₹{{ shownBalanceOnDate | number: '1.2-2' }} +
+
+ Current balance + ₹{{ shownCurrentBalance | number: '1.2-2' }} +
+
+ } + + @if (!loading && loaded && filteredRows.length === 0) { +
+ account_balance_wallet +

+ @if (movementOnly) { + No account was opened or closed on {{ date }}. + } @else { + No accounts existed on {{ date }}. + } +

+
+ } + + @if (filteredRows.length > 0) { +
+ + + + + + + + + + + + + + + + @for (r of filteredRows; track r.section + r.accountId) { + + + + + + + + + + + + } + + + + + + + + +
Account NoSectionClientProductStatusOpenedClosedBalance on {{ date }}Current Balance
+ {{ r.accountNo }} + @if (r.openedOnDate) { + OPENED + } + @if (r.closedOnDate) { + CLOSED + } + {{ r.section }}{{ r.clientName ?? '—' }}{{ r.productName ?? '—' }} + {{ r.status }} + {{ r.openedOn ?? '—' }}{{ r.closedOn ?? '—' }}₹{{ r.balanceOnDate | number: '1.2-2' }} + ₹{{ r.currentBalance | number: '1.2-2' }} + @if (r.section === 'SHARE' && r.sharesHeld) { + · {{ r.sharesHeld }} sh + } +
+ {{ filteredRows.length }} account(s) · {{ openedCount }} opened · {{ closedCount }} closed + ₹{{ shownBalanceOnDate | number: '1.2-2' }}₹{{ shownCurrentBalance | number: '1.2-2' }}
+
+ } +
diff --git a/src/app/cashier/account-register/account-register.component.scss b/src/app/cashier/account-register/account-register.component.scss new file mode 100644 index 000000000..702002e4e --- /dev/null +++ b/src/app/cashier/account-register/account-register.component.scss @@ -0,0 +1,144 @@ +/** + * 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/. + */ + +/* Page chrome comes from cashier-shared.scss; badges, filters and muted text are shared with the + day book. Only what belongs to the account register lives here. */ + +.ar-summary { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 14px; + + &__item { + flex: 1 1 180px; + padding: 12px 16px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + + &--in { + border-left: 4px solid #16a34a; + } + + &--out { + border-left: 4px solid #dc2626; + } + } + + &__label { + display: block; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + } + + &__value { + display: block; + margin-top: 4px; + font-size: 20px; + font-weight: 800; + font-variant-numeric: tabular-nums; + color: #0d1b2a; + } +} + +/* Checkbox sits inside a pg-field, which is laid out for inputs — this keeps the label and box on + one line without fighting that. */ +.ar-check { + &__box { + display: flex; + align-items: center; + gap: 6px; + height: 38px; + font-size: 12px; + color: #475569; + white-space: nowrap; + } +} + +/* Marks the accounts that actually moved on the chosen date — the reason someone opens a register + for a past day at all. */ +.ar-tag { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.04em; + + &--in { + background: #dcfce7; + color: #15803d; + } + + &--out { + background: #fee2e2; + color: #b91c1c; + } +} + +.ar-row--opened td:first-child { + box-shadow: inset 3px 0 0 #22c55e; +} + +.ar-row--closed td:first-child { + box-shadow: inset 3px 0 0 #ef4444; +} + +/* Only shown on paper: the screen already states the date in the toolbar. */ +.ar-printhead { + display: none; +} + +/* A register is meant to be printed and filed, so paper gets its own treatment: the toolbar and + filters go, the table gains borders, and rows are not split across pages. */ +@media print { + .ar-noprint { + display: none !important; + } + + .ar-printhead { + display: block; + margin: 0 0 10px; + font-size: 13px; + font-weight: 800; + color: #000; + } + + .pg { + padding: 0; + } + + .ar-summary__item { + border: 1px solid #999; + } + + .pg-table { + font-size: 10px; + + th, + td { + border: 1px solid #999; + padding: 4px 6px; + color: #000; + } + } + + tr { + page-break-inside: avoid; + } + + /* Table headers repeat on every page, so a multi-page register stays readable. */ + thead { + display: table-header-group; + } +} diff --git a/src/app/cashier/account-register/account-register.component.ts b/src/app/cashier/account-register/account-register.component.ts new file mode 100644 index 000000000..683841a11 --- /dev/null +++ b/src/app/cashier/account-register/account-register.component.ts @@ -0,0 +1,221 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { catchError, of } from 'rxjs'; +import { CashierService } from '../cashier.service'; + +interface RegisterRow { + accountId: number; + accountNo: string; + section: 'SAVINGS' | 'FD' | 'RD' | 'SHARE'; + clientName: string | null; + productName: string | null; + status: string; + openedOn: string | null; + closedOn: string | null; + /** True when this account was opened (or closed) on the very date being viewed. */ + openedOnDate: boolean; + closedOnDate: boolean; + /** The account's balance as it stood on the register's date, for every section. */ + balanceOnDate: number | null; + currentBalance: number | null; + sharesHeld?: number; +} + +/** + * Account register for one date: which accounts opened and closed that day, each account's balance + * as it stood then, and what it holds now. + */ +@Component({ + selector: 'mifosx-account-register', + templateUrl: './account-register.component.html', + styleUrls: [ + '../cashier-shared.scss', + '../day-book/day-book.component.scss', + './account-register.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule + ] +}) +export class AccountRegisterComponent implements OnInit { + private service = inject(CashierService); + + date = ''; + section: 'ALL' | 'SAVINGS' | 'FD' | 'RD' | 'SHARE' = 'ALL'; + search = ''; + /** Narrows to just the accounts that opened or closed on the chosen date. */ + movementOnly = false; + + rows: RegisterRow[] = []; + openedCount = 0; + closedCount = 0; + totalBalanceOnDate = 0; + totalCurrentBalance = 0; + loading = false; + loaded = false; + errorMsg = ''; + + readonly sections = [ + { value: 'ALL', label: 'All accounts' }, + { value: 'SAVINGS', label: 'Savings' }, + { value: 'FD', label: 'Fixed Deposit' }, + { value: 'RD', label: 'Recurring Deposit' }, + { value: 'SHARE', label: 'Share' } + ]; + + ngOnInit(): void { + this.date = this.today(); + this.load(); + } + + /** Local date — toISOString() would shift the day in any timezone behind UTC. */ + private today(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + load(): void { + if (!this.date) return; + this.loading = true; + this.errorMsg = ''; + this.service + .accountRegister(this.date, this.section) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.message ?? + 'Could not load the account register.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loading = false; + this.loaded = true; + this.rows = res?.accounts ?? []; + this.openedCount = Number(res?.openedCount ?? 0); + this.closedCount = Number(res?.closedCount ?? 0); + this.totalBalanceOnDate = Number(res?.totalBalanceOnDate ?? 0); + this.totalCurrentBalance = Number(res?.totalCurrentBalance ?? 0); + }); + } + + shiftDate(days: number): void { + const [ + y, + m, + d + ] = this.date.split('-').map(Number); + const dt = new Date(y, m - 1, d + days); + this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`; + this.load(); + } + + goToday(): void { + this.date = this.today(); + this.load(); + } + + /** Free-text narrowing over the rows already fetched — no round trip per keystroke. */ + get filteredRows(): RegisterRow[] { + const base = this.movementOnly ? this.rows.filter((r) => r.openedOnDate || r.closedOnDate) : this.rows; + const q = this.search.trim().toLowerCase(); + if (!q) return base; + return base.filter((r) => + [ + r.accountNo, + r.clientName, + r.productName, + r.status, + r.section + ] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(q)) + ); + } + + /** Totals for what is on screen, so a filter or search still reconciles. */ + get shownBalanceOnDate(): number { + return this.filteredRows.reduce((s, r) => s + Number(r.balanceOnDate ?? 0), 0); + } + + get shownCurrentBalance(): number { + return this.filteredRows.reduce((s, r) => s + Number(r.currentBalance ?? 0), 0); + } + + statusClass(status: string): string { + switch (status) { + case 'ACTIVE': + return 'db-badge db-badge--ok'; + case 'CLOSED': + case 'PREMATURELY CLOSED': + case 'REJECTED': + case 'WITHDRAWN': + return 'db-badge db-badge--rejected'; + case 'SUBMITTED': + case 'APPROVED': + return 'db-badge db-badge--pending'; + default: + return 'db-badge'; + } + } + + exportCsv(): void { + const header = [ + 'Account No', + 'Section', + 'Client', + 'Product', + 'Status', + 'Opened On', + 'Closed On', + `Balance on ${this.date}`, + 'Current Balance' + ]; + const lines = this.filteredRows.map((r) => + [ + r.accountNo, + r.section, + r.clientName ?? '', + r.productName ?? '', + r.status, + r.openedOn ?? '', + r.closedOn ?? '', + r.balanceOnDate == null ? '' : String(r.balanceOnDate), + String(r.currentBalance ?? 0) + ] + // Quote every field and double embedded quotes: client names routinely contain commas. + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(',') + ); + const csv = [ + header.join(','), + ...lines + ].join('\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 = `account-register-${this.date}.csv`; + a.click(); + URL.revokeObjectURL(url); + } + + /** Hands the page to the browser's print dialog; print styles drop the toolbar and filters. */ + printRegister(): void { + window.print(); + } +} diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html new file mode 100644 index 000000000..65d27eb6e --- /dev/null +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -0,0 +1,743 @@ + + +
+
+
+
BRANCH OPERATIONS · {{ missedOnly ? 'MISSED CLOSE' : 'DAY REGISTER' }}
+

{{ missedOnly ? 'Missed Close Register' : 'Day Open / Day Close' }}

+

+ @if (missedOnly) { + Business days that were opened and never closed. While a day stays open its closing balance never carries + forward, a back-dated entry can still be posted into it, and any cash difference on that date goes unnoticed. + } @else { + The branch's business day, opened each morning and closed each evening. A day left open never carries its + closing balance forward, and any cash difference on that date goes unnoticed — so the ones still hanging are + listed first. + } +

+
+
+ @if (canOpenToday && !missedOnly) { + + } + +
+
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + + @if (!canOperate && loaded) { +
+ info + You can see the register but not open or close a day — a Clerk, Head Cashier or Bank Manager does that, since a + cashier works inside the day and cannot declare it closed. +
+ } + + @if (firstOpenNeedsCashMigration && !missedOnly) { +
+ lock + + This branch has never opened a business day, and its Cash at Vault GL account has no opening balance yet. Count + the vault and open the first business day together from + Migrate Cash Denomination — Open Day here is + locked until that is done, so today's cash tracking never starts out of step with the ledger. + +
+ } + + + @if (missed.length > 0) { +
+
+ warning + {{ missed.length }} business day{{ missed.length === 1 ? '' : 's' }} never closed +
+ @for (m of missed; track m.id) { +
+ {{ m.businessDate }} + open {{ m.daysOpen }} day{{ m.daysOpen === 1 ? '' : 's' }} + + opened {{ timeOnly(m.openedAt) }} by {{ m.openedByName }} · opening ₹{{ m.openingCash | number: '1.2-2' }} + + @if (canOperate) { + + } +
+ } +
+ } + + + @if (missedOnly && loaded && missed.length === 0) { +
+ check_circle +

No missed closes. Every business day opened for this branch has been closed.

+
+ } + + + @if (!missedOnly && openDay) { +
+
+ DAY OPEN + {{ openDay.businessDate }} + + opened {{ timeOnly(openDay.openedAt) }} by {{ openDay.openedByName }} · opening cash ₹{{ + openDay.openingCash | number: '1.2-2' + }} + +
+ @if (canOperate) { + + } +
+ } @else if (loaded && canOperate) { +
+
+ NO DAY OPEN + Open the business day before any cash transaction is accepted. +
+
+ } + + + @if (showOpenForm) { +
+
+ Open business day + +
+
+ + +
+

+ @if (hasAutoFilledOpenCounts) { + Auto-filled below from yesterday's own closing count (₹{{ carriedForwardTotal | number: '1.2-2' }}) — correct + only the rows where today's physical count differs before opening. + } @else { + Left blank with no notes/coins counted below, yesterday's closing figure carries forward automatically — which + is what keeps the opening balance from drifting. Counting the drawer below is what actually records the + note/coin stock the branch can check a vault handout against later. + } +

+ + @if (hasAutoFilledOpenCounts && openLiveDifference !== null) { + @if (openLiveDifference === 0) { +
+ vs. yesterday's close + Tallied +
+ } @else { +
+ vs. yesterday's close + + {{ openLiveDifference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(openLiveDifference) | number: '1.2-2' }} + +
+ } + } + +
+ + + + + + + + + + @if (notes.length) { + + + + } + @for (d of notes; track d.id) { + + + + + + } + @if (coins.length) { + + + + } + @for (d of coins; track d.id) { + + + + + + } + +
DenominationCountAmount
Bank Notes
+ {{ d.label }} + +
+ + + +
+ @if (hasAutoFilledOpenCounts && countFor(openCounts, d) !== autoOpenCountFor(d)) { + yesterday closed {{ autoOpenCountFor(d) }} + } +
₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}
Coins
+ {{ d.label }} + +
+ + + +
+ @if (hasAutoFilledOpenCounts && countFor(openCounts, d) !== autoOpenCountFor(d)) { + yesterday closed {{ autoOpenCountFor(d) }} + } +
₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}
+
+ @if (openDenomTotal > 0) { +

+ Denomination total: ₹{{ openDenomTotal | number: '1.2-2' }} + @if (hasAutoFilledOpenCounts) { + + } +

+ } + + @if (openLiveDifference !== null && openLiveDifference !== 0) { + + } + +
+ + +
+
+ } + + + @if (closeTarget) { +
+
+ Close business day — {{ closeTarget.businessDate }} + +
+ + @if (needsForceReason) { +

+ This day is being closed after its own date. It will be recorded as a + force close, with the reason below kept permanently against it. +

+ } + + @if (previewLoading) { +

Working out the day's figure…

+ } + + + @if (preview) { +
+
+ Opening cash (carried forward) + ₹{{ preview.openingCash | number: '1.2-2' }} +
+
+ + Cash deposits taken (approved) + ₹{{ preview.deposits | number: '1.2-2' }} +
+
+ − Cash paid out (approved withdrawals) + ₹{{ preview.withdrawals | number: '1.2-2' }} +
+
+ Cash the branch should be holding + ₹{{ preview.expectedCash | number: '1.2-2' }} +
+ @if (preview.vaultIn > 0 || preview.settledOut > 0) { +
+ Vault activity today — moves cash within the branch, not counted in the total above: ₹{{ + preview.vaultIn | number: '1.2-2' + }} + handed to a cashier, ₹{{ preview.settledOut | number: '1.2-2' }} settled back to the vault. +
+ } +
+ } + + + @if (eodLoading) { +

Fetching vault and cashier positions…

+ } + + + @if (branchTotalSummary; as bt) { +
+
+ account_balance_wallet + Branch Total (Vault + All Cashiers) +
+
+ Vault ₹{{ bt.vaultTotal | number: '1.2-2' }} + + Cashiers ₹{{ bt.cashierTotal | number: '1.2-2' }} + = ₹{{ bt.grandTotal | number: '1.2-2' }} + vs. book ₹{{ bt.expected | number: '1.2-2' }} +
+ @if (bt.difference === 0) { + Branch Tallied + } @else { + + {{ bt.difference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(bt.difference) | number: '1.2-2' }} + + } +
+ } + + @for (t of eodTellers; track t.tellerId) { +
+
+ account_balance + {{ t.tellerName }} + + vault holds + {{ t.vault?.hasBreakdown ? '₹' + (t.vault.totalAmount | number: '1.2-2') : 'no breakdown recorded' }} + +
+ + @if (t.vault?.hasBreakdown && t.vault.denominations.length > 0) { +
+ @for (d of t.vault.denominations; track d.denominationId) { + ₹{{ d.denominationValue }} × {{ d.count }} + } +
+ } + + @if (t.cashiers.length === 0) { +

No cashiers allocated under this vault.

+ } + @for (c of t.cashiers; track c.cashierId) { +
+
+ {{ c.cashierName }} + + morning ₹{{ c.morningTotal | number: '1.2-2' }} → current ₹{{ c.currentTotal | number: '1.2-2' }} · + {{ c.txnCount ?? '—' }} txn{{ c.txnCount === 1 ? '' : 's' }} today + +
+
+ @if (c.reconciled === true) { + Reconciled + } @else if (c.reconciled === false) { + Not Reconciled + } + @if (c.alreadySettled === true) { + Settled to Vault + } @else if (c.alreadySettled === false) { + Not Settled + } +
+ @if (c.denominations.length > 0) { +
+ @for (d of c.denominations; track d.denominationId) { + + ₹{{ d.denominationValue }}: {{ d.morningCount }} → {{ d.count }} + @if (d.delta !== 0) { + ({{ d.delta > 0 ? '+' : '' }}{{ d.delta }}) + } + + } +
+ } @else { + No balance. + } +
+ } +
+ } + +
+ + @if (preview && liveDifference !== null) { + @if (liveDifference === 0) { +
+ Difference + Tallied +
+ } @else { +
+ Difference + + {{ liveDifference > 0 ? 'Excess' : 'Short' }} ₹{{ + liveDifference < 0 ? -liveDifference : (liveDifference | number: '1.2-2') + }} + +
+ } + } + @if (preview && liveDifference !== 0) { + + } +
+ +

+ @if (hasAutoFilledCounts) { + Auto-filled below from the vault and every cashier's position shown above — correct only the rows where the + physical count differs, note by note. + } @else { + Pre-filled with what the books expect — change it only if the drawer counted something different, and that + difference is then recorded against the day. Counting the denominations below overrides the typed figure with + what was actually counted, note by note. + } +

+ +
+ + + + + + + + + + @if (notes.length) { + + + + } + @for (d of notes; track d.id) { + + + + + + } + @if (coins.length) { + + + + } + @for (d of coins; track d.id) { + + + + + + } + +
DenominationCountAmount
Bank Notes
+ {{ d.label }} + +
+ + + +
+ @if (hasAutoFilledCounts && countFor(closeCounts, d) !== autoCountFor(d)) { + system counted {{ autoCountFor(d) }} + } +
₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}
Coins
+ {{ d.label }} + +
+ + + +
+ @if (hasAutoFilledCounts && countFor(closeCounts, d) !== autoCountFor(d)) { + system counted {{ autoCountFor(d) }} + } +
₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}
+
+ @if (closeDenomTotal > 0) { +

+ Denomination total: ₹{{ closeDenomTotal | number: '1.2-2' }} + @if (hasAutoFilledCounts) { + + } +

+ } + + + @if (isLateClose || hasUnreconciledOrUnsettledCashiers) { + + } + + + +

+ The figure above is stored against the record, so a difference stays readable later even if a transaction is + reversed afterwards. +

+ + @if (hasUnreconciledOrUnsettledCashiers) { + +
+ {{ blocksSameDayClose ? 'block' : 'warning' }} +
+ @if (blocksSameDayClose) { + Cannot close today — cashiers still holding cash have not been reconciled or settled. + } @else { + This day's cash was never reconciled or settled. + } +
    + @for (detail of unreconciledOrUnsettledCashierDetails; track detail.name) { +
  • + {{ detail.name }} — + @if (detail.notReconciled && detail.notSettled) { + Not Reconciled, Not Settled + } @else if (detail.notReconciled) { + Not Reconciled + } @else { + Not Settled + } +
  • + } +
+ @if (blocksSameDayClose) { + Each cashier must count their drawer, reconcile it, and settle the cash back to the vault. Then this + day can be closed. + } @else { + The cash cannot be counted for a date that has passed. Record why above — their names are kept + against the record. + } +
+
+ } + +
+ + +
+
+ } + + @if (loading) { +
Loading…
+ } + + @if (!loading && loaded && days.length === 0 && !missedOnly) { +
+ event_note +

No business day has been opened for this branch yet.

+
+ } + + + @if (days.length > 0 && !missedOnly) { +
+ + + + + + + + + + + + + + + + @for (d of days; track d.id) { + + + + + + + + + + + + } + +
Business DateStatusOpened ByOpening ₹Closed ByClosing ₹Book ₹DifferenceNote
+ {{ d.businessDate }} + + @if (d.status === 'OPEN') { + OPEN + } @else if (d.forceClosed) { + FORCE CLOSED + } @else { + CLOSED + } + + {{ d.openedByName ?? '—' }} + · {{ timeOnly(d.openedAt) }} + ₹{{ d.openingCash | number: '1.2-2' }} + {{ d.closedByName ?? '—' }} + @if (d.closedAt) { + · {{ timeOnly(d.closedAt) }} + } + {{ hasValue(d.closingCash) ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}{{ hasValue(d.bookCash) ? '₹' + (d.bookCash | number: '1.2-2') : '—' }} + @if (!hasValue(d.cashDifference)) { + — + } @else if (d.cashDifference === 0) { + Tallied + } @else { + {{ differenceLabel(d.cashDifference) }} + } + + @if (d.forceCloseReason) { + Left open: {{ d.forceCloseReason }} + } + @if (d.remarks) { + {{ d.remarks }} + } +
+
+ } +
diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss new file mode 100644 index 000000000..c97aebcc9 --- /dev/null +++ b/src/app/cashier/branch-day/branch-day.component.scss @@ -0,0 +1,374 @@ +/** + * 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/. + */ + +/* Page chrome comes from cashier-shared.scss; the badges, panel and comparison tiles are shared + with the day book. Only what is specific to the day register lives here. */ + +/* Read-only notice for a cashier. Defined here rather than borrowed from the exceptions screen so + this component does not depend on another component's stylesheet. */ +.ve-note { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + margin-bottom: 14px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 8px; + color: #1e40af; + font-size: 12px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +/* Blocks Open Day until the branch's first-ever open has gone through Migrate Cash Denomination — + see BranchDayComponent.checkFirstOpenCashMigration(). Same shape as .ve-note, in the error palette + the rest of this screen already uses for a missed close (.bd-missed). */ +.bd-locked { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 14px; + margin-bottom: 14px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #b91c1c; + font-size: 12px; + line-height: 1.5; + + mat-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + } + + a { + color: inherit; + font-weight: 600; + text-decoration: underline; + } +} + +.bd-success { + padding: 10px 14px; + margin-bottom: 14px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 8px; + color: #14532d; + font-size: 13px; +} + +/* One row per never-closed day, inside the shared red exception panel. */ +.bd-missed { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 7px 0; + font-size: 12px; + border-top: 1px dotted #fecaca; + + &__age { + color: #b91c1c; + } + + /* Pushed right so the date and age read before the action. */ + &__act { + margin-left: auto; + height: 28px; + font-size: 11px; + } +} + +/* The branch's current day — the one thing someone opening this screen is looking for. */ +.bd-current { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 14px 16px; + margin-bottom: 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #f59e0b; + border-radius: 8px; + + /* No day open at all: nothing is running, so the amber "in progress" edge would mislead. */ + &--shut { + border-left-color: #94a3b8; + background: #f8fafc; + } + + &__main { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + &__date { + font-size: 15px; + color: #0d1b2a; + } + + .pg-btn { + margin-left: auto; + } +} + +.bd-panel { + margin-bottom: 14px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + + /* A late close is a different act from an ordinary one, and looks like it. */ + &--late { + border-left: 4px solid #dc2626; + } + + &__wide { + display: block; + margin-top: 12px; + max-width: 640px; + } +} + +/* The closing figure's arithmetic, laid out as a running sum so each line can be checked against + the screen it came from. */ +.bd-calc { + max-width: 520px; + margin-bottom: 14px; + padding: 10px 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 12px; + + &__row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 5px 0; + color: #475569; + + &--in .bd-calc__amt { + color: #15803d; + } + + &--out .bd-calc__amt { + color: #b91c1c; + } + + &--total { + margin-top: 4px; + padding-top: 8px; + border-top: 2px solid #e2e8f0; + font-weight: 800; + font-size: 14px; + color: #0d1b2a; + } + } + + &__amt { + font-variant-numeric: tabular-nums; + font-weight: 700; + white-space: nowrap; + } + + &__memo { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed #e2e8f0; + font-size: 11px; + color: #94a3b8; + } +} + +.bd-hint { + margin: 10px 0 0; + font-size: 11px; + color: #94a3b8; +} + +.bd-note { + max-width: 280px; + font-size: 11px; + line-height: 1.4; +} + +/* The denomination grid inside the open/close panels — same table chrome as pg-card elsewhere, + just tightened to sit inside the panel instead of spanning the page. */ +.bd-denom { + margin: 4px 0 0; + max-width: 520px; + + /* Flags a row where the hand-count no longer matches what the vault/cashier positions reported. */ + &__auto { + display: block; + margin-top: 2px; + font-size: 10px; + white-space: nowrap; + } + + &__reset { + margin-left: 10px; + height: 24px; + padding: 0 10px; + font-size: 11px; + } +} + +/* Auto-fetched EOD summary: what the vault and every cashier under it currently hold. */ +.bd-eod { + margin: 0 0 12px; + padding: 10px 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 12px; + + &__head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #0ea5e9; + } + } + + &__denoms { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 4px 0 8px; + } + + &__chip { + padding: 2px 8px; + background: #f1f5f9; + border-radius: 999px; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: #334155; + + &--up { + background: #dcfce7; + color: #15803d; + } + + &--down { + background: #fee2e2; + color: #b91c1c; + } + } + + &__cashier { + padding: 6px 0; + border-top: 1px dashed #e2e8f0; + + &-head { + display: flex; + justify-content: space-between; + gap: 12px; + font-weight: 600; + color: #0d1b2a; + margin-bottom: 2px; + } + } + + &__empty { + font-size: 11px; + } + + &__status { + display: flex; + gap: 6px; + margin: 2px 0 6px; + } + + &--total { + border-left: 4px solid #0ea5e9; + background: #f0f9ff; + } + + &__totals { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 10px; + margin-bottom: 6px; + font-size: 13px; + font-variant-numeric: tabular-nums; + + &-sum { + font-weight: 800; + font-size: 15px; + color: #0d1b2a; + } + } +} + +/* Alert shown when cashiers holding cash have not been reconciled or settled — blocks day close. */ +.bd-reconcile-block { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 14px 0; + padding: 12px 16px; + background: #fef2f2; + border: 1px solid #fecaca; + border-left: 4px solid #dc2626; + border-radius: 8px; + color: #991b1b; + font-size: 12px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #dc2626; + flex-shrink: 0; + margin-top: 1px; + } + + &__list { + margin: 6px 0 6px 16px; + padding: 0; + list-style: disc; + font-size: 12px; + color: #b91c1c; + line-height: 1.6; + } +} + +/* Amber, not red: an unreconciled cashier is now a reason to record why the day is being closed + rather than a refusal to close it at all. Red said "you cannot", which was true when the button + was disabled and misleading once a force close became possible. */ +.bd-reconcile-block--warn { + background: #fffbeb; + border-color: #fde68a; + border-left-color: #d97706; + color: #92400e; + + mat-icon { + color: #d97706; + } +} diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts new file mode 100644 index 000000000..da476f658 --- /dev/null +++ b/src/app/cashier/branch-day/branch-day.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, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { catchError, forkJoin, map, of, switchMap } from 'rxjs'; +import { CashierService } from '../cashier.service'; + +interface Denom { + id: number; + value: number; + kind: 'note' | 'coin'; + label: string; +} + +interface BranchDay { + id: number; + officeId: number; + officeName: string | null; + businessDate: string; + openedByName: string | null; + openedAt: string | null; + openingCash: number | null; + closedByName: string | null; + closedAt: string | null; + closingCash: number | null; + bookCash: number | null; + cashDifference: number | null; + forceClosed: boolean; + forceCloseReason: string | null; + remarks: string | null; + status: 'OPEN' | 'CLOSED'; + /** Only present on the missed-close list. */ + daysOpen?: number; +} + +/** + * Branch day register — opening and closing the branch's business day, and the list of days that + * were opened and never closed. + * + * A day left open is not a tidiness problem: its closing balance never carries forward, a + * back-dated entry can still land in it, and any cash difference on that date goes unnoticed. + */ +@Component({ + selector: 'mifosx-branch-day', + templateUrl: './branch-day.component.html', + styleUrls: [ + '../cashier-shared.scss', + '../day-book/day-book.component.scss', + './branch-day.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + RouterLink + ] +}) +export class BranchDayComponent implements OnInit { + private service = inject(CashierService); + private route = inject(ActivatedRoute); + + /** + * Set from the route: the missed-close register is the same data as the day register, shown with + * only the never-closed days. Two menu entries used to point at one screen, so choosing "Missed + * Close Register" looked like nothing had happened whenever the branch was up to date. + */ + missedOnly = false; + + days: BranchDay[] = []; + missed: BranchDay[] = []; + openDay: any = null; + today = ''; + /** Set by the backend from the caller's roles — a cashier may read the register but not act on it. */ + canOperate = false; + loading = false; + loaded = false; + errorMsg = ''; + successMsg = ''; + /** True only for a branch that has never opened a business day and whose Cash at Vault / Cash at + * Teller GL account has never had anything posted to it — Open Day here would let day-to-day cash + * tracking begin while the ledger silently stays wherever it already was. The backend enforces + * this independently too (see BranchDayRegisterApiResource#requireCashMigrationBeforeFirstEverOpen); + * this flag only steers the user to the right screen before they hit that rejection. */ + firstOpenNeedsCashMigration = false; + + // Open-day form + showOpenForm = false; + openDate = ''; + openingCash: number | null = null; + /** Required only when the counted/typed opening figure differs from yesterday's own closing + * figure — the backend rejects the open without it in that case. */ + openDifferenceReason = ''; + opening = false; + + // Close-day form. The date is fixed by which day is being closed, never typed. + closeTarget: BranchDay | null = null; + closingCash: number | null = null; + closeRemarks = ''; + forceReason = ''; + closing = false; + + // Denomination breakdown, shared by both the open and close forms — a physical note/coin count + // is the true source of the lump-sum figure, not a typed-in guess. Counted separately per form + // since opening and closing are two independent physical counts. + notes: Denom[] = []; + coins: Denom[] = []; + openCounts: Record = {}; + closeCounts: Record = {}; + /** What yesterday's own close counted, note by note — the open form starts from this instead of + * zero. The backend already carries this figure forward on its own when nothing is submitted for + * the day being opened; showing it here is only about letting whoever opens the day see and + * correct it, not a second place the carry-forward logic lives. */ + openCountsAuto: Record = {}; + /** What the vault + every cashier's live position added up to when the close form opened — the + * physical-count table starts from this instead of zero, since the EOD summary already fetched + * it. Kept separately from closeCounts so a manual recount can be told apart from it and undone. */ + closeCountsAuto: Record = {}; + + get allDenoms(): Denom[] { + return [ + ...this.notes, + ...this.coins + ]; + } + + ngOnInit(): void { + this.missedOnly = !!this.route.snapshot.data['missedOnly']; + this.load(); + this.service.getActiveDenominations().subscribe({ + next: (list) => { + const rows: Denom[] = (list || []).map((x: any) => ({ + id: x.id, + value: x.denomination, + kind: x.kind, + label: '₹' + x.denomination + })); + this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value); + this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value); + }, + error: () => { + this.notes = []; + this.coins = []; + } + }); + } + + countFor(counts: Record, d: Denom): number { + return counts[d.id] || 0; + } + + subtotalFor(counts: Record, d: Denom): number { + return this.countFor(counts, d) * d.value; + } + + onCountChange(counts: Record, d: Denom, raw: any): void { + counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0)); + } + + bumpCount(counts: Record, d: Denom, delta: number): void { + counts[d.id] = Math.max(0, (counts[d.id] || 0) + delta); + } + + totalOf(counts: Record): number { + return this.allDenoms.reduce((s, d) => s + this.subtotalFor(counts, d), 0); + } + + /** Denominations with a positive count, shaped for the API payload. */ + private denominationPayload(counts: Record) { + return this.allDenoms + .filter((d) => this.countFor(counts, d) > 0) + .map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(counts, d) })); + } + + get openDenomTotal(): number { + return this.totalOf(this.openCounts); + } + + get closeDenomTotal(): number { + return this.totalOf(this.closeCounts); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.service + .branchDayRegister() + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day register.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loading = false; + this.loaded = true; + this.days = res?.days ?? []; + this.openDay = res?.openDay ?? null; + this.today = res?.today ?? ''; + this.canOperate = !!res?.canOperate; + this.openDate = this.today; + this.checkFirstOpenCashMigration(); + }); + + this.service + .branchDayMissed() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.missed = res?.missed ?? []; + }); + } + + /** + * Only relevant the moment this branch has never opened a business day before — every later + * open/close on this screen is untouched, so there is nothing to re-check once history exists. + */ + private checkFirstOpenCashMigration(): void { + if (this.days.length > 0 || this.openDay) { + this.firstOpenNeedsCashMigration = false; + return; + } + this.service.cashGlStatus().subscribe({ + next: (status: any) => { + // configured === false means this tenant has no Cash at Vault / Cash at Teller account + // mapped at all — nothing for this check to reconcile against, so it does not block. + this.firstOpenNeedsCashMigration = status?.configured !== false && !status?.hasBalance; + }, + error: () => { + this.firstOpenNeedsCashMigration = false; + } + }); + } + + /** True when the branch has no open day at all, so today can be opened. */ + get canOpenToday(): boolean { + return this.canOperate && !this.openDay && !this.firstOpenNeedsCashMigration; + } + + /** + * A still-open day's closing/book figures are dropped from the API response entirely (the + * backend's Gson instance omits a null map value rather than sending it as JSON null), so the + * field arrives as undefined, not null. Checking only `!== null` against that — which a stricter + * pass of this file made the mistake of doing — leaves undefined on the "has a value" branch, + * which then renders `'₹' + null` (the number pipe's own null output, string-concatenated) as the + * literal text "₹null". This checks for either, the way an open day actually shows up. + */ + hasValue(value: number | null | undefined): boolean { + return value !== null && value !== undefined; + } + + startOpen(): void { + this.showOpenForm = true; + this.openDate = this.today; + // Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure + // over it is how a carried balance drifts. + this.openingCash = null; + this.openCounts = {}; + this.openCountsAuto = {}; + this.openDifferenceReason = ''; + this.successMsg = ''; + this.errorMsg = ''; + this.loadCarriedForwardDenominations(); + } + + cancelOpen(): void { + this.showOpenForm = false; + this.openCountsAuto = {}; + } + + /** + * Pulls yesterday's own closing note/coin count so the open form shows what is about to be + * carried forward instead of a blank table the caller has to trust blindly. Only the most + * recently closed day is used — the same day the backend itself falls back to when the caller + * submits no denominations and no lump sum. + */ + private loadCarriedForwardDenominations(): void { + const lastClosed = this.days.find((d) => d.status === 'CLOSED' && d.closingCash != null); + if (!lastClosed) return; + this.service + .branchDayDenominations(lastClosed.officeId, lastClosed.businessDate) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + const closing = res?.closing || []; + if (!closing.length) return; + const totals: Record = {}; + for (const d of closing) { + totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0); + } + this.openCountsAuto = totals; + // Only overwrite the table if nothing has been typed/counted since the form opened. + if (this.openDenomTotal === 0 && this.openingCash == null) { + this.openCounts = { ...totals }; + } + }); + } + + get hasAutoFilledOpenCounts(): boolean { + return Object.keys(this.openCountsAuto).length > 0; + } + + autoOpenCountFor(d: Denom): number { + return this.openCountsAuto[d.id] || 0; + } + + resetOpenToAutoFill(): void { + this.openCounts = { ...this.openCountsAuto }; + } + + get carriedForwardTotal(): number { + return this.totalOf(this.openCountsAuto); + } + + /** The figure the form would actually submit right now — the denomination total when any note/coin + * has been counted, otherwise the plain typed-in amount. Mirrors {@link effectiveClosingFigure}. */ + get effectiveOpeningFigure(): number | null { + return this.openDenomTotal > 0 ? this.openDenomTotal : this.openingCash; + } + + /** Difference between what is about to be opened with and what yesterday actually closed with — + * visible before saving, the same way the close form shows a live difference against the book + * figure, so a short/excess opening count isn't discovered only after the day is already running. */ + get openLiveDifference(): number | null { + if (!this.hasAutoFilledOpenCounts || this.effectiveOpeningFigure == null) return null; + return this.effectiveOpeningFigure - this.carriedForwardTotal; + } + + /** True once a reason is needed but hasn't been typed — the same "explain the gap before it's + * allowed" gate the close form already applies to a late close. */ + get needsOpenDifferenceReason(): boolean { + return this.openLiveDifference != null && this.openLiveDifference !== 0 && !this.openDifferenceReason.trim(); + } + + get canSubmitOpen(): boolean { + return !!this.openDate && !this.opening && !this.needsOpenDifferenceReason; + } + + submitOpen(): void { + if (!this.openDate || this.needsOpenDifferenceReason) return; + this.opening = true; + this.errorMsg = ''; + const denominations = this.denominationPayload(this.openCounts); + this.service + .openBranchDay( + this.openDate, + this.openingCash ?? undefined, + undefined, + denominations, + this.openDifferenceReason.trim() || undefined + ) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not open the day.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.opening = false; + if (res) { + this.showOpenForm = false; + this.successMsg = `Business day ${res.businessDate} opened.`; + this.load(); + } + }); + } + + /** The day's expected figure and the working behind it, fetched when the close form opens. */ + preview: any = null; + previewLoading = false; + + startClose(day: BranchDay): void { + this.closeTarget = day; + this.closingCash = null; + this.closeCounts = {}; + this.closeCountsAuto = {}; + this.closeRemarks = ''; + this.forceReason = ''; + this.successMsg = ''; + this.errorMsg = ''; + this.preview = null; + + // Pre-fill the counted figure with what the books expect. It stays editable — the day is still + // closed on what was physically counted — but in the ordinary case where the drawer tallies + // there is nothing to type, and no chance of a slip of the keyboard inventing a difference. + this.previewLoading = true; + this.service + .branchDayClosePreview(day.businessDate) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.previewLoading = false; + this.preview = res; + if (res?.expectedCash != null && this.closingCash == null) { + this.closingCash = Number(res.expectedCash); + } + }); + + this.loadEodSummary(day); + } + + // ── EOD denomination summary: what's in the vault, and what every cashier under it is + // currently holding, fetched automatically so closing the day doesn't require separately + // checking Vault Position and every cashier's own Cashier Position screen by hand. ── + + eodLoading = false; + eodTellers: any[] = []; + + /** + * Vault + every cashier's current holding, added up across the whole branch and checked against + * the book figure — the question a manager actually needs answered at close: does the BRANCH + * tally, not just the vault alone. Computed on read rather than stored, so it never has to be + * kept in sync across the two independent loads (the book preview and the EOD summary) that feed + * it. + */ + get branchTotalSummary(): { + vaultTotal: number; + cashierTotal: number; + grandTotal: number; + expected: number; + difference: number; + } | null { + if (!this.preview || this.eodTellers.length === 0) return null; + let vaultTotal = 0; + let cashierTotal = 0; + for (const t of this.eodTellers) { + vaultTotal += Number(t.vault?.totalAmount ?? 0); + for (const c of t.cashiers) { + cashierTotal += Number(c.currentTotal ?? 0); + } + } + const grandTotal = vaultTotal + cashierTotal; + const expected = Number(this.preview.expectedCash ?? 0); + return { vaultTotal, cashierTotal, grandTotal, expected, difference: grandTotal - expected }; + } + + private loadEodSummary(day: BranchDay): void { + this.eodLoading = true; + this.eodTellers = []; + this.service + .getTellers(day.officeId) + .pipe( + switchMap((tellers) => { + const list = tellers || []; + if (!list.length) return of([]); + return forkJoin(list.map((t: any) => this.loadTellerSummary(t, day.businessDate))); + }), + catchError(() => of([])) + ) + .subscribe((results: any[]) => { + this.eodLoading = false; + this.eodTellers = results; + this.autoFillCloseCounts(); + }); + } + + /** + * Seeds the physical-count table from what the vault and every cashier are already shown to be + * holding, instead of leaving it at zero and making whoever closes the day re-key numbers that + * are already on screen above. It stays a starting point, not a lock — any row can still be + * corrected against what was actually counted, and {@link resetToAutoFill} undoes a correction. + */ + private autoFillCloseCounts(): void { + const totals: Record = {}; + for (const t of this.eodTellers) { + for (const d of t.vault?.denominations || []) { + totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0); + } + for (const c of t.cashiers || []) { + for (const d of c.denominations || []) { + totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0); + } + } + } + if (Object.keys(totals).length === 0) return; + this.closeCountsAuto = totals; + this.closeCounts = { ...totals }; + } + + get hasAutoFilledCounts(): boolean { + return Object.keys(this.closeCountsAuto).length > 0; + } + + /** The auto-fetched count for one denomination, so a row that was hand-edited away from it can + * still show what the vault + cashier positions said. */ + autoCountFor(d: Denom): number { + return this.closeCountsAuto[d.id] || 0; + } + + resetToAutoFill(): void { + this.closeCounts = { ...this.closeCountsAuto }; + } + + private loadTellerSummary(teller: any, date: string) { + return forkJoin({ + vault: this.service.vaultPosition(teller.id, date).pipe(catchError(() => of(null))), + cashiers: this.service.getCashiersForTeller(teller.id).pipe(catchError(() => of({ cashiers: [] }))) + }).pipe( + switchMap(({ vault, cashiers }) => { + const cashierList = cashiers?.cashiers || []; + if (!cashierList.length) { + return of({ tellerId: teller.id, tellerName: teller.name, vault, cashiers: [] }); + } + return forkJoin(cashierList.map((c: any) => this.loadCashierSummary(c, date))).pipe( + map((cashierSummaries: any[]) => ({ + tellerId: teller.id, + tellerName: teller.name, + vault, + cashiers: cashierSummaries + })) + ); + }) + ); + } + + private loadCashierSummary(cashier: any, date: string) { + return forkJoin({ + position: this.service.cashierPositionReport(cashier.id, date).pipe(catchError(() => of(null))), + morning: this.service.getCashierMorningBalance(cashier.id, date).pipe(catchError(() => of(null))), + reconciliation: this.service.checkReconciliation(cashier.id, date).pipe(catchError(() => of(null))), + settlement: this.service.checkSettlement(cashier.id, date).pipe(catchError(() => of(null))), + glEntries: this.service.getGlEntries(date, cashier.id, undefined, 0, 1).pipe(catchError(() => of(null))) + }).pipe( + map(({ position, morning, reconciliation, settlement, glEntries }: any) => { + const morningMap = new Map(); + (morning?.denominations || []).forEach((d: any) => morningMap.set(d.denominationId, d.morningCount ?? 0)); + return { + cashierId: cashier.id, + cashierName: cashier.staffName, + morningTotal: Number(morning?.totalMorning ?? 0), + currentTotal: Number(position?.totalAmount ?? 0), + reconciled: reconciliation?.reconciled ?? null, + alreadySettled: settlement?.alreadySettled ?? null, + txnCount: glEntries?.totalFilteredRecords ?? null, + denominations: ((position?.denominations || []) as any[]).map((d: any) => ({ + ...d, + morningCount: morningMap.get(d.denominationId) ?? 0, + delta: d.count - (morningMap.get(d.denominationId) ?? 0) + })) + }; + }) + ); + } + + /** The counted figure the form would submit right now — the denomination total when any note/coin + * has been counted, otherwise the plain typed-in amount. */ + get effectiveClosingFigure(): number | null { + return this.closeDenomTotal > 0 ? this.closeDenomTotal : this.closingCash; + } + + /** Difference the form would record right now, so it is visible before saving. */ + get liveDifference(): number | null { + if (!this.preview || this.effectiveClosingFigure == null) return null; + return Number(this.effectiveClosingFigure) - Number(this.preview.expectedCash ?? 0); + } + + /** Puts the expected figure back after an edit. */ + useExpected(): void { + if (this.preview?.expectedCash != null) { + this.closingCash = Number(this.preview.expectedCash); + } + } + + cancelClose(): void { + this.closeTarget = null; + this.eodTellers = []; + this.closeCountsAuto = {}; + } + + /** A date earlier than today is a late close, and has to say why it was left open. */ + get isLateClose(): boolean { + return !!this.closeTarget && this.closeTarget.businessDate < this.today; + } + + /** + * True when any cashier who was given cash (currentTotal > 0) is still NOT reconciled + * or NOT settled — meaning outstanding cash hasn't been accounted for yet. + */ + get hasUnreconciledOrUnsettledCashiers(): boolean { + for (const t of this.eodTellers) { + for (const c of t.cashiers) { + if (c.currentTotal > 0 && (c.reconciled === false || c.alreadySettled === false)) { + return true; + } + } + } + return false; + } + + /** Names of cashiers blocking the close, for the error message. */ + get unreconciledOrUnsettledCashierDetails(): { name: string; notReconciled: boolean; notSettled: boolean }[] { + const result: { name: string; notReconciled: boolean; notSettled: boolean }[] = []; + for (const t of this.eodTellers) { + for (const c of t.cashiers) { + if (c.currentTotal > 0 && (c.reconciled === false || c.alreadySettled === false)) { + result.push({ + name: c.cashierName, + notReconciled: c.reconciled === false, + notSettled: c.alreadySettled === false + }); + } + } + } + return result; + } + + /** + * Whether a reason has to be given before the day can close. + * + * Only for a late close. Cash still sitting with a cashier is not something a reason excuses — + * see canSubmitClose. + */ + get needsForceReason(): boolean { + return this.isLateClose; + } + + get canSubmitClose(): boolean { + const hasFigure = this.closingCash != null || this.closeDenomTotal > 0; + if (!this.closeTarget || !hasFigure || this.closing) return false; + // Block while cashier/vault data is still loading — the reconciliation check can't run yet. + if (this.eodLoading || this.previewLoading) return false; + // An unreconciled cashier is a reason-required condition, not an absolute block: the backend + // accepts the close once a reason is recorded (and appends the cashiers' names to it), and + // refusing outright left a day that could never be closed at all — a cashier who reconciled on + // the wrong date, went on leave, or left mid-shift would strand that day and every day after it. + if (this.isLateClose || this.hasUnreconciledOrUnsettledCashiers) { + return !!this.forceReason.trim(); + } + return true; + } + + submitClose(): void { + if (this.eodLoading || this.previewLoading) { + this.errorMsg = 'Still loading the cashier and vault positions — try again in a moment.'; + return; + } + // Safety net mirroring canSubmitClose: never send a close that the backend will refuse for a + // missing override reason. + if ((this.isLateClose || this.hasUnreconciledOrUnsettledCashiers) && !this.forceReason.trim()) { + this.errorMsg = 'Record why this day is being closed before submitting.'; + return; + } + const day = this.closeTarget; + const denominations = this.denominationPayload(this.closeCounts); + const figure = denominations.length ? this.closeDenomTotal : this.closingCash; + if (!day || figure == null) return; + + this.closing = true; + this.errorMsg = ''; + this.service + .closeBranchDay( + day.businessDate, + Number(figure), + this.closeRemarks.trim() || undefined, + // Sent whenever one was typed, not only on a late close: it is also the override the backend + // needs for a same-day close over an unreconciled cashier, and gating it on isLateClose + // dropped it on exactly the request that could not succeed without it. + this.forceReason.trim() || undefined, + undefined, + denominations + ) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the day.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.closing = false; + if (res) { + this.closeTarget = null; + const diff = Number(res.cashDifference ?? 0); + this.successMsg = + diff === 0 + ? `Business day ${res.businessDate} closed — cash tallied.` + : `Business day ${res.businessDate} closed with a difference of ₹${diff.toFixed(2)}.`; + this.load(); + } + }); + } + + absValue(n: number): number { + return Math.abs(n); + } + + /** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */ + differenceLabel(d: number | null): string { + if (d == null) return '—'; + const n = Number(d); + if (n === 0) return 'Tallied'; + return n > 0 ? `Excess ₹${Math.abs(n).toFixed(2)}` : `Short ₹${Math.abs(n).toFixed(2)}`; + } + + stamp(iso: string | null): string { + if (!iso) return '—'; + const [ + d, + t + ] = iso.split('T'); + return t ? `${d} ${t.slice(0, 5)}` : d; + } + + timeOnly(iso: string | null): string { + if (!iso) return '—'; + const t = iso.split('T')[1]; + return t ? t.slice(0, 5) : '—'; + } +} diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.html b/src/app/cashier/cash-transfer/cash-transfer.component.html index 82cb5f963..4c09120a9 100644 --- a/src/app/cashier/cash-transfer/cash-transfer.component.html +++ b/src/app/cashier/cash-transfer/cash-transfer.component.html @@ -50,7 +50,7 @@ Date - + @@ -59,11 +59,28 @@ + + @if (fromTellerId && !loadingVault) { + @if (vaultHasBreakdown) { +
+ account_balance + Vault available: ₹{{ vaultTotalAvailable | number: '1.2-2' }} +
+ } @else { +
+ warning + No denomination breakdown recorded for this vault — per-note availability can't be checked, only the total + opening balance. +
+ } + } +
+ @@ -71,14 +88,17 @@ @if (notes.length) { - + } @for (d of notes; track d.id) { - + + - + } @for (d of coins; track d.id) { - + + - + }
DenominationAvailable Count Amount
Bank NotesBank Notes
{{ d.label }} + {{ vaultHasBreakdown ? availableFor(d) : '—' }} +
@@ -91,14 +111,17 @@ } @if (coins.length) {
CoinsCoins
{{ d.label }} + {{ vaultHasBreakdown ? availableFor(d) : '—' }} +
@@ -111,13 +134,24 @@ } @if (denoms.length === 0) {
No active denominations. Add some in Cash Denomination.No active denominations. Add some in Cash Denomination.
+ @if (anyDenomExceedsAvailable) { +
+ One or more notes/coins above are counted higher than the vault currently holds — reduce them before recording the + transfer. +
+ } @else if (exceedsVaultTotal) { +
+ Total exceeds what the vault has available (₹{{ vaultTotalAvailable | number: '1.2-2' }}). +
+ } +
Total to transfer: ₹{{ total | number: '1.2-2' }} - +
+ + + {{ visibleOverdueRows.length }} of {{ rows.length }} member{{ rows.length === 1 ? '' : 's' }} + +
@@ -122,10 +196,19 @@ + } @else if (visibleOverdueRows.length === 0) { + + + } @else { - @for (row of rows; track row.lockId) { + @for (row of visibleOverdueRows; track row.lockId) { - + @@ -181,7 +264,7 @@
No members with a locked dividend and an overdue EMI right now.
No member matches “{{ overdueSearch }}”.
{{ row.clientName }} + {{ row.clientName }} + @if (row.shareAccountNo) { + + } + {{ row.lockedAmount | number: '1.2-2' }} {{ row.lockedDate }} {{ row.releaseDueDate }}
-
+
@@ -196,21 +279,69 @@ }
- + +
+ + + @if (lockYears.length > 0) { +
+ Financial year +
+ + @for (year of lockYears; track year) { + + } +
+ + {{ visibleLocks.length }} lock{{ visibleLocks.length === 1 ? '' : 's' }} · + {{ visibleLocksTotal | number: '1.2-2' }} + +
+ } - + + - + @@ -218,16 +349,34 @@ - } @else if (locksLoaded && locks.length === 0) { + } @else if (locksLoaded && visibleLocks.length === 0) { - + } @else { - @for (l of locks; track l.lockId) { + @for (l of visibleLocks; track l.lockId) { - + @@ -236,179 +385,21 @@ {{ l.status | titlecase }} + - @if (expandedClaimLockId === l.lockId) { - - - - } - } - } - -
ClientSavings AccountDividend Amount Locked Date Release Due Status Applied LoanDemand Draft
Loading…
No dividend locks match this filter. + @if (lockSearch) { + No dividend lock matches “{{ lockSearch }}”. + } @else { + No dividend locks match this filter. + } +
{{ l.clientName }} - {{ l.savingsAccountNo }} + {{ l.clientName }} + @if (l.shareAccountNo) { + + } + + @if (l.financialYear) { +
FY {{ l.financialYear }}
+
+ {{ l.productName }} · {{ l.periodStartDate }} → {{ l.periodEndDate }} +
+ } @else { + — + }
{{ l.amount | number: '1.2-2' }} {{ l.lockedDate }} {{ l.appliedLoanAccountNo || '—' }} - @if (l.status === 'UNCLAIMED') { - }
-
-
- Unclaimed amount: {{ l.amount | number: '1.2-2' }} — split it below (loan, savings, cash) -
- @if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) { - - - } @else { -
No overdue loan for this client.
- } - - @if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) { - - } - - @if (claimCashAmount[l.lockId] > 0) { - - } -
- Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }} -
- @if (claimError) { -
{{ claimError }}
- } - -
-
-
- -
- -
- -
- - - - - - - - - - - - - - - @if (claimRequestsLoading) { - - - - } @else if (claimRequestsLoaded && claimRequests.length === 0) { - - - - } @else { - @for (req of claimRequests; track req.id) { - - - - - - - - - - - @if (expandedRejectClaimId === req.id) { - - - - } } } diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss index e2a2e511a..a452f8856 100644 --- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss +++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss @@ -169,3 +169,236 @@ color: #fff; } } + +/* Which declaration a lock belongs to. The financial year leads because that is how a member asks + for it ("my 2026-27 dividend"); the product and exact period sit under it for the clerk who needs + to be certain, without crowding the row. */ +.dd-dividend-year { + font-weight: 600; + font-size: 13px; + color: #1e293b; + white-space: nowrap; +} + +.dd-dividend-period { + font-size: 11.5px; + color: #64748b; + white-space: nowrap; +} + +/* Financial-year chips. Sits below the status chips as a second, narrower axis over the same list — + status is fetched from the server, year filters what came back. */ +.dd-year-filter { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 0 16px 12px; + + &__label { + font-size: 12px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + &__total { + margin-left: auto; + font-size: 12.5px; + color: #475569; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } +} + +/* The DD number just drawn. Approving refreshes the pending list, so the row it came from is gone + by the time the clerk reads it — and that number is what goes on the counterfoil. */ +.dd-issued-banner { + display: flex; + align-items: center; + gap: 12px; + margin: 12px 16px 0; + padding: 10px 14px; + border: 1px solid #86efac; + border-radius: 6px; + background: #f0fdf4; + color: #166534; + font-size: 13.5px; + + &__main { + flex: 1; + } + + &__close { + border: none; + background: none; + color: #166534; + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0 4px; + + &:hover { + color: #14532d; + } + } +} + +/* One toolbar per table: search on the left where the eye lands first, the row count next to it so + a narrowed table says how much it is hiding, and Refresh pushed to the far right. */ +.dd-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 12px 16px; + + &__count { + margin-left: auto; + font-size: 12.5px; + color: #475569; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } +} + +.dd-search { + position: relative; + flex: 1 1 260px; + max-width: 360px; + + &__input { + width: 100%; + padding: 7px 30px 7px 11px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 13px; + color: #0f172a; + background: #fff; + + &::placeholder { + color: #94a3b8; + } + + &:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgb(37 99 235 / 12%); + } + + /* The browser's own clear affordance would sit under ours. */ + &::-webkit-search-cancel-button { + appearance: none; + } + } + + /* Only rendered while there is something to clear, so it never reads as a dead control. */ + &__clear { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: #64748b; + font-size: 17px; + line-height: 1; + cursor: pointer; + + &:hover { + background: #e2e8f0; + color: #0f172a; + } + } +} + +/* The share account number under the member's name — the thing a clerk matches against the + passbook in front of them, so it has to be readable but must not outweigh the name. */ +.dd-account-no { + margin-top: 2px; + font-size: 11.5px; + color: #64748b; + font-variant-numeric: tabular-nums; +} + +/* Horizontal tab strip: one list on screen at a time, chosen here. + Scrolls rather than wraps on a narrow window, so the strip stays one line and the tab positions + a clerk has learned do not move. */ +.dd-tabs { + display: flex; + gap: 4px; + margin-bottom: 16px; + padding-bottom: 2px; + border-bottom: 1px solid #e2e8f0; + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.dd-tab { + display: inline-flex; + align-items: center; + gap: 7px; + flex: 0 0 auto; + padding: 9px 14px; + border: 0; + + /* The active marker is this underline, so every tab reserves the space for it — otherwise the + strip would shift by 2px each time the selection moved. */ + border-bottom: 2px solid transparent; + background: transparent; + font-size: 12.5px; + font-weight: 600; + color: #64748b; + white-space: nowrap; + cursor: pointer; + + &:hover { + color: #1e40af; + } + + &--active { + color: #1e40af; + border-bottom-color: #2563eb; + } +} + +.dd-tab__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 18px; + padding: 0 6px; + border-radius: 9999px; + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 700; + font-variant-numeric: tabular-nums; + + /* A member blocked by an overdue EMI, or a claim waiting on a manager, is work outstanding — + coloured so it reads as something to open rather than a neutral tally. */ + &--alert { + background: #fef3c7; + color: #92400e; + } +} + +/* The draft number a member's dividend was paid out on. Monospaced-by-figure so a clerk reading it + against the counterfoil can compare digit by digit. */ +.dd-dd-number { + font-size: 12.5px; + font-weight: 700; + color: #0f172a; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts index 5ddb98147..1c5ea3642 100644 --- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts +++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts @@ -9,17 +9,21 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; import { catchError } from 'rxjs/operators'; import { of } from 'rxjs'; import { DividendDashboardService } from '../dividend-dashboard.service'; -import { DividendClaimRequestService } from '../dividend-claim-request.service'; /** - * Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year - * lock history, members whose unclaimed dividend sits alongside an overdue loan EMI (with a - * proactive "Apply to Loan" action), and the maker-checker claim workflow for members who come to - * claim their dividend directly (split across loan, savings, or cash). + * Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year lock + * history, and members whose unclaimed dividend sits alongside an overdue loan EMI (with a + * proactive "Apply to Loan" action). + * + * Claiming a dividend hands off to the DD inventory, because a dividend is only ever collected as a + * Demand Draft. The draft is then approved on the DD Issuance Approvals screen alongside every + * other draft, so there is no separate claim queue here — the lock history shows the DD number once + * it has been drawn. */ @Component({ selector: 'mifosx-dividend-dashboard', @@ -37,7 +41,20 @@ import { DividendClaimRequestService } from '../dividend-claim-request.service'; }) export class DividendDashboardComponent implements OnInit { private service = inject(DividendDashboardService); - private claimRequestService = inject(DividendClaimRequestService); + private router = inject(Router); + + /** + * Which list the clerk is looking at. + * + * The three lists answer unrelated questions — what has been declared, who is blocked by an + * overdue EMI, and the full lock history — and a clerk is only ever in one of them. Stacked on one + * page they made the screen long enough that the section a clerk came for was usually below the + * fold, so they are tabbed instead. + * + * Every list is still loaded on init rather than on first view: the counts in the tab strip have + * to be right before a tab is opened, or the strip would be advertising numbers it does not know. + */ + activeTab: 'declarations' | 'overdue' | 'locks' = 'declarations'; rows: any[] = []; loading = false; @@ -56,181 +73,72 @@ export class DividendDashboardComponent implements OnInit { declarationsLoading = false; declarationsLoaded = false; + /** + * Free-text search, one box per table. + * + * Kept separate rather than shared because the three tables answer different questions and a + * clerk is usually in one of them: narrowing the lock history to a member should not blank out + * the declarations table above it, which has nothing to do with that member. + * + * Filtering is done here rather than server-side because every row is already loaded — these are + * a society's dividends, not a transaction ledger — so a round trip per keystroke would be slower + * than the filter itself and would blank the table while it ran. + */ + declarationSearch = ''; + overdueSearch = ''; + lockSearch = ''; + + /** Every lock the status filter returned, before the year filter narrows it. */ locks: any[] = []; locksLoading = false; locksLoaded = false; lockStatusFilter = 'ALL'; - expandedClaimLockId: number | null = null; - claimOverdueLoans: Record = {}; - claimLoanId: Record = {}; - claimLoanAmount: Record = {}; - claimSavingsAmount: Record = {}; - claimSavingsAccountOverride: Record = {}; - claimCashAmount: Record = {}; - claimTokNo: Record = {}; - claimSubmitting = false; - claimError: string | null = null; - - claimRequests: any[] = []; - claimRequestsLoading = false; - claimRequestsLoaded = false; - claimActioningId: number | null = null; - claimActionError: Record = {}; - expandedRejectClaimId: number | null = null; - rejectReason: Record = {}; + /** + * Financial years that actually have locks, newest first — derived from the data rather than + * listed anywhere, so a new declaration adds its year on its own and a year whose locks are all + * gone stops being offered. No maintenance as years roll over. + */ + lockYears: string[] = []; + lockYearFilter = 'ALL'; ngOnInit(): void { this.loadSummary(); this.loadDeclarations(); this.load(); this.loadLocks(); - this.loadClaimRequests(); } - toggleClaim(lock: any): void { - if (this.expandedClaimLockId === lock.lockId) { - this.expandedClaimLockId = null; - return; - } - this.expandedClaimLockId = lock.lockId; - this.claimError = null; - this.claimLoanId[lock.lockId] = null; - this.claimLoanAmount[lock.lockId] = null; - this.claimSavingsAmount[lock.lockId] = Number(lock.amount); - this.claimSavingsAccountOverride[lock.lockId] = null; - this.claimCashAmount[lock.lockId] = null; - this.claimTokNo[lock.lockId] = ''; - this.claimRequestService - .getOverdueLoans(lock.clientId) - .pipe(catchError(() => of([]))) - .subscribe((loans: any[]) => { - this.claimOverdueLoans[lock.lockId] = loans ?? []; - if (loans && loans.length > 0) { - this.claimLoanId[lock.lockId] = loans[0].loanId; - const loanAmt = Math.min(Number(lock.amount), Number(loans[0].overdueAmount)); - this.claimLoanAmount[lock.lockId] = loanAmt; - this.claimSavingsAmount[lock.lockId] = Number((Number(lock.amount) - loanAmt).toFixed(2)); + /** + * Takes the clerk to the DD inventory to draw the draft this dividend is paid out as. + * + * A dividend is only ever collected as a Demand Draft, so the claim is really a request to issue + * one — and the place a draft is issued is the leaflet inventory, where the clerk can see which + * numbered leaflet they are about to commit. Gathering the draft's details on this dashboard + * instead meant filling a second, thinner version of the DD form and never seeing the DD number + * until after approval. + * + * The dividend travels in the query string rather than in a service, so a reloaded or + * bookmarked page still knows which lock it is drawing against instead of silently issuing an + * ordinary counter draft. + */ + claimAsDemandDraft(lock: any): void { + this.router.navigate( + [ + '/sponsor-dd', + 'inventory' + ], + { + queryParams: { + dividendLockId: lock.lockId, + dividendAmount: Number(lock.amount), + dividendBeneficiary: lock.clientName ?? '', + dividendFinancialYear: lock.financialYear ?? '' } - }); - } - - claimTotal(lock: any): number { - return ( - Number(this.claimLoanAmount[lock.lockId] || 0) + - Number(this.claimSavingsAmount[lock.lockId] || 0) + - Math.floor(Number(this.claimCashAmount[lock.lockId] || 0)) + } ); } - submitClaim(lock: any): void { - const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0); - const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0); - // Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), while a - // dividend share often works out to paise — floor to the nearest rupee so the denomination - // total the cashier counts can always match exactly; any leftover paise stays unclaimed. - const cashAmount = Math.floor(Number(this.claimCashAmount[lock.lockId] || 0)); - const total = loanAmount + savingsAmount + cashAmount; - if (total <= 0) { - this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.'; - return; - } - if (total > Number(lock.amount)) { - this.claimError = `Total (${total}) cannot exceed the unclaimed amount of ${lock.amount}.`; - return; - } - if (loanAmount > 0 && !this.claimLoanId[lock.lockId]) { - this.claimError = 'Select a loan to apply the loan amount towards.'; - return; - } - if (savingsAmount > 0 && !lock.savingsAccountId && !this.claimSavingsAccountOverride[lock.lockId]) { - this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.'; - return; - } - const tokNo = this.claimTokNo[lock.lockId]?.trim(); - if (cashAmount > 0 && !tokNo) { - this.claimError = 'Enter a Token No. for the cash portion — the cashier will use it to hand over the cash.'; - return; - } - this.claimSubmitting = true; - this.claimError = null; - this.claimRequestService - .submit(lock.lockId, { - loanId: loanAmount > 0 ? this.claimLoanId[lock.lockId] : undefined, - loanApplyAmount: loanAmount > 0 ? loanAmount : undefined, - savingsAccountId: - savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined, - savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined, - cashApplyAmount: cashAmount > 0 ? cashAmount : undefined, - tokNo: cashAmount > 0 ? tokNo : undefined - }) - .subscribe({ - next: () => { - this.claimSubmitting = false; - this.expandedClaimLockId = null; - this.loadClaimRequests(); - }, - error: (err: any) => { - this.claimSubmitting = false; - this.claimError = err?.error?.message || 'Failed to submit claim request.'; - } - }); - } - - loadClaimRequests(): void { - this.claimRequestsLoading = true; - this.claimRequestService - .list('PENDING') - .pipe(catchError(() => of([]))) - .subscribe((res: any[]) => { - this.claimRequests = res ?? []; - this.claimRequestsLoading = false; - this.claimRequestsLoaded = true; - }); - } - - approveClaim(req: any): void { - this.claimActioningId = req.id; - delete this.claimActionError[req.id]; - this.claimRequestService.approve(req.id).subscribe({ - next: () => { - this.claimActioningId = null; - this.loadClaimRequests(); - this.loadLocks(); - this.loadSummary(); - }, - error: (err: any) => { - this.claimActioningId = null; - this.claimActionError[req.id] = err?.error?.message || 'Failed to approve claim.'; - } - }); - } - - toggleRejectClaim(req: any): void { - this.expandedRejectClaimId = this.expandedRejectClaimId === req.id ? null : req.id; - } - - rejectClaim(req: any): void { - const reason = this.rejectReason[req.id]; - if (!reason || !reason.trim()) { - this.claimActionError[req.id] = 'Rejection reason is required.'; - return; - } - this.claimActioningId = req.id; - delete this.claimActionError[req.id]; - this.claimRequestService.reject(req.id, reason).subscribe({ - next: () => { - this.claimActioningId = null; - this.expandedRejectClaimId = null; - this.loadClaimRequests(); - }, - error: (err: any) => { - this.claimActioningId = null; - this.claimActionError[req.id] = err?.error?.message || 'Failed to reject claim.'; - } - }); - } - loadSummary(): void { this.summaryLoading = true; this.service @@ -254,6 +162,33 @@ export class DividendDashboardComponent implements OnInit { }); } + /** + * Whether any of a row's searchable fields contains the term. + * + * Substring rather than prefix matching, because a clerk reading an account number off a passbook + * often types the distinctive tail of it rather than the branch prefix every account shares. Null + * fields are skipped — a lock with no share account still has to be findable by member name. + */ + private matches(term: string, ...fields: (string | number | null | undefined)[]): boolean { + const needle = term.trim().toLowerCase(); + if (!needle) { + return true; + } + return fields.some((field) => field != null && String(field).toLowerCase().includes(needle)); + } + + /** Declarations the table renders. Product-level rows, so there is no member to search on. */ + get visibleDeclarations(): any[] { + return this.declarations.filter((d) => + this.matches(this.declarationSearch, d.productName, d.periodStartDate, d.periodEndDate, d.status) + ); + } + + /** Members with an overdue EMI, narrowed to the one the clerk is dealing with. */ + get visibleOverdueRows(): any[] { + return this.rows.filter((r) => this.matches(this.overdueSearch, r.clientName, r.shareAccountNo)); + } + declarationStatusLabel(status: string): string { return status === 'PENDING_APPROVAL' ? 'Pending Approval' : 'Approved'; } @@ -269,16 +204,54 @@ export class DividendDashboardComponent implements OnInit { .pipe(catchError(() => of([]))) .subscribe((res: any[]) => { this.locks = res ?? []; + this.rebuildLockYears(); this.locksLoading = false; this.locksLoaded = true; }); } + /** + * Rebuilds the year chips from whatever locks came back, newest first. + * + * A year the current status filter has no locks for disappears from the chips, so the selected + * year is reset to ALL when that happens — otherwise the table would show nothing with no + * indication why. + */ + private rebuildLockYears(): void { + this.lockYears = [...new Set(this.locks.map((l) => l.financialYear).filter((y) => !!y))].sort((a, b) => + b.localeCompare(a) + ); + if (this.lockYearFilter !== 'ALL' && !this.lockYears.includes(this.lockYearFilter)) { + this.lockYearFilter = 'ALL'; + } + } + + /** + * Locks the table actually renders — the status filter is applied server-side, the year and the + * search here. All three narrow together, so a clerk can hold a year and still search within it. + */ + get visibleLocks(): any[] { + return this.locks.filter( + (l) => + (this.lockYearFilter === 'ALL' || l.financialYear === this.lockYearFilter) && + this.matches(this.lockSearch, l.clientName, l.shareAccountNo, l.productName, l.appliedLoanAccountNo) + ); + } + + /** Total of what is on screen, so a year's worth of dividend can be read off directly. */ + get visibleLocksTotal(): number { + return this.visibleLocks.reduce((sum, l) => sum + (Number(l.amount) || 0), 0); + } + setLockStatusFilter(status: string): void { this.lockStatusFilter = status; this.loadLocks(); } + setLockYearFilter(year: string): void { + this.lockYearFilter = year; + } + lockStatusClass(status: string): string { switch (status) { case 'UNCLAIMED': diff --git a/src/app/clerk/my-closure-requests/my-closure-requests.component.html b/src/app/clerk/my-closure-requests/my-closure-requests.component.html new file mode 100644 index 000000000..e24e2b230 --- /dev/null +++ b/src/app/clerk/my-closure-requests/my-closure-requests.component.html @@ -0,0 +1,79 @@ + + +
+
+
+
CLERK · CLOSURE
+

My Closure Requests

+

+ Account closure requests you have submitted, and their approval / cashier status. To close an account, use the + "Close this account" action on the account's own Transaction Entry screen. +

+
+
+ +
+
+ +
+
ClientSavings AccountTo LoanTo SavingsCashUnclaimed BalanceClient Loan Status
Loading…
No dividend claim requests pending review.
{{ req.client_name }} - {{ req.savings_account_no }} - - {{ - req.loan_apply_amount > 0 - ? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')' - : '—' - }} - {{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }} - {{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }} - @if (req.cash_apply_amount > 0 && req.tok_no) { -
Tok: {{ req.tok_no }}
- } -
{{ req.locked_amount | number: '1.2-2' }} - @if (req.has_overdue_loan) { - Has Overdue EMI - } @else { - No Overdue Loan - } - - - - @if (claimActionError[req.id]) { -
{{ claimActionError[req.id] }}
- } -
-
- - -
-
+ + + + + + + + + + + + + @if (loading) { + + + + } @else if (loaded && requests.length === 0) { + + + + } @else { + @for (r of requests; track r.id) { + + + + + + + + + + } + } + +
Account NoClientPayout ModeToken NoClosing AmountSubmitted AtStatus
Loading…
No closure requests found.
+ {{ r.account_no }} + {{ r.client_name }}{{ r.payment_type_name || 'Cash' }}{{ r.tok_no || '—' }} + @if (r.closing_amount != null) { + ₹{{ r.closing_amount | number: '1.2-2' }} + } @else { + — + } + {{ formatDateTime(r.submitted_at) }} + {{ statusLabel(r.status) }} +
+
+
diff --git a/src/app/clerk/my-closure-requests/my-closure-requests.component.ts b/src/app/clerk/my-closure-requests/my-closure-requests.component.ts new file mode 100644 index 000000000..6f7693fe1 --- /dev/null +++ b/src/app/clerk/my-closure-requests/my-closure-requests.component.ts @@ -0,0 +1,112 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { ClerkClosureRequestService } from '../../cashier/clerk-closure-request.service'; + +/** Clerk Dashboard "My Closure Requests" — lists the Cash account-closure requests this clerk has + * submitted (from the account's own Transaction Entry screen) and their approval/disbursement + * status. Mirrors MyShareWithdrawalRequestsComponent. There is no "New Closure Request" button + * here -- submission happens in-context on the account's own screen, not from this list. */ +@Component({ + selector: 'mifosx-my-closure-requests', + templateUrl: './my-closure-requests.component.html', + styleUrls: [ + '../../cashier/cashier-shared.scss', + '../clerk-shared.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule + ] +}) +export class MyClosureRequestsComponent implements OnInit { + private service = inject(ClerkClosureRequestService); + + requests: any[] = []; + totalRecords = 0; + loading = false; + loaded = false; + + filterStatus = ''; + + get statusOptions(): { value: string; label: string }[] { + return [ + { value: '', label: 'All' }, + { value: 'PENDING', label: 'Pending' }, + { value: 'APPROVED', label: 'Approved — awaiting cashier' }, + { value: 'REJECTED', label: 'Rejected' }, + { value: 'CLOSED', label: 'Closed' } + ]; + } + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.service + .getMyRequests(this.filterStatus || undefined, 0, 50) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) + .subscribe((res: any) => { + this.requests = res?.pageItems ?? []; + this.totalRecords = Number(res?.totalFilteredRecords ?? 0); + this.loading = false; + this.loaded = true; + }); + } + + onFilterChange(): void { + this.load(); + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'inv-badge--active', + APPROVED: 'inv-badge--matured', + REJECTED: 'inv-badge--written_off', + CLOSED: 'inv-badge--matured' + }; + return `inv-badge ${map[status] ?? ''}`; + } + + statusLabel(status: string): string { + const map: Record = { + PENDING: 'Pending Approval', + APPROVED: 'Awaiting Cashier', + REJECTED: 'Rejected', + CLOSED: 'Closed' + }; + return map[status] ?? status; + } + + formatDateTime(val: any): string { + if (!val) return '—'; + try { + const d = new Date(val); + return d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return String(val); + } + } +} diff --git a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html index a5dc03299..634cdd3ba 100644 --- a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html +++ b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html @@ -12,13 +12,15 @@
swap_horiz
-
My Disbursement Requests
-
Track your submitted GL disbursement requests.
+
Disbursements & Transfer
+
+ Disbursement, Collection, Fund Transfer and Bank Transfer requests you submitted. +
@@ -99,7 +101,7 @@

You have not submitted any disbursement requests yet.

} @else { @@ -170,7 +172,9 @@ {{ ce.accountNo || '—' }} {{ ce.accountName || '—' }} - {{ ce.accountType || '—' }} + {{ + ce.accountType ? accountTypeLabel(ce.accountType) : '—' + }} {{ ce.amount | number: '1.2-2' }} diff --git a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts index c75d6cac9..9f57f9280 100644 --- a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts +++ b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts @@ -177,7 +177,29 @@ export class MyDisbursementRequestsComponent implements OnInit { getCreditEntries(req: any): any[] { try { const arr = typeof req.credit_entries === 'string' ? JSON.parse(req.credit_entries) : req.credit_entries; - return Array.isArray(arr) ? arr.map((e: any) => ({ ...e, amount: parseFloat(e.amount) || 0 })) : []; + const entries: any[] = Array.isArray(arr) + ? arr.map((e: any) => ({ ...e, amount: parseFloat(e.amount) || 0 })) + : []; + // Collection embeds its GL leg inside credit_entries (it can collect from several + // accounts into the one GL, so the GL has to be one row among them). Disbursement and + // Fund Transfer instead name their GL as the request's own top-level source — dr_amount + // is its amount — because there is exactly one, so it never needed a row of its own. That + // meant the DR/CR breakdown here only ever showed the credit side for those two types, + // looking like half the slip was missing. Adding it back as the first row here, rather + // than in credit_entries itself, keeps the stored JSON exactly what approve() posts from. + if ((req.request_type === 'DISBURSEMENT' || req.request_type === 'FUND_TRANSFER') && req.gl_account_no) { + return [ + { + dbCr: 'Dr', + accountNo: req.gl_account_no, + accountName: req.gl_account_name, + accountType: 'GL', + amount: parseFloat(req.dr_amount) || 0 + }, + ...entries + ]; + } + return entries; } catch { return []; } @@ -187,6 +209,23 @@ export class MyDisbursementRequestsComponent implements OnInit { return this.getCreditEntries(req).length; } + /** + * Human-readable account type for the badge. The stored value is the backend's own code + * (SAVINGS / CURRENT / FIXED_DEPOSIT / RECURRING_DEPOSIT / SHARE / LOAN / GL); presentation only. + */ + accountTypeLabel(accountType: string): string { + const labels: Record = { + SAVINGS: 'Savings', + CURRENT: 'Current', + FIXED_DEPOSIT: 'Fixed Deposit', + RECURRING_DEPOSIT: 'Recurring Deposit', + SHARE: 'Share', + LOAN: 'Loan', + GL: 'GL' + }; + return labels[(accountType ?? '').toUpperCase()] ?? accountType; + } + statusClass(status: string): string { const map: Record = { PENDING: 'mdr-status--pending', diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index f5ad0590e..dbe9d8b20 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -263,7 +263,6 @@ {{ 'labels.inputs.Last Name' | translate }} person - <<<<<<< Updated upstream - ======= - - >>>>>>> Stashed changes @if (createClientForm.controls.lastname.hasError('required')) { Last name is required } @else if (createClientForm.controls.lastname.hasError('pattern')) { @@ -398,7 +394,7 @@ } - + @if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
@@ -973,6 +969,11 @@ createClientForm.get('alternateMobileNo')?.touched ) { Enter valid 10-digit number (starts with 6-9) + } @else if ( + createClientForm.get('alternateMobileNo')?.hasError('sameAsMobile') && + createClientForm.get('alternateMobileNo')?.touched + ) { + Alternate number can't be the same as Mobile No } @@ -983,7 +984,11 @@ color="primary" type="button" class="verify-btn" - [disabled]="readOnly || createClientForm.get('alternateMobileNo')?.value?.length !== 10" + [disabled]=" + readOnly || + createClientForm.get('alternateMobileNo')?.value?.length !== 10 || + createClientForm.get('alternateMobileNo')?.hasError('sameAsMobile') + " (click)="verifyAlternateDIP()" > diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 8ea5bf5e5..ed53470c4 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -25,7 +25,10 @@ import { UntypedFormGroup, Validators, UntypedFormControl, - ReactiveFormsModule + ReactiveFormsModule, + ValidatorFn, + AbstractControl, + ValidationErrors } from '@angular/forms'; import { Subject, Subscription, merge } from 'rxjs'; import { filter, switchMap, takeUntil } from 'rxjs/operators'; @@ -56,6 +59,12 @@ import { ContactOtpBlockComponent, ContactOtpState } from 'app/clients/edit-client/contact-otp-block/contact-otp-block.component'; +import { + MINOR_AGE_THRESHOLD, + SENIOR_CITIZEN_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; /** * Create Client Component @@ -299,10 +308,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges ]; /** Calculated age detail string (Y/M/D) */ calculatedAgeDetail: string | null = null; - /** Whether client is a minor (age < 18) */ + /** Whether client is a minor — threshold from the shared age util, derived from Date of Birth */ isMinor = false; - /** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */ + /** Whether client is a senior citizen — threshold from the shared age util, derived from DOB */ isSeniorCitizen = false; + /** Exposed for template hints so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; + readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD; /** Age breakdown for DB storage */ ageBreakdown = { years: 0, months: 0, days: 0 }; @@ -913,6 +925,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges } } + /** Alternate Mobile No must differ from Mobile No — the same number saved in both fields is + * meaningless (there's no second contact route) and the DIP checks would just repeat. */ + private notSameAsMobileNoValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + const mobileNo = control.parent?.get('mobileNo')?.value; + return value && mobileNo && value === mobileNo ? { sameAsMobile: true } : null; + }; + } + /** * Creates the client form. */ @@ -952,7 +974,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges '', [ Validators.pattern(/^[6-9]\d{9}$/), - Validators.maxLength(10) + Validators.maxLength(10), + this.notSameAsMobileNoValidator() ] ], emailAddress: [ @@ -1237,8 +1260,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`; this.ageBreakdown = { years, months, days }; const wasMinor = this.isMinor; - this.isMinor = years < 18; - this.isSeniorCitizen = years >= 60; + this.isMinor = isMinorAge(years); + this.isSeniorCitizen = isSeniorCitizenAge(years); // Add/remove guardian form group based on minor status if (this.isMinor && !wasMinor) { this.createClientForm.addControl( @@ -1325,6 +1348,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges // invalid) and its own reset never had a chance to run. this.mobileOtpVerified = false; this.mobileNewOtpId = null; + // Alternate Mobile No's "not same as Mobile No" validator reads mobileNo's current + // value, so re-run it here too — otherwise fixing mobileNo away from a clash leaves + // the alternate field's error stuck until the user also edits that field. + this.createClientForm.get('alternateMobileNo')?.updateValueAndValidity({ emitEvent: false }); }); this.createClientForm @@ -2423,6 +2450,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges { upTo: null, category: 3, label: 'above ₹10 crore' } ]; + /** + * A numeric rule bound counts as configured only when it is present and above zero. + * + * Migration 0448 replaced every unset income/turnover NULL with 0.0, so "absent" now arrives as + * either. Every comparison in calculatedRisk already pairs its check with `> 0` for that reason; + * this keeps the criteria-less test reading the columns the same way. + */ + private static isConfigured(bound: number | string | null | undefined): boolean { + return bound != null && parseFloat(String(bound)) > 0; + } + /** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */ riskCategoryLabel(category: number): string { if (category === 3) { @@ -2509,17 +2547,32 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges // Evaluate rules from DB (ordered by orderPosition ASC) if (this.riskRules && this.riskRules.length > 0) { for (const rule of this.riskRules) { - // Skip transaction/AML rules that have no KYC criteria + // Only KYC profile rules categorise a customer at onboarding. The same table also holds + // ~221 AML transaction-monitoring scenarios ("Large cash transaction", "Additional RFIs + // Rule - 91", ...) which describe behaviour and may only fire against posted transactions, + // as AML alerts — RBI Master Direction on KYC separates risk categorisation (Section 12) + // from transaction monitoring (Section 13). ruleKind, set on the row, is what tells them + // apart; it replaced an inference from "this rule has no criteria set", which migration + // 0448 silently broke by writing 0.0 over those NULLs. That let rule 91 match a salaried + // client on an income band it was never meant to carry and pre-flag them for AML. + if (rule.ruleKind === 'AML') { + continue; + } + + // Backstop for a rule row predating rule_kind: a rule naming no occupation, no legal form + // and no income or turnover band constrains nothing, so it would match every customer. + // Treat 0 as unset — that is how the comparisons below read it, and what 0448 left behind. if ( + rule.ruleKind == null && rule.legalFormId == null && rule.occupationCategoryCvId == null && rule.occupationCvId == null && - rule.minMonthlyIncome == null && - rule.maxMonthlyIncome == null && - rule.minAnnualIncome == null && - rule.maxAnnualIncome == null && - rule.minAnnualTurnover == null && - rule.maxAnnualTurnover == null + !ClientGeneralStepComponent.isConfigured(rule.minMonthlyIncome) && + !ClientGeneralStepComponent.isConfigured(rule.maxMonthlyIncome) && + !ClientGeneralStepComponent.isConfigured(rule.minAnnualIncome) && + !ClientGeneralStepComponent.isConfigured(rule.maxAnnualIncome) && + !ClientGeneralStepComponent.isConfigured(rule.minAnnualTurnover) && + !ClientGeneralStepComponent.isConfigured(rule.maxAnnualTurnover) ) { continue; } @@ -2567,7 +2620,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges return { category: rule.riskCategory, reason: rule.reason, - amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'), + // A profile rule sets the risk category; it does not raise an AML flag. High Risk means + // enhanced due diligence and a 2-yearly KYC refresh (RBI MD Sections 12 and 38), not + // suspicion — an alert belongs to observed transaction behaviour (Section 13), so a + // customer who has transacted nothing yet stays CLEAR. A bank that genuinely wants a + // rule to flag on match can still say so explicitly via the rule's own amlCheckStatus. + amlCheckStatus: rule.amlCheckStatus || 'CLEAR', bannedEntityMatch: rule.bannedEntityMatch || 'NO', willfulDefaulter: rule.willfulDefaulter || 'NO', ecgcSalFlag: rule.ecgcSalFlag || 'NO', @@ -2590,28 +2648,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges dormantFlag: 'ACTIVE' }; } - - /** - * Automatically capitalizes the first letter of each word and lowercases the rest. - * e.g., "sri rAm" -> "Sri Ram" - */ - formatName(controlName: string): void { - const control = this.createClientForm.get(controlName); - if (!control || typeof control.value !== 'string') return; - - const val = control.value; - const formatted = val - .split(' ') - .map((word: string) => { - if (!word) return ''; - return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); - }) - .join(' '); - - if (formatted !== val) { - control.setValue(formatted, { emitEvent: false }); - } - } } /** diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html index c1922f22c..4daf63941 100644 --- a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html +++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html @@ -221,6 +221,22 @@ } + + Expiry date (optional) + event_busy + + + +
@@ -238,14 +254,18 @@ @if (existingAddressProofDocuments.length > 0 && !addrFiles.front && readOnly) {
- @if (addrMergedPreview) { - Saved address proof + @if (existingAddrFrontSlotPreview) { + Saved address proof } @else { description }
- {{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }} + {{ existingAddrFrontSlotName }}
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html index 6c0cb5670..4f71a5b6d 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html @@ -204,6 +204,24 @@ from {{ existing.ADDRESS_PROOF.source }}
+ @if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) { +
+ @if (existing.ADDRESS_PROOF.previewUrl) { + Address proof scan - front + } + @if (existing.ADDRESS_PROOF.previewUrlBack) { + Address proof scan - back + } +
+ }
{{ existing.ADDRESS_PROOF.name }} @@ -217,78 +235,51 @@ }
+ @if (cardDate('ADDRESS_PROOF', 'issue') || cardDate('ADDRESS_PROOF', 'expiry')) { +
+ @if (cardDate('ADDRESS_PROOF', 'issue')) { + Issued {{ cardDate('ADDRESS_PROOF', 'issue') }} + } + @if (cardDate('ADDRESS_PROOF', 'expiry')) { + Expires {{ cardDate('ADDRESS_PROOF', 'expiry') }} + } +
+ } +
+ } @else { +
+

Not uploaded yet. Accepted formats: PNG, JPEG, JPG, PDF

+
} - @if (!existing.ADDRESS_PROOF.present || existing.ADDRESS_PROOF.expanded) { -

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

-
- - - - - + @if (uploading['ADDRESS_PROOF']) { +
+ + Uploading… +
+ } + @if (uploadErrors['ADDRESS_PROOF']) { +
+ error_outline + {{ uploadErrors['ADDRESS_PROOF'] }}
- - @if (uploadErrors['ADDRESS_PROOF']) { -
- error_outline - {{ uploadErrors['ADDRESS_PROOF'] }} -
- } - @if (uploadResponses['ADDRESS_PROOF']) { -
-

- {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['ADDRESS_PROOF'].status }} -

-

- {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['ADDRESS_PROOF'].requestId }} -

-

- {{ 'labels.inputs.Acknowledgement Number' | translate }}: - {{ uploadResponses['ADDRESS_PROOF'].acknowledgementNumber }} -

-

- {{ 'labels.inputs.Processing Status' | translate }}: - {{ uploadResponses['ADDRESS_PROOF'].processingStatus }} -

-
- } } @@ -310,6 +301,24 @@ from {{ existing.PHOTO_PROOF.source }}
+ @if (existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) { +
+ @if (existing.PHOTO_PROOF.previewUrl) { + ID proof scan - front + } + @if (existing.PHOTO_PROOF.previewUrlBack) { + ID proof scan - back + } +
+ }
{{ existing.PHOTO_PROOF.name }} @@ -322,78 +331,49 @@ }
+ @if (cardDate('PHOTO_PROOF', 'issue') || cardDate('PHOTO_PROOF', 'expiry')) { +
+ @if (cardDate('PHOTO_PROOF', 'issue')) { + Issued {{ cardDate('PHOTO_PROOF', 'issue') }} + } + @if (cardDate('PHOTO_PROOF', 'expiry')) { + Expires {{ cardDate('PHOTO_PROOF', 'expiry') }} + } +
+ } + + } @else { +
+

Not uploaded yet. Accepted formats: PNG, JPEG, JPG, PDF

+
} - @if (!existing.PHOTO_PROOF.present || existing.PHOTO_PROOF.expanded) { -

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

-
- - - - - + @if (uploading['PHOTO_PROOF']) { +
+ + Uploading… +
+ } + @if (uploadErrors['PHOTO_PROOF']) { +
+ error_outline + {{ uploadErrors['PHOTO_PROOF'] }}
- - @if (uploadErrors['PHOTO_PROOF']) { -
- error_outline - {{ uploadErrors['PHOTO_PROOF'] }} -
- } - @if (uploadResponses['PHOTO_PROOF']) { -
-

- {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['PHOTO_PROOF'].status }} -

-

- {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['PHOTO_PROOF'].requestId }} -

-

- {{ 'labels.inputs.Acknowledgement Number' | translate }}: - {{ uploadResponses['PHOTO_PROOF'].acknowledgementNumber }} -

-

- {{ 'labels.inputs.Processing Status' | translate }}: - {{ uploadResponses['PHOTO_PROOF'].processingStatus }} -

-
- } } @@ -422,74 +402,33 @@ mat-stroked-button type="button" class="ckyc-existing__replace" - (click)="toggleReplace('CLIENT_IMAGE')" + (click)="openUploadDialog('CLIENT_IMAGE')" > - - {{ existing.CLIENT_IMAGE.expanded ? 'Cancel replace' : 'Upload new version' }} + + Upload new version + +
+ } @else { +
+

Not uploaded yet. Accepted formats: PNG, JPEG, JPG

+
} - @if (!existing.CLIENT_IMAGE.present || existing.CLIENT_IMAGE.expanded) { -

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

-
- - - - - + @if (uploading['CLIENT_IMAGE']) { +
+ + Uploading… +
+ } + @if (uploadErrors['CLIENT_IMAGE']) { +
+ error_outline + {{ uploadErrors['CLIENT_IMAGE'] }}
- - @if (uploadErrors['CLIENT_IMAGE']) { -
- error_outline - {{ uploadErrors['CLIENT_IMAGE'] }} -
- } - @if (uploadResponses['CLIENT_IMAGE']) { -
-

- {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['CLIENT_IMAGE'].status }} -

-

- {{ 'labels.inputs.Request ID' | translate }}: {{ uploadResponses['CLIENT_IMAGE'].requestId }} -

-

- {{ 'labels.inputs.Acknowledgement Number' | translate }}: - {{ uploadResponses['CLIENT_IMAGE'].acknowledgementNumber }} -

-

- {{ 'labels.inputs.Processing Status' | translate }}: - {{ uploadResponses['CLIENT_IMAGE'].processingStatus }} -

-
- } } @@ -526,75 +465,33 @@ mat-stroked-button type="button" class="ckyc-existing__replace" - (click)="toggleReplace('CLIENT_SIGNATURE')" + (click)="openUploadDialog('CLIENT_SIGNATURE')" > - - {{ existing.CLIENT_SIGNATURE.expanded ? 'Cancel replace' : 'Upload new version' }} + + Upload new version + +
+ } @else { +
+

Not uploaded yet. Accepted formats: PNG, JPEG, JPG

+
} - @if (!existing.CLIENT_SIGNATURE.present || existing.CLIENT_SIGNATURE.expanded) { -

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

-
- - - - - + @if (uploading['CLIENT_SIGNATURE']) { +
+ + Uploading… +
+ } + @if (uploadErrors['CLIENT_SIGNATURE']) { +
+ error_outline + {{ uploadErrors['CLIENT_SIGNATURE'] }}
- - @if (uploadErrors['CLIENT_SIGNATURE']) { -
- error_outline - {{ uploadErrors['CLIENT_SIGNATURE'] }} -
- } - @if (uploadResponses['CLIENT_SIGNATURE']) { -
-

- {{ 'labels.inputs.Status' | translate }}: {{ uploadResponses['CLIENT_SIGNATURE'].status }} -

-

- {{ 'labels.inputs.Request ID' | translate }}: - {{ uploadResponses['CLIENT_SIGNATURE'].requestId }} -

-

- {{ 'labels.inputs.Acknowledgement Number' | translate }}: - {{ uploadResponses['CLIENT_SIGNATURE'].acknowledgementNumber }} -

-

- {{ 'labels.inputs.Processing Status' | translate }}: - {{ uploadResponses['CLIENT_SIGNATURE'].processingStatus }} -

-
- } } diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss index 66d527c48..602d2dd3e 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss @@ -553,15 +553,21 @@ margin: 0 0 12px; } -.ckyc-file-row { +.ckyc-missing { + margin-top: 14px; display: flex; + flex-direction: column; + gap: 10px; align-items: flex-start; - gap: 8px; - margin-bottom: 12px; } -.ckyc-file-name-field { - flex: 1; +.ckyc-uploading { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + font-size: 12px; + color: #5f6368; } .ckyc-upload-error { @@ -583,19 +589,6 @@ } } -.ckyc-upload-response { - margin-top: 12px; - padding: 10px 14px; - background-color: #e8f5e9; - border: 1px solid #a5d6a7; - border-radius: 8px; - font-size: 12px; - - p { - margin: 4px 0; - } -} - @media (width <= 900px) { .ckyc-upload-grid { grid-template-columns: 1fr; @@ -610,14 +603,6 @@ .ckyc-upload-grid { grid-template-columns: 1fr; } - - .ckyc-file-row { - flex-direction: column; - } - - .ckyc-file-name-field { - width: 100%; - } } /* ===================== Already-available banners ===================== */ @@ -731,6 +716,34 @@ letter-spacing: 0.3px; } + &__thumbs { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + } + + &__dates { + display: flex; + align-items: center; + gap: 10px; + font-size: 11px; + color: #0f6e56; + flex-wrap: wrap; + + span { + display: inline-flex; + align-items: center; + gap: 5px; + } + + fa-icon { + color: #1d9e75; + font-size: 11px; + } + } + &__thumb { width: 100px; height: 100px; diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts index adcd3d486..0acbbf2d8 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts @@ -9,27 +9,38 @@ /** Angular Imports */ import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Subject, takeUntil, catchError, of, forkJoin } from 'rxjs'; +import { Subject, Observable, takeUntil, catchError, of, forkJoin } from 'rxjs'; /** Custom Services */ import { ClientsService } from '../../clients.service'; import { AlertService } from 'app/core/alert/alert.service'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatDialog } from '@angular/material/dialog'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { Dates } from 'app/core/utils/dates'; +import { ProofType, normalizeProofType } from '../../shared/kyc-proof-types'; +import { CkycUploadDialogComponent, CkycUploadDialogResult } from './ckyc-upload-dialog/ckyc-upload-dialog.component'; interface ExistingArtefact { present: boolean; name?: string; source?: string; previewUrl?: string; + previewUrlBack?: string; documentId?: string; + documentTypeId?: string; documentKey?: string; + /** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention client-india-address-step + * already writes/reads for address proof; now shared by the photo/identity proof too. */ + description?: string; fileCount?: number; - expanded?: boolean; } +type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF'; +type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE'; + @Component({ selector: 'mifosx-ckyc-tab', templateUrl: './ckyc-tab.component.html', @@ -46,6 +57,8 @@ export class CkycTabComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private clientsService = inject(ClientsService); private alertService = inject(AlertService); + private dialog = inject(MatDialog); + private dates = inject(Dates); private destroy$ = new Subject(); @@ -80,16 +93,41 @@ export class CkycTabComponent implements OnInit, OnDestroy { } /** Pre-existing artefacts detected from client data */ - existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = { + existing: Record = { ADDRESS_PROOF: { present: false }, PHOTO_PROOF: { present: false }, CLIENT_IMAGE: { present: false }, CLIENT_SIGNATURE: { present: false } }; + /** Document-type options for the proof pickers, fetched once from /kyc/proof-types */ + addressProofOptions: ProofType[] = []; + idProofOptions: ProofType[] = []; + + /** Document type currently selected in each proof card's picker */ + selectedProofType: Record = { + ADDRESS_PROOF: null, + PHOTO_PROOF: null + }; + + /** Front/back files staged for the two proof cards; other cards keep a single file each */ + docFiles: Record = { + ADDRESS_PROOF: { front: null, back: null }, + PHOTO_PROOF: { front: null, back: null } + }; + + /** Document number + issue/expiry dates staged from the upload dialog, same fields client + * creation collects for a KYC/address proof — persisted onto the identifier's documentKey + * and description ("Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd"). */ + private documentDetails: Record< + ProofKind, + { documentKey: string; issueDate: string | null; expiryDate: string | null } + > = { + ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null }, + PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null } + }; + /** File references */ - addressProofFile: File | null = null; - photoProofFile: File | null = null; clientImageFile: File | null = null; clientSignatureFile: File | null = null; @@ -101,9 +139,6 @@ export class CkycTabComponent implements OnInit, OnDestroy { CLIENT_SIGNATURE: false }; - /** Per-document upload response */ - uploadResponses: { [key: string]: any } = {}; - /** Per-document error messages */ uploadErrors: { [key: string]: string | null } = { ADDRESS_PROOF: null, @@ -262,6 +297,8 @@ export class CkycTabComponent implements OnInit, OnDestroy { } // 3 & 4. ID proof + Address proof — client identifiers classified via proof-types + this.idProofOptions = (proofTypes?.idProofs || []).map((p: any) => normalizeProofType(p)); + this.addressProofOptions = (proofTypes?.addressProofs || []).map((p: any) => normalizeProofType(p)); const idProofIds = new Set((proofTypes?.idProofs || []).map((p: any) => String(p.id))); const addressProofIds = new Set((proofTypes?.addressProofs || []).map((p: any) => String(p.id))); @@ -287,21 +324,77 @@ export class CkycTabComponent implements OnInit, OnDestroy { name: proofName, documentKey: isSyntheticKey ? '' : rawKey, documentId: identity?.id, + documentTypeId: docTypeId, + description: identity?.description || '', fileCount: (identity?.documents || []).length }; if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) { this.existing.PHOTO_PROOF = common; + this.selectedProofType.PHOTO_PROOF = this.idProofOptions.find((p) => String(p.id) === docTypeId) || null; + this.loadIdentifierDocPreview('PHOTO_PROOF', identity.id); } else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) { this.existing.ADDRESS_PROOF = common; + this.selectedProofType.ADDRESS_PROOF = + this.addressProofOptions.find((p) => String(p.id) === docTypeId) || null; + this.loadIdentifierDocPreview('ADDRESS_PROOF', identity.id); } } }); } - toggleReplace(documentType: string): void { - const entry = (this.existing as any)[documentType]; - if (entry) entry.expanded = !entry.expanded; + /** + * The scanned ID/address-proof card only showed its file name and key — never the scan + * itself, unlike the photo and signature cards right beside it. Mirrors those two: fetch + * the identifier's own attached document(s) (m_client_identifier -> m_document, the same + * chain the Identities tab and the create-client stepper read) and download the latest for + * an inline preview. Two-sided proof types (Aadhaar, Passport, Voter ID, Driving Licence) + * store front/back as two separate documents named " — Front"/"— Back" (the same + * convention the create-client stepper writes), so both are matched and previewed here; + * a legacy single upload with no such marker just shows as the one preview. + */ + private loadIdentifierDocPreview(kind: ProofKind, identifierId: string): void { + this.clientsService + .getClientIdentificationDocuments(identifierId) + .pipe( + takeUntil(this.destroy$), + catchError(() => of([])) + ) + .subscribe((docs: any) => { + const list = Array.isArray(docs) ? docs : []; + if (!list.length) return; + this.existing[kind].fileCount = list.length; + + const byNewest = (a: any, b: any) => b.id - a.id; + const label = (d: any) => (d?.name || d?.fileName || '').toString(); + const back = [...list].filter((d: any) => /back/i.test(label(d))).sort(byNewest)[0]; + const front = + [...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] || + [...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0]; + + if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front'); + if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back'); + }); + } + + private downloadIdentifierDocPreview( + identifierId: string, + documentId: string, + kind: ProofKind, + slot: 'front' | 'back' + ): void { + this.clientsService + .downloadClientIdentificationDocument(identifierId, documentId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (blob: any) => { + if (!blob) return; + const url = URL.createObjectURL(blob); + if (slot === 'front') this.existing[kind].previewUrl = url; + else this.existing[kind].previewUrlBack = url; + }, + error: () => undefined + }); } onAccountSelect(accountNo: string): void { @@ -309,33 +402,8 @@ export class CkycTabComponent implements OnInit, OnDestroy { this.selectedAccountType = this.selectedAccount?.accountType || ''; } - onFileSelect(event: any, documentType: string): void { - const file = event.target?.files?.[0]; - if (!file) return; - - switch (documentType) { - case 'ADDRESS_PROOF': - this.addressProofFile = file; - break; - case 'PHOTO_PROOF': - this.photoProofFile = file; - break; - case 'CLIENT_IMAGE': - this.clientImageFile = file; - break; - case 'CLIENT_SIGNATURE': - this.clientSignatureFile = file; - break; - } - this.uploadErrors[documentType] = null; - } - getFile(documentType: string): File | null { switch (documentType) { - case 'ADDRESS_PROOF': - return this.addressProofFile; - case 'PHOTO_PROOF': - return this.photoProofFile; case 'CLIENT_IMAGE': return this.clientImageFile; case 'CLIENT_SIGNATURE': @@ -345,38 +413,278 @@ export class CkycTabComponent implements OnInit, OnDestroy { } } + private static readonly CARD_TITLES: Record = { + ADDRESS_PROOF: 'Address Proof Document', + PHOTO_PROOF: 'Photo Proof Document', + CLIENT_IMAGE: 'Image of Client', + CLIENT_SIGNATURE: 'Signature of Client' + }; + + /** Opens the picker/preview popup for one card and, once the user confirms a file there, feeds + * the picked type/file(s) into the same upload path the card always used. Picking and preview + * now live entirely in the dialog — this screen only stages the result and re-runs upload. */ + openUploadDialog(kind: CkycDocKind): void { + const isProofKind = kind === 'ADDRESS_PROOF' || kind === 'PHOTO_PROOF'; + const action = this.existing[kind].present ? 'Replace' : 'Upload'; + const existingDates = isProofKind + ? this.parseDescriptionDates(this.existing[kind].description) + : { issueDate: null, expiryDate: null }; + + const ref = this.dialog.open(CkycUploadDialogComponent, { + width: '480px', + maxWidth: '95vw', + data: { + title: `${action} ${CkycTabComponent.CARD_TITLES[kind]}`, + isProofKind, + accept: isProofKind ? '.png,.jpeg,.jpg,.pdf' : '.png,.jpeg,.jpg', + proofOptions: + kind === 'PHOTO_PROOF' + ? this.idProofOptions + : kind === 'ADDRESS_PROOF' + ? this.addressProofOptions + : undefined, + initialProofType: isProofKind ? this.selectedProofType[kind as ProofKind] : undefined, + initialDocumentKey: isProofKind ? this.existing[kind].documentKey || '' : undefined, + initialIssueDate: existingDates.issueDate, + initialExpiryDate: existingDates.expiryDate + } + }); + + ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => { + if (!result) return; + if (isProofKind) { + const proofKind = kind as ProofKind; + this.selectedProofType[proofKind] = result.proofType || null; + this.docFiles[proofKind] = { front: result.front, back: result.back || null }; + this.documentDetails[proofKind] = { + documentKey: result.documentKey || '', + issueDate: result.issueDate || null, + expiryDate: result.expiryDate || null + }; + } else if (kind === 'CLIENT_IMAGE') { + this.clientImageFile = result.front; + } else { + this.clientSignatureFile = result.front; + } + this.uploadDocument(kind); + }); + } + + /** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and + * client-india-address-step, for address proof) writes into the identifier's description. */ + private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } { + const issueMatch = (description || '').match(/Issue date:\s*([^,|]+)/i); + const expiryMatch = (description || '').match(/Expiry date:\s*([^,|]+)/i); + return { + issueDate: issueMatch ? this.dates.parseDate(issueMatch[1].trim()) : null, + expiryDate: expiryMatch ? this.dates.parseDate(expiryMatch[1].trim()) : null + }; + } + + private buildDescription(issueDate: string | null, expiryDate: string | null): string { + const parts: string[] = []; + if (issueDate) parts.push(`Issue date: ${issueDate}`); + if (expiryDate) parts.push(`Expiry date: ${expiryDate}`); + return parts.join(', '); + } + + /** Issue/expiry date for display on the card, formatted dd-MM-yyyy — parsed from the same + * description text the upload dialog writes. */ + cardDate(kind: ProofKind, which: 'issue' | 'expiry'): string { + const parsed = this.parseDescriptionDates(this.existing[kind].description); + const value = which === 'issue' ? parsed.issueDate : parsed.expiryDate; + return value ? this.dates.formatDate(value, 'dd-MM-yyyy') : ''; + } + + /** + * Routes each card to whichever real endpoint that artefact is actually stored through — there + * is no single "/clients/{id}/ckyc/documents" resource on the backend, so posting every card to + * one made-up URL 404'd for all four. A photo goes to the image endpoint, a signature to the + * client's own documents, and an ID/address proof to the m_client_identifier it already belongs + * to (the same one the Identities tab and create-client stepper post to) — never a fresh, + * type-less upload, since which identifier a proof belongs to is exactly what makes it that + * proof rather than some other file on the client's record. + */ uploadDocument(documentType: string): void { + if (documentType === 'PHOTO_PROOF' || documentType === 'ADDRESS_PROOF') { + this.uploadProofDocument(documentType); + return; + } + const file = this.getFile(documentType); if (!file) return; this.uploading[documentType] = true; this.uploadErrors[documentType] = null; + const key = documentType as keyof typeof this.existing; - const formData = new FormData(); - formData.append('file', file); - formData.append('documentType', documentType); + let request$: Observable; + switch (documentType) { + case 'CLIENT_IMAGE': + request$ = this.clientsService.uploadClientProfileImage(this.clientId, file); + break; + case 'CLIENT_SIGNATURE': + request$ = this.clientsService.uploadClientSignatureImage(this.clientId, file); + break; + default: + return; + } - this.clientsService - .uploadCkycDocument(this.clientId, formData) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: any) => { - this.uploading[documentType] = false; - this.uploadResponses[documentType] = response; - this.alertService.alert({ - type: 'success', - message: response?.message || 'Document uploaded successfully.' - }); - }, - error: (err: any) => { - this.uploading[documentType] = false; - this.uploadErrors[documentType] = - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - err?.message || - 'Failed to upload document.'; + request$.pipe(takeUntil(this.destroy$)).subscribe({ + next: () => { + this.uploading[documentType] = false; + this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' }); + this.refreshPreview(documentType); + }, + error: (err: any) => { + this.uploading[documentType] = false; + this.uploadErrors[documentType] = this.extractErrorMessage(err); + } + }); + } + + /** + * Proof cards work like the create-client KYC step: pick a document type first (its `sides` + * decides whether one image or a front+back pair is required), then upload. Reuses the same + * identifier the Identities tab manages — updates its documentTypeId if the picked type + * changed, or creates a fresh identifier when none exists yet, so this card never depends on + * a separate trip to the Identities tab. + */ + private uploadProofDocument(kind: ProofKind): void { + const proof = this.selectedProofType[kind]; + if (!proof) { + this.uploadErrors[kind] = 'Select a document type first.'; + return; + } + const files = this.docFiles[kind]; + if (!files.front || (proof.sides === 2 && !files.back)) { + this.uploadErrors[kind] = + proof.sides === 2 + ? 'Both front and back images are required for this document type.' + : 'Select a file to upload.'; + return; + } + + this.uploading[kind] = true; + this.uploadErrors[kind] = null; + + const existingId = this.existing[kind].documentId; + const details = this.documentDetails[kind]; + const documentKey = details.documentKey || `${proof.name} - ${this.clientId}`; + const description = this.buildDescription(details.issueDate, details.expiryDate); + + const identifier$: Observable = !existingId + ? this.clientsService.addClientIdentifier(this.clientId, { + documentTypeId: proof.id, + status: 'ACTIVE', + documentKey, + description: description || 'KYC document' + }) + : this.clientsService.updateClientIdentifier(this.clientId, existingId, { + documentTypeId: proof.id, + status: 'ACTIVE', + documentKey, + description + }); + + identifier$.pipe(takeUntil(this.destroy$)).subscribe({ + next: (res: any) => { + const identifierId = existingId || res?.resourceId; + const uploads: Observable[] = []; + + const front = new FormData(); + front.append('name', proof.sides === 2 ? `${proof.name} — Front` : proof.name); + front.append('file', files.front!); + uploads.push(this.clientsService.uploadClientIdentifierDocument(identifierId, front)); + + if (proof.sides === 2 && files.back) { + const back = new FormData(); + back.append('name', `${proof.name} — Back`); + back.append('file', files.back); + uploads.push(this.clientsService.uploadClientIdentifierDocument(identifierId, back)); } - }); + + forkJoin(uploads) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.uploading[kind] = false; + this.existing[kind].present = true; + this.existing[kind].documentId = identifierId; + this.existing[kind].documentTypeId = String(proof.id); + this.existing[kind].documentKey = details.documentKey || ''; + this.existing[kind].description = description; + this.existing[kind].name = proof.name; + this.existing[kind].source = this.existing[kind].source || 'Identities section'; + this.docFiles[kind] = { front: null, back: null }; + this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' }); + this.refreshPreview(kind); + }, + error: (err: any) => { + this.uploading[kind] = false; + this.uploadErrors[kind] = this.extractErrorMessage(err); + } + }); + }, + error: (err: any) => { + this.uploading[kind] = false; + this.uploadErrors[kind] = this.extractErrorMessage(err); + } + }); + } + + private extractErrorMessage(err: any): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'Failed to upload document.' + ); + } + + /** Re-fetches whichever artefact was just replaced, so its preview reflects the new file + * immediately instead of the one this screen loaded on entry. */ + private refreshPreview(documentType: string): void { + const staleKey = documentType as keyof typeof this.existing; + if (this.existing[staleKey].previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(this.existing[staleKey].previewUrl!); + } + if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) { + URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!); + } + switch (documentType) { + case 'CLIENT_IMAGE': + this.clientsService + .getClientProfileImage(this.clientId) + .pipe(takeUntil(this.destroy$)) + .subscribe((photo: any) => { + if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo; + }); + break; + case 'CLIENT_SIGNATURE': + this.clientsService + .getClientDocuments(this.clientId) + .pipe(takeUntil(this.destroy$)) + .subscribe((documents: any) => { + const doc = (documents as any[]) + ?.filter((d: any) => d?.name === 'clientSignature') + ?.sort((a: any, b: any) => b.id - a.id)?.[0]; + if (!doc) return; + this.existing.CLIENT_SIGNATURE.documentId = doc.id; + this.clientsService + .getClientSignatureImage(this.clientId, doc.id) + .pipe(takeUntil(this.destroy$)) + .subscribe((blob: any) => { + if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); + }); + }); + break; + case 'PHOTO_PROOF': + case 'ADDRESS_PROOF': + this.loadIdentifierDocPreview(documentType, this.existing[documentType].documentId!); + break; + } } ngOnDestroy(): void { @@ -385,6 +693,7 @@ export class CkycTabComponent implements OnInit, OnDestroy { // Release object URLs Object.values(this.existing).forEach((e) => { if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl); + if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack); }); } } diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.html b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.html new file mode 100644 index 000000000..cae9e00ff --- /dev/null +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.html @@ -0,0 +1,167 @@ + + +
+
+ +
+
+

{{ data.title }}

+

Fill in the document details, then attach the scan.

+
+ +
+ + + @if (data.isProofKind) { +
+ + + + @for (p of data.proofOptions; track p.id) { + {{ p.name }} + } + + +

Upload requirements will update automatically based on the type you pick.

+
+ + @if (selectedProofType) { + @if (selectedProofType.info) { +
+ + {{ selectedProofType.info }} +
+ } + +
+ + + + +
+ + + +
Scan
+ +
+
+ {{ sides === 2 ? 'Front side' : 'Document' }} + +
+ + @if (sides === 2) { +
+ Back side + +
+ } +
+ } + } @else { +
+
+ Document + +
+
+ } +
+ + + + + diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.scss new file mode 100644 index 000000000..a6833e451 --- /dev/null +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.scss @@ -0,0 +1,270 @@ +/** + * 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 { + display: block; + max-width: 100%; +} + +/* ===================== Header ===================== */ +.cud-header { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 20px 24px 16px; + border-bottom: 1px solid #eef1f4; + + &__badge { + flex-shrink: 0; + width: 36px; + height: 36px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + color: #fff; + background: linear-gradient(135deg, #1a73e8, #4285f4); + box-shadow: 0 3px 10px rgb(26 115 232 / 28%); + } + + &__text { + flex: 1; + min-width: 0; + } +} + +.cud-title { + margin: 0 !important; + padding: 0; + font-size: 16px; + font-weight: 700; + color: #1a1d21; + line-height: 1.3; +} + +.cud-subtitle { + margin: 2px 0 0; + font-size: 12px; + color: #6b7280; +} + +.cud-close { + flex-shrink: 0; + width: 32px; + height: 32px; + margin: -4px -4px 0 0; + color: #9aa0a6; + + &:hover { + color: #3c4043; + } +} + +/* ===================== Body / sections ===================== */ +.cud-body.mat-mdc-dialog-content { + padding: 18px 24px !important; + max-height: 70vh; +} + +.cud-section { + margin-bottom: 14px; + + &--dates { + display: flex; + gap: 14px; + + .cud-date-field { + flex: 1; + min-width: 0; + } + } + + &__label { + display: flex; + align-items: center; + gap: 7px; + font-size: 11px; + font-weight: 700; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 6px; + + fa-icon { + color: #1a73e8; + font-size: 10.5px; + } + } +} + +.cud-field { + width: 100%; +} + +.cud-hint { + font-size: 12px; + color: #6b7280; + margin: 6px 0 0; + line-height: 1.4; +} + +.cud-banner { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 10px 14px; + margin-bottom: 14px; + background: #f0fbf6; + border: 1px solid #cfecdd; + border-radius: 10px; + font-size: 12.5px; + color: #085041; + line-height: 1.45; + + fa-icon { + margin-top: 2px; + color: #1d9e75; + flex-shrink: 0; + } +} + +.cud-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0 12px; + font-size: 10.5px; + font-weight: 700; + color: #9aa0a6; + text-transform: uppercase; + letter-spacing: 0.08em; + + &::before, + &::after { + content: ''; + flex: 1; + height: 1px; + background: #eef1f4; + } +} + +/* ===================== Front/back scan grid ===================== */ +.cud-grid { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + + &--two { + grid-template-columns: repeat(2, 1fr); + } +} + +.cud-slot { + display: flex; + flex-direction: column; + gap: 6px; + + &__label { + font-size: 10.5px; + font-weight: 700; + color: #5f6368; + text-transform: uppercase; + letter-spacing: 0.05em; + } +} + +.cud-dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 130px; + padding: 16px; + border: 2px dashed #cbd5e1; + border-radius: 12px; + cursor: pointer; + text-align: center; + background: #fafbfc; + transition: + border-color 0.15s, + background 0.15s, + box-shadow 0.15s; + + &:hover { + border-color: #1a73e8; + background: #f5f9ff; + box-shadow: 0 2px 10px rgb(26 115 232 / 10%); + } + + &--has { + border-style: solid; + border-color: #1d9e75; + background: #f0fbf6; + } + + &__icon { + font-size: 22px; + color: #9aa5b1; + } + + &--has &__icon { + color: #1d9e75; + } + + &__text { + font-size: 12px; + color: #64748b; + margin: 0; + font-weight: 500; + } + + &__name { + font-size: 11.5px; + color: #085041; + font-weight: 600; + margin: 0; + word-break: break-all; + padding: 0 8px; + } + + &__img { + max-width: 100%; + max-height: 150px; + border-radius: 8px; + object-fit: contain; + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); + } +} + +/* ===================== Actions ===================== */ +.cud-actions { + padding: 12px 24px !important; + margin: 0 !important; + border-top: 1px solid #eef1f4; + + fa-icon { + font-size: 12px; + } +} + +@media (width <= 560px) { + :host { + width: 100%; + } + + .cud-section--dates { + flex-direction: column; + gap: 14px; + } + + .cud-grid--two { + grid-template-columns: 1fr; + } +} diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.ts new file mode 100644 index 000000000..3fe969bce --- /dev/null +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-upload-dialog/ckyc-upload-dialog.component.ts @@ -0,0 +1,155 @@ +/** + * 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, OnDestroy, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + MatDialogRef, + MAT_DIALOG_DATA, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { MatIconButton } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { Dates } from 'app/core/utils/dates'; +import { ProofType } from '../../../shared/kyc-proof-types'; + +export interface CkycUploadDialogData { + title: string; + isProofKind: boolean; + accept: string; + proofOptions?: ProofType[]; + initialProofType?: ProofType | null; + initialDocumentKey?: string; + initialIssueDate?: Date | null; + initialExpiryDate?: Date | null; +} + +export interface CkycUploadDialogResult { + proofType?: ProofType | null; + front: File; + back?: File; + /** Document number (Aadhaar/PAN/Passport number, etc.) — same field client creation collects. */ + documentKey?: string; + /** 'yyyy-MM-dd', same as the rest of this app's date fields. */ + issueDate?: string | null; + expiryDate?: string | null; +} + +/** + * A focused popup for replacing/uploading one CKYC artefact at a time — opened from each card on + * the CKYC tab instead of expanding a form inline underneath it. Collects the same details + * client creation does for an identity/address proof (document type, number, issue + expiry + * date) and shows a client-side preview of whatever image is picked before it's ever sent to the + * server; the actual upload still happens back in CkycTabComponent (this dialog only collects + * the type, number, dates and file(s), then hands them back). + */ +@Component({ + selector: 'mifosx-ckyc-upload-dialog', + templateUrl: './ckyc-upload-dialog.component.html', + styleUrls: ['./ckyc-upload-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatIconButton, + FaIconComponent + ] +}) +export class CkycUploadDialogComponent implements OnDestroy { + private dates = inject(Dates); + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + selectedProofType: ProofType | null = this.data.initialProofType || null; + documentKey = this.data.initialDocumentKey || ''; + issueDate: Date | null = this.data.initialIssueDate || null; + expiryDate: Date | null = this.data.initialExpiryDate || null; + + front: File | null = null; + back: File | null = null; + frontPreview: string | null = null; + backPreview: string | null = null; + + get sides(): number { + return this.data.isProofKind ? (this.selectedProofType?.sides ?? 1) : 1; + } + + get canSubmit(): boolean { + if (this.data.isProofKind) { + if (!this.selectedProofType) return false; + if (!this.documentKey.trim()) return false; + } + if (!this.front) return false; + if (this.sides === 2 && !this.back) return false; + return true; + } + + compareProofType = (a: ProofType | null, b: ProofType | null): boolean => (a?.id ?? null) === (b?.id ?? null); + + onProofTypeChange(proof: ProofType | null): void { + this.selectedProofType = proof; + this.clearSlot('front'); + this.clearSlot('back'); + } + + onFilePick(slot: 'front' | 'back', event: any): void { + const file = event?.target?.files?.[0]; + if (event?.target) event.target.value = ''; + if (!file) return; + + this.clearSlot(slot); + const preview = file.type?.startsWith('image/') ? URL.createObjectURL(file) : null; + if (slot === 'front') { + this.front = file; + this.frontPreview = preview; + } else { + this.back = file; + this.backPreview = preview; + } + } + + private clearSlot(slot: 'front' | 'back'): void { + if (slot === 'front') { + if (this.frontPreview) URL.revokeObjectURL(this.frontPreview); + this.front = null; + this.frontPreview = null; + } else { + if (this.backPreview) URL.revokeObjectURL(this.backPreview); + this.back = null; + this.backPreview = null; + } + } + + submit(): void { + if (!this.canSubmit) return; + this.dialogRef.close({ + proofType: this.selectedProofType, + front: this.front!, + back: this.sides === 2 ? this.back! : undefined, + documentKey: this.data.isProofKind ? this.documentKey.trim() : undefined, + issueDate: this.issueDate ? this.dates.getDate(this.issueDate) : null, + expiryDate: this.expiryDate ? this.dates.getDate(this.expiryDate) : null + }); + } + + cancel(): void { + this.dialogRef.close(null); + } + + ngOnDestroy(): void { + if (this.frontPreview) URL.revokeObjectURL(this.frontPreview); + if (this.backPreview) URL.revokeObjectURL(this.backPreview); + } +} diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.html b/src/app/clients/clients-view/general-tab/general-tab.component.html index 2123fb7ae..242346b4e 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.html +++ b/src/app/clients/clients-view/general-tab/general-tab.component.html @@ -435,12 +435,17 @@ @if (!showClosedFixedAccounts) {
- +
@@ -524,12 +529,20 @@ @if (showClosedFixedAccounts) {
-
{{ 'labels.inputs.Account No' | translate }} - + + @if (element.renewalCycleNo) { + Renewal {{ element.renewalCycleNo }} + }
+
diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.scss b/src/app/clients/clients-view/general-tab/general-tab.component.scss index 11848e360..f913a9808 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.scss +++ b/src/app/clients/clients-view/general-tab/general-tab.component.scss @@ -209,6 +209,20 @@ $red: #d93025; gap: 0; } +// Marks an FD row that is a renewal of an earlier term, since the row shows the original FD number +// rather than this term's own account number. +.fd-renewal-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 7px; + border-radius: 10px; + background-color: $gray-200; + color: $gray-700; + font-size: 11px; + font-weight: 500; + white-space: nowrap; +} + // ── Table Wrapper ── .table-wrapper { border-radius: 0 0 8px 8px; diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.ts b/src/app/clients/clients-view/general-tab/general-tab.component.ts index d957b00e2..333c43931 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.ts +++ b/src/app/clients/clients-view/general-tab/general-tab.component.ts @@ -234,7 +234,57 @@ export class GeneralTabComponent implements OnDestroy { } get fixedDepositCount(): number { - return this.savingAccounts.filter((a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit').length; + return this.fixedDepositAccountsForDisplay.filter( + (a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit' + ).length; + } + + /** + * Accounts for the Fixed Deposit sections, with superseded renewal cycles folded away. + * + * An FD renewal closes one account and opens the next term as its own account row, so a renewed FD + * would otherwise appear twice on this page -- the matured term under Closed and the new term under + * Active -- reading as two unrelated deposits. A closed term is therefore dropped whenever a later + * cycle of the same FD (same `fdAccountNumber`) exists, leaving one row per FD that shows its + * current term. A chain that has genuinely ended keeps its final term, so it still lists as closed. + * + * Only FD rows are rewritten; savings, RD, current and share accounts pass through untouched. + */ + get fixedDepositAccountsForDisplay(): any[] { + // Memoised on the source array's identity. This getter is bound straight to two [dataSource] inputs, so + // returning a freshly built array on every change-detection pass would make the table re-bind and + // re-render its rows on every tick -- mouse moves included. + if (this.fdDisplayCacheSource !== this.savingAccounts) { + this.fdDisplayCacheSource = this.savingAccounts; + this.fdDisplayCache = this.foldSupersededFdCycles(this.savingAccounts); + } + return this.fdDisplayCache; + } + + private fdDisplayCacheSource: any[] = null; + private fdDisplayCache: any[] = []; + + private foldSupersededFdCycles(accounts: any[]): any[] { + const latestCycleByFd = new Map(); + for (const account of accounts) { + if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') { + continue; + } + const fdNumber = account.fdAccountNumber ?? account.accountNo; + const cycle = account.renewalCycleNo ?? 0; + if (cycle > (latestCycleByFd.get(fdNumber) ?? -1)) { + latestCycleByFd.set(fdNumber, cycle); + } + } + + return accounts.filter((account: any) => { + if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') { + return true; + } + const fdNumber = account.fdAccountNumber ?? account.accountNo; + const cycle = account.renewalCycleNo ?? 0; + return cycle === latestCycleByFd.get(fdNumber); + }); } get recurringDepositCount(): number { diff --git a/src/app/clients/clients-view/identities-tab/identities-tab.component.ts b/src/app/clients/clients-view/identities-tab/identities-tab.component.ts index fc9d25486..daaafe57c 100644 --- a/src/app/clients/clients-view/identities-tab/identities-tab.component.ts +++ b/src/app/clients/clients-view/identities-tab/identities-tab.component.ts @@ -229,7 +229,7 @@ export class IdentitiesTabComponent implements OnDestroy { } const identifierData = { documentTypeId, - status: 'Active', + status: 'ACTIVE', documentKey: kind === 'id' ? section.documentKey : `${proofName} - ${this.clientId}`, description: kind === 'address' && section.address ? section.address : description }; diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index c91dfc132..44b97a309 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html @@ -82,12 +82,13 @@ {{ kycDocumentAlertTitle }}

{{ kycDocumentAlertMessage }}

+ @@ -280,6 +281,82 @@ + + @if (showAadhaarReVerify && !aadhaarOtpSent) { +
+ + Enter 12-digit Aadhaar + + + + +
+ } + @if (aadhaarOtpSent) { +
+ sms OTP sent + + Enter OTP + + + + + +
+ } + + } @@ -565,73 +646,6 @@ } - - @if (showAadhaarReVerify && !aadhaarOtpSent) { -
- - Enter 12-digit Aadhaar - - - -
- } - @if (aadhaarOtpSent) { -
- sms OTP sent - - Enter OTP - - - - -
- } @if (verificationError.aadhaar) {
error_outline {{ verificationError.aadhaar }} @@ -1128,6 +1142,177 @@ + +
+
+

Risk Profile

+ Used by the periodic risk review +
+ +
+ + Politically Exposed Person + Holds the rating at High; never lowered on ordinary activity + + + Sanctions / watch-list match + UNSC, MHA or internal watch list + + + Adverse media / regulatory action + FIR, court proceedings or published action + +
+ +
+ + Declared annual income (₹) + + As stated by the customer + + + + Declared annual turnover (₹) + + For a business customer + +
+
+ + + @if (kycData?.kycDocumentName || kycDocumentNeedsUpdate) { +
+
+

Officially Valid Document

+ @if (kycDocumentNeedsUpdate) { + Re-collect from the customer + } +
+ +
+ + Document + + Aadhaar Card + Passport + Driving Licence + Voter ID + NREGA Job Card + Letter from NPR + + + + + Document Number + + + + + Issued On + + + + + + + Valid Till + + + + Blank if it does not expire + +
+ + +
+
+ photo_library + Scanned document + @if (docScansLoading) { + + } +
+ + @if (!docScansLoading && !docScans.front && !docScans.back) { +
+ No scan is on file for this document. Upload one below. +
+ } + +
+ @for (slot of docScanSlots; track slot) { +
+
{{ slot === 'front' ? 'Front' : 'Back' }}
+ +
+ @if (docScanOf(slot)?.previewUrl) { + + } @else if (docScanOf(slot)) { + +
+ description + {{ docScanOf(slot)!.fileName }} +
+ } @else { +
Not on file
+ } +
+ + + +
+ } +
+ + @if (docScanError) { +
+ error_outline{{ docScanError }} +
+ } + @if (docScanSuccess) { +
+ check_circle{{ docScanSuccess }} +
+ } +
+
+ } +
{ this.clientId = params['clientId']; + // Scans are matched against the document number on the KYC record, so they are loaded once + // that record has arrived rather than alongside it. this.loadKycData(); this.loadClientDateOfBirth(); }); @@ -171,15 +228,26 @@ export class KycTabComponent implements OnInit, OnDestroy { this.kycForm.patchValue({ kycType: data.kycType || 1, riskCategory: data.riskCategory || 1, + isPep: !!data.isPep, + sanctionsMatch: !!data.sanctionsMatch, + adverseMediaMatch: !!data.adverseMediaMatch, + declaredAnnualIncome: data.declaredAnnualIncome ?? null, + declaredAnnualTurnover: data.declaredAnnualTurnover ?? null, panNumber: data.panNumber || '', // Aadhaar is stored masked on the server; user must re-enter // the full 12-digit number to re-verify, so don't pre-fill. aadhaarNumber: '', gstinNumber: data.gstinNumber || '', + kycDocumentName: data.kycDocumentName || '', + kycDocumentNumber: data.kycDocumentNumber || '', + kycDocIssueDate: this.parseApiDate(data.kycDocIssueDate), + kycDocExpiryDate: this.parseApiDate(data.kycDocExpiryDate), remarks: data.remarks || '', isException: data.isException || false, exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] }); + // Now that the document number is known, the scans filed under it can be found. + this.loadDocumentScans(); } }); } @@ -355,6 +423,63 @@ export class KycTabComponent implements OnInit, OnDestroy { * stored record; either way the customer has cleared that check. Verifying one of two * outstanding items narrows the exception to the remaining one rather than closing it. */ + /** + * The officially valid document, for the update payload. + * + * Only sent when a document is named, so an officer editing the risk category on a client who + * never had an OVD recorded does not silently blank the other three columns. + */ + private buildDocumentFields() { + const name = (this.kycForm.value.kycDocumentName || '').trim(); + if (!name) { + return {}; + } + const dateFormat = 'dd MMMM yyyy'; + return { + kycDocumentName: name, + kycDocumentNumber: (this.kycForm.value.kycDocumentNumber || '').trim() || null, + kycDocIssueDate: this.kycForm.value.kycDocIssueDate + ? this.dateUtils.formatDate(this.kycForm.value.kycDocIssueDate, dateFormat) + : null, + // Blank is meaningful: a passport expires, a PAN card does not. + kycDocExpiryDate: this.kycForm.value.kycDocExpiryDate + ? this.dateUtils.formatDate(this.kycForm.value.kycDocExpiryDate, dateFormat) + : null + }; + } + + /** True while the banner is asking for a document, so the form shows the fields to supply it. */ + get kycDocumentNeedsUpdate(): boolean { + return this.showKycDocumentAlert; + } + + /** Today, as the latest an existing document can have been issued. */ + get maxDate(): Date { + return new Date(); + } + + /** + * The customer's risk profile, as every save has to carry it. + * + *

+ * Sent from every path that writes the KYC record rather than only the main Save, because the form holds one set of + * values: a save triggered from the PAN declaration or the Aadhaar receipt would otherwise write the record without + * these and silently clear a PEP flag the officer had just ticked. + */ + private buildRiskProfileFields() { + const v = this.kycForm.value; + return { + isPep: !!v.isPep, + sanctionsMatch: !!v.sanctionsMatch, + adverseMediaMatch: !!v.adverseMediaMatch, + // Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a + // customer who declared no income at all. + declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome, + declaredAnnualTurnover: + v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover + }; + } + private buildExceptionFields() { const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true; const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true; @@ -392,7 +517,9 @@ export class KycTabComponent implements OnInit, OnDestroy { remarks: this.kycForm.value.remarks || null, // dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes. dateFormat: 'dd MMMM yyyy', - ...this.buildExceptionFields() + ...this.buildDocumentFields(), + ...this.buildRiskProfileFields(), + ...this.buildExceptionFields() }; if (!this.kycId) { @@ -438,6 +565,7 @@ export class KycTabComponent implements OnInit, OnDestroy { remarks: this.kycForm.value.remarks || null, riskCategory: this.kycForm.value.riskCategory || 1, dateFormat: 'dd MMMM yyyy', + ...this.buildRiskProfileFields(), ...this.buildExceptionFields() }; @@ -478,11 +606,12 @@ export class KycTabComponent implements OnInit, OnDestroy { if (response?.changes?.panVerified) { this.verificationStatus.pan = 'VERIFIED'; this.verificationDetails.pan = response.changes; - // A PAN that now verifies makes any earlier declaration moot. + // A PAN that now verifies makes any earlier declaration moot. That write already + // re-derives the exception columns, so it settles the exception too. if (this.panDeclarationTaken) { this.writePanDeclaration(null); } else { - this.loadKycData(); + this.syncExceptionAfterVerify(); } } else { this.verificationStatus.pan = 'FAILED'; @@ -636,7 +765,8 @@ export class KycTabComponent implements OnInit, OnDestroy { dateFormat, riskCategory: this.kycForm.value.riskCategory || 1, ...declarationFields, - ...this.buildExceptionFields() + ...this.buildRiskProfileFields(), + ...this.buildExceptionFields() }; this.clientsService @@ -660,6 +790,60 @@ export class KycTabComponent implements OnInit, OnDestroy { }); } + /** + * Re-derives the exception columns once a verification has actually passed. + * + * `saveKycBeforeVerify()` writes the exception state *before* the vendor is called, when the + * check is by definition still outstanding — so a check that then succeeds leaves the record + * carrying an exception nobody closes. The KYC tab reads `panVerified` and shows "Verified" + * while the exceptions register keeps reading `isException`, which is why a client could sit in + * the Pending tab with every document verified. + * + * Called on each verification success. `buildExceptionFields()` already prefers the in-session + * `verificationStatus` over the stored record, so the freshly passed check counts even though + * `kycData` has not been reloaded yet; `hadException` still reads the stored value, which is + * what tells it to close the entry rather than leave every flag off. + * + * Verifying one of two outstanding items narrows the exception instead of closing it — that + * falls out of the derivation, so this needs no special case for it. + */ + private syncExceptionAfterVerify(): void { + if (!this.kycId) { + this.loadKycData(); + return; + } + + const exceptionFields = this.buildExceptionFields(); + + // Nothing recorded and nothing outstanding — no write would change anything. + if (this.kycData?.isException !== true && !exceptionFields.isException) { + this.loadKycData(); + return; + } + + this.clientsService + .updateClientKyc(this.clientId, this.kycId, { + locale: 'en', + dateFormat: 'dd MMMM yyyy', + riskCategory: this.kycForm.value.riskCategory || 1, + ...exceptionFields + }) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + // The verification itself succeeded either way, so a failure here must not read as a + // failed check — surface it as the save problem it is and still refresh. + next: () => this.loadKycData(), + error: (err: any) => { + this.errorMessage = + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'Verification succeeded but the KYC exception could not be updated.'; + this.loadKycData(); + } + }); + } + /** * Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar. * @@ -742,7 +926,8 @@ export class KycTabComponent implements OnInit, OnDestroy { dateFormat, riskCategory: this.kycForm.value.riskCategory || 1, ...receiptFields, - ...this.buildExceptionFields() + ...this.buildRiskProfileFields(), + ...this.buildExceptionFields() }; this.clientsService @@ -786,6 +971,12 @@ export class KycTabComponent implements OnInit, OnDestroy { this.verificationError.aadhaar = null; } + /** Abandons the OTP exchange, so a mis-click can be backed out of without reloading the tab. */ + cancelAadhaarReVerify(): void { + this.resetOtpState(); + this.verificationError.aadhaar = null; + } + /** Step 1: Send OTP to Aadhaar-linked mobile */ sendAadhaarOtp(): void { const aadhaar = this.isEditMode ? this.kycForm?.get('aadhaarNumber')?.value : this.aadhaarFullNumber; @@ -855,7 +1046,7 @@ export class KycTabComponent implements OnInit, OnDestroy { if (this.aadhaarReceiptTaken) { this.writeAadhaarReceipt(null); } else { - this.loadKycData(); + this.syncExceptionAfterVerify(); } } else { this.verificationStatus.aadhaar = 'FAILED'; @@ -1200,6 +1391,297 @@ I further confirm that: doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`); } + // ── Document scans ───────────────────────────────────────────────────────── + + /** + * Finds the identifier the KYC document was filed under and loads its scans. + * + * The KYC record keeps the document's name and number but not the identifier id, so the two are + * matched on the number — that is what makes them the same document, and it survives the client + * holding several identifiers of the same type. + */ + private loadDocumentScans(): void { + if (!this.clientId) { + return; + } + this.docScansLoading = true; + this.clientsService + .getClientIdentifiers(this.clientId) + .pipe(catchError(() => of([] as any[]))) + .subscribe((identifiers: any[]) => { + const wantedKey = (this.kycData?.kycDocumentNumber || '').toString().trim(); + const wantedName = (this.kycData?.kycDocumentName || '').toString().trim().toLowerCase(); + const norm = (s: any) => (s || '').toString().trim().toLowerCase(); + + // Every identifier carrying this document number, not just the first. The onboarding step + // files each side against its own identifier row, so this client's Aadhaar front sits on + // one row and the back on another under the same number - taking only the first match + // showed the front and reported the back as missing. + let matches = (identifiers || []).filter((i) => wantedKey && norm(i.documentKey) === norm(wantedKey)); + if (!matches.length) { + matches = (identifiers || []).filter((i) => wantedName && norm(i.documentType?.name) === wantedName); + } + + if (!matches.length) { + this.docScansLoading = false; + this.docIdentifierId = null; + this.docIdentifierIds = []; + this.docScans = { front: null, back: null }; + return; + } + // Replacements are filed against the first row, so the sides stay together from here on. + this.docIdentifierId = String(matches[0].id); + this.docIdentifierIds = matches.map((m: any) => String(m.id)); + this.fetchScansFor(this.docIdentifierIds); + }); + } + + /** + * Sides are read off the document name, which is how they were written at upload + * (" — Front" / "— Back"). Where the name says nothing, upload order stands in, front + * first, rather than leaving a scan the officer cannot see. + */ + private fetchScansFor(identifierIds: string[]): void { + forkJoin( + identifierIds.map((id) => + this.clientsService.getClientIdentificationDocuments(id).pipe( + // Tagged with the row it came from, because the download call needs the parent id and + // the sides can be spread across more than one. + map((docs: any) => ((docs as any[]) || []).map((d) => ({ ...d, identifierId: id }))), + catchError(() => of([] as any[])) + ) + ) + ).subscribe((perIdentifier: any[][]) => { + this.docScansLoading = false; + // This runs again after every replace, so the previews being thrown away are revoked + // rather than left holding their blobs. + for (const old of [this.docScans.front, this.docScans.back]) { + if (old?.previewUrl) { + URL.revokeObjectURL(old.previewUrl); + } + } + + // Oldest first by document id, so the newest upload for a side is the last one written and + // therefore the one that stands. Identifier order cannot do this job: a replacement is filed + // against the first row while the original sits on the second, so reading the rows in order + // let a document uploaded months ago overwrite the scan collected a moment ago - the officer + // pressed Replace, saw "replaced", and the old image came straight back. + const docs = perIdentifier.flat().sort((a, b) => Number(a.id) - Number(b.id)); + const sides: typeof this.docScans = { front: null, back: null }; + const unlabelled: any[] = []; + + const entryOf = (d: any): DocScanSide => ({ + documentId: String(d.id), + identifierId: String(d.identifierId), + fileName: d.fileName || d.name, + previewUrl: null + }); + + for (const d of docs) { + const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase(); + // Later uploads win, so a replaced side shows the new scan rather than the original. + if (text.includes('back')) { + sides.back = entryOf(d); + } else if (text.includes('front')) { + sides.front = entryOf(d); + } else { + unlabelled.push(d); + } + } + for (const d of unlabelled) { + if (!sides.front) { + sides.front = entryOf(d); + } else if (!sides.back) { + sides.back = entryOf(d); + } + } + + this.docScans = sides; + for (const key of ['front', 'back'] as const) { + if (sides[key]) { + this.loadScanPreview(sides[key]!); + } + } + }); + } + + private loadScanPreview(side: DocScanSide): void { + this.clientsService + .downloadClientIdentificationDocument(side.identifierId, side.documentId) + .pipe(catchError(() => of(null))) + .subscribe((blob: Blob | null) => { + if (!blob || !blob.type?.startsWith('image/')) { + return; + } + // Revoked in ngOnDestroy; a preview left behind holds the blob in memory for the life of + // the tab. + side.previewUrl = URL.createObjectURL(blob); + }); + } + + /** + * Replaces one side with a freshly collected scan. + * + * The new file is uploaded under the same side name, which is what the readers here and on the + * client screen key on, so the latest upload becomes the scan on show. The old file is left in + * place deliberately: a KYC document that has been re-collected is a record of what was held + * before and when it changed, and destroying it would leave the audit trail with a gap. + */ + onDocScanSelected(event: Event, slot: 'front' | 'back'): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + // Cleared so choosing the same filename twice still fires a change event. + input.value = ''; + if (!file || !this.docIdentifierId) { + return; + } + + this.docScanError = null; + this.docScanSuccess = null; + this.docScanUploading = slot; + + const label = slot === 'front' ? 'Front' : 'Back'; + const docName = this.kycForm.get('kycDocumentName')?.value || this.kycData?.kycDocumentName || 'KYC document'; + + const formData = new FormData(); + formData.append('file', file); + formData.append('name', `${docName} — ${label}`); + formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`); + + this.clientsService.uploadClientIdentifierDocument(this.docIdentifierId, formData).subscribe({ + next: () => { + this.docScanUploading = null; + this.docScanSuccess = `${label} side replaced.`; + // Re-read every row, not just the one written to: the other side still lives on its own. + this.fetchScansFor(this.docIdentifierIds); + }, + error: (err: any) => { + this.docScanUploading = null; + this.docScanError = + err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`; + } + }); + } + + /** + * A verified record only accepts an amendment with a reason on it, so the dialog has to ask for + * one. 300 is VERIFIED; the code form is checked too, since either can arrive on the payload. + */ + private get isKycVerified(): boolean { + return this.kycData?.kycStatus === 300 || this.kycData?.kycStatusCode === 'clientKycStatus.verified'; + } + + /** + * Re-collects the officially valid document from its own row. + * + * PAN and Aadhaar are each handled on their row; this one sent the officer into edit mode on the + * whole KYC form to do the same job. The dialog asks for the number, the dates and each side of + * the scan together, which is what re-collecting a document actually is. + */ + updateKycDocument(): void { + if (!this.kycId) { + this.docScanError = 'KYC record not found. Save KYC details first.'; + return; + } + + this.dialog + .open(KycDocumentUpdateDialogComponent, { + width: '640px', + maxWidth: '95vw', + disableClose: true, + data: { + documentName: this.kycData?.kycDocumentName || 'KYC document', + documentNumber: this.kycData?.kycDocumentNumber || '', + issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate), + expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate), + reason: this.showKycDocumentAlert ? this.kycDocumentAlertMessage : '', + currentFront: this.docScans.front?.previewUrl || null, + currentBack: this.docScans.back?.previewUrl || null, + requiresRemarks: this.isKycVerified + } as KycDocumentUpdateData + }) + .afterClosed() + .subscribe((result: KycDocumentUpdateResult | undefined) => { + if (result) { + this.persistKycDocument(result); + } + }); + } + + /** + * Saves the corrected details, then uploads whichever sides were re-scanned. + * + * The details go first: they are the record, and a scan attached to a document whose number was + * never corrected would file the new image under the old identity. + */ + private persistKycDocument(result: KycDocumentUpdateResult): void { + this.saving = true; + this.docScanError = null; + this.docScanSuccess = null; + + const dateFormat = 'dd MMMM yyyy'; + const payload: any = { + locale: 'en', + dateFormat, + kycDocumentName: this.kycData?.kycDocumentName || null, + kycDocumentNumber: result.documentNumber || null, + kycDocIssueDate: result.issueDate ? this.dateUtils.formatDate(result.issueDate, dateFormat) : null, + kycDocExpiryDate: result.expiryDate ? this.dateUtils.formatDate(result.expiryDate, dateFormat) : null + }; + // The server refuses an amendment to a verified record without one, and records it as the + // reason the document changed. + if (result.remarks) { + payload.remarks = result.remarks; + } + + this.clientsService.updateClientKyc(this.clientId, this.kycId!, payload).subscribe({ + next: () => { + this.saving = false; + this.docScanSuccess = 'Document updated.'; + this.loadKycData(); + this.uploadReplacedSides(result); + }, + error: (err: any) => { + this.saving = false; + this.docScanError = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not update the document.'; + } + }); + } + + /** Sends each re-scanned side through the same upload path the panel below uses. */ + private uploadReplacedSides(result: KycDocumentUpdateResult): void { + if (!this.docIdentifierId) { + return; + } + const sides: { slot: 'front' | 'back'; file: File }[] = []; + if (result.front) { + sides.push({ slot: 'front', file: result.front }); + } + if (result.back) { + sides.push({ slot: 'back', file: result.back }); + } + if (!sides.length) { + return; + } + + const docName = this.kycData?.kycDocumentName || 'KYC document'; + forkJoin( + sides.map(({ slot, file }) => { + const formData = new FormData(); + formData.append('file', file); + formData.append('name', `${docName} — ${slot === 'front' ? 'Front' : 'Back'}`); + formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`); + return this.clientsService + .uploadClientIdentifierDocument(this.docIdentifierId!, formData) + .pipe(catchError(() => of(null))); + }) + ).subscribe(() => { + this.docScanSuccess = 'Document and scan updated.'; + this.fetchScansFor(this.docIdentifierIds); + }); + } + /** Handle file selection for signed declaration upload */ onDeclarationFileSelected(event: Event): void { const input = event.target as HTMLInputElement; @@ -1258,5 +1740,11 @@ I further confirm that: ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); + // Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab. + for (const side of [this.docScans.front, this.docScans.back]) { + if (side?.previewUrl) { + URL.revokeObjectURL(side.previewUrl); + } + } } } diff --git a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html index 287371407..02ce124f1 100644 --- a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html +++ b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html @@ -105,7 +105,7 @@ [class.pd-verify-badge--verified]="!clientViewData.isMinor" [class.pd-verify-badge--pending]="clientViewData.isMinor" > - {{ clientViewData.isMinor ? 'Yes (Age < 18)' : 'No' }} + {{ clientViewData.isMinor ? 'Yes' : 'No' }}

diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 1ce943e3f..7220fbe47 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -16,7 +16,7 @@ import { Observable, of, throwError } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { map, catchError } from 'rxjs/operators'; -import { environment } from 'environments/environment'; +import { environment } from '../../environments/environment'; import { dataUrlToFile } from 'app/shared/utils/data-url.util'; /** @@ -536,6 +536,17 @@ export class ClientsService { return this.http.post(`/clients/${clientId}/identifiers`, identifierData); } + /** + * Updates an identifier in place — the document number, its type, or the description. + * + * Without this the only way to record a re-collected document was to post a new identifier, so + * a customer whose Aadhaar was re-verified ended up with two rows carrying the same number and + * the same scans, and nothing saying which was current. + */ + updateClientIdentifier(clientId: string, identifierId: string, identifierData: any) { + return this.http.put(`/clients/${clientId}/identifiers/${identifierId}`, identifierData); + } + deleteClientIdentifier(clientId: string, identifierId: string) { return this.http.delete(`/clients/${clientId}/identifiers/${identifierId}`); } @@ -1043,4 +1054,56 @@ export class ClientsService { getKycContactLog(clientId: string | number): Observable { return this.http.get(`/kyc/exceptions/${clientId}/contact-log`); } + + /** + * Clients whose periodic re-KYC due date has passed, oldest first. + * + * Drives the expiry worklist. A client drops out of this list the moment their re-KYC is + * completed, because renewing pushes the expiry date into the future. + */ + getLapsedKyc(): Observable { + return this.http.get('/kyc/exceptions/lapsed'); + } + + /** + * Savings accounts the dormancy job has marked. + * + * @param subStatus 100 inactive, 200 dormant, 300 escheat; omit for all three + */ + getDormantAccounts(subStatus?: number): Observable { + const params = subStatus ? new HttpParams().set('subStatus', String(subStatus)) : undefined; + return this.http.get('/savingsaccounts/dormant', { params }); + } + + /** + * Records that the escheated balances of the given accounts were remitted to RBI's DEAF. + * + * The transfer itself happens outside this system; this only writes down that it was made, which + * is what lets the escheat report tell remitted money from money still sitting with the bank. + * Accounts that are not escheated, or already recorded, are skipped server-side — the result + * carries `recorded` and `skipped` counts. + */ + recordDeafRemittance(data: { + savingsAccountIds: number[]; + remittedOn: string; + deafReference?: string; + remarks?: string; + locale?: string; + dateFormat?: string; + }): Observable { + return this.http.post('/savingsaccounts/deaf-remittance', { + locale: 'en', + dateFormat: 'yyyy-MM-dd', + ...data + }); + } + + /** Records that a customer claimed a remitted balance back and the bank recovered it from DEAF. */ + refundDeafRemittance(savingsAccountId: number, refundedOn: string): Observable { + return this.http.post(`/savingsaccounts/deaf-remittance/${savingsAccountId}/refund`, { + locale: 'en', + dateFormat: 'yyyy-MM-dd', + refundedOn + }); + } } diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index 6218ea5ac..ccd05c057 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -840,6 +840,15 @@ export class CreateClientComponent { this.saveAddressProof(clientId); } + /** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention the CKYC tab and + * client-india-address-step's own "load existing" parsing both read back. */ + private buildProofDescription(issueDate: string | null | undefined, expiryDate: string | null | undefined): string { + const parts: string[] = []; + if (issueDate) parts.push(`Issue date: ${issueDate}`); + if (expiryDate) parts.push(`Expiry date: ${expiryDate}`); + return parts.join(', '); + } + /** * Save the address-proof picked on the permanent tab as a client identifier * + upload each selected file as a document attached to that identifier. @@ -855,9 +864,9 @@ export class CreateClientComponent { const proofName = payload.proof?.name || 'Address Proof'; const identifierData: any = { documentTypeId: payload.proofId, - status: 'Active', + status: 'ACTIVE', documentKey: payload.documentKey || `${proofName} - ${clientId}`, - description: payload.issueDate ? `Issue date: ${payload.issueDate}` : '' + description: this.buildProofDescription(payload.issueDate, payload.expiryDate) }; this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({ @@ -904,7 +913,7 @@ export class CreateClientComponent { } const identifierData: any = { documentTypeId, - status: 'Active', + status: 'ACTIVE', documentKey: payload.documentKey || `${proofName} - ${clientId}`, description: 'KYC document' }; diff --git a/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts index 7d01f68d9..99cbd5959 100644 --- a/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts +++ b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts @@ -164,6 +164,13 @@ export class ContactOtpBlockComponent implements OnChanges, OnDestroy { request$.subscribe({ next: (res: any) => { this.sending = false; + // The backend reports failure to actually dispatch the OTP (gateway error, flagged + // number, SMTP failure...) as a 200 response with success:false and a real reason in + // `message` — never a thrown error. Surface that reason instead of treating it as sent. + if (res?.success === false) { + this.errorMessage = res?.message || 'Could not send OTP.'; + return; + } this.otpId = res?.otpId ?? res?.resourceId ?? null; this.otpSent = true; this.clearAllDigits(); diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts index c55beef28..b2a07e089 100644 --- a/src/app/clients/edit-client/edit-client.component.ts +++ b/src/app/clients/edit-client/edit-client.component.ts @@ -1035,6 +1035,15 @@ export class EditClientComponent { this.saveAddressProof(clientId); } + /** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention the CKYC tab and + * client-india-address-step's own "load existing" parsing both read back. */ + private buildProofDescription(issueDate: string | null | undefined, expiryDate: string | null | undefined): string { + const parts: string[] = []; + if (issueDate) parts.push(`Issue date: ${issueDate}`); + if (expiryDate) parts.push(`Expiry date: ${expiryDate}`); + return parts.join(', '); + } + /** * Save the address-proof picked on the permanent tab as a client identifier * + upload each selected file as a document attached to that identifier. @@ -1050,9 +1059,9 @@ export class EditClientComponent { const proofName = payload.proof?.name || 'Address Proof'; const identifierData: any = { documentTypeId: payload.proofId, - status: 'Active', + status: 'ACTIVE', documentKey: payload.documentKey || `${proofName} - ${clientId}`, - description: payload.issueDate ? `Issue date: ${payload.issueDate}` : '' + description: this.buildProofDescription(payload.issueDate, payload.expiryDate) }; this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({ @@ -1079,12 +1088,15 @@ export class EditClientComponent { */ private saveKycDocProof(clientId: string) { const payload = this.clientKycStep?.kycDocPayload; - if (!payload?.proofId || !payload?.files?.length) return; + // A document number corrected without re-scanning the document is still a change worth + // saving, so this no longer requires files — it used to return here and the new number was + // silently dropped. + if (!payload?.proofId) return; const proofName = payload.proof?.name || 'KYC Document'; const uploadFiles = (identifierId: string) => { - for (const entry of payload.files) { + for (const entry of payload.files || []) { const formData = new FormData(); formData.append('name', `${proofName} — ${entry.slot}`); formData.append('file', entry.file); @@ -1092,8 +1104,26 @@ export class EditClientComponent { } }; - if (this.clientKycStep?.existingKycIdentifierId) { - uploadFiles(this.clientKycStep.existingKycIdentifierId); + // A document already on file is updated in place, not posted again. + // + // Re-collecting a document used to create a second identifier carrying the same number and + // the same scans, with nothing to say which of the two was current — the client in front of + // this was left with two identical Aadhaar rows. The document the customer renewed is the + // one already recorded, so its number and type are written back to that row and any new + // scans are attached to it. + const existingId = this.clientKycStep?.existingKycIdentifierId; + if (existingId) { + this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => { + const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'ACTIVE' }; + if (documentTypeId != null) { + update.documentTypeId = documentTypeId; + } + this.clientsService.updateClientIdentifier(clientId, existingId, update).subscribe({ + // The scans go up either way: failing to rewrite the number must not cost the images. + next: () => uploadFiles(existingId), + error: () => uploadFiles(existingId) + }); + }); return; } @@ -1107,7 +1137,7 @@ export class EditClientComponent { } const identifierData: any = { documentTypeId, - status: 'Active', + status: 'ACTIVE', documentKey: payload.documentKey || `${proofName} - ${clientId}`, description: 'KYC document' }; diff --git a/src/app/clients/kyc-exception.util.spec.ts b/src/app/clients/kyc-exception.util.spec.ts index 3f77c8e59..c65736fe7 100644 --- a/src/app/clients/kyc-exception.util.spec.ts +++ b/src/app/clients/kyc-exception.util.spec.ts @@ -230,6 +230,23 @@ describe('deriveKycExceptionFields', () => { const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`; expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN); }); + + it('closes a record left open by a verification that passed after the exception was written', () => { + // A verification writes the exception before calling the vendor, so a check that then passes + // leaves `isException` set with the stale reason still loaded into the form's reason list. + // Saving such a record has to close it, not re-raise it off its own stale reason. + expect( + deriveKycExceptionFields({ + ...existingException(true, true, KYC_EXCEPTION_PAN), + manualReasons: [KYC_EXCEPTION_PAN] + }) + ).toEqual({ + isException: false, + exceptionReason: KYC_EXCEPTION_PAN, + exceptionResolved: true, + exceptionResolvedOn: TODAY + }); + }); }); describe('re-opening', () => { diff --git a/src/app/clients/services/external-national-id.service.ts b/src/app/clients/services/external-national-id.service.ts index f205d471c..1580e6d4c 100644 --- a/src/app/clients/services/external-national-id.service.ts +++ b/src/app/clients/services/external-national-id.service.ts @@ -11,7 +11,7 @@ import { UntypedFormGroup } from '@angular/forms'; import { Subject } from 'rxjs'; import { debounceTime, distinctUntilChanged, filter, switchMap, takeUntil, timeout, catchError } from 'rxjs/operators'; import { of } from 'rxjs'; -import { environment } from 'environments/environment'; +import { environment } from '../../../environments/environment'; import { ClientsService } from '../clients.service'; import { EXTERNAL_ID_STATUS_KEYS, diff --git a/src/app/clients/shared/client-age.util.ts b/src/app/clients/shared/client-age.util.ts new file mode 100644 index 000000000..9f2931f06 --- /dev/null +++ b/src/app/clients/shared/client-age.util.ts @@ -0,0 +1,97 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Single source of truth for every age-derived client classification. + * + * These rules were previously re-implemented as bare literals in ten separate components (the client + * stepper plus each product's account-details step), which is how a client could end up classified one + * way on the client screen and another way on the account screen -- and, in the FD case, how a + * 24-year-old ended up with a senior-citizen interest rate. Anything that needs to know whether a + * client is a minor or a senior citizen must derive it from here rather than comparing ages inline. + */ + +/** Below this age a client is a minor: guardian mapping required, restricted KYC/OVD document set. */ +export const MINOR_AGE_THRESHOLD = 10; + +/** At or above this age a client qualifies for senior-citizen benefits (higher deposit rates). */ +export const SENIOR_CITIZEN_AGE_THRESHOLD = 60; + +/** + * Age at which a minor account must be converted to a regular one. Deliberately independent of + * {@link MINOR_AGE_THRESHOLD}: legal majority in India is 18 regardless of the age at which this CBS + * chooses to stop applying minor-specific onboarding rules. + */ +export const AGE_OF_MAJORITY = 18; + +/** Parses the backend's date-of-birth representation -- either a `[yyyy, m, d]` array or a date string. */ +export function parseClientDob(dob: any): Date | null { + if (!dob) return null; + if (Array.isArray(dob) && dob.length >= 3) { + return new Date(dob[0], dob[1] - 1, dob[2]); + } + const parsed = new Date(dob); + return isNaN(parsed.getTime()) ? null : parsed; +} + +/** + * Completed years between `dob` and `asOf`. Pass the tenant's business date as `asOf` wherever it is + * available, so a back-dated or forward-dated business date classifies the client the same way the + * rest of the system does. + */ +export function calculateAge(dob: Date, asOf?: Date | null): number { + const today = asOf ?? new Date(); + let age = today.getFullYear() - dob.getFullYear(); + const monthDiff = today.getMonth() - dob.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) { + age--; + } + return age; +} + +/** True when the age qualifies as a minor. `null` (unknown DOB) is never a minor. */ +export function isMinorAge(age: number | null | undefined): boolean { + return age !== null && age !== undefined && age < MINOR_AGE_THRESHOLD; +} + +/** True when the age qualifies for senior-citizen benefits. `null` (unknown DOB) never qualifies. */ +export function isSeniorCitizenAge(age: number | null | undefined): boolean { + return age !== null && age !== undefined && age >= SENIOR_CITIZEN_AGE_THRESHOLD; +} + +/** + * Whether a client record represents a natural person. Only a natural person can be a minor or a + * senior citizen -- an entity/company has no age, so age-derived benefits must never apply to one + * even if some incorporation date happens to be stored against it. + */ +export function isIndividualClient(client: any): boolean { + const legalForm = client?.legalForm; + if (!legalForm) return true; // absent legal form: treat as individual, matching the backend default + const code = String(legalForm.code ?? '').toLowerCase(); + if (code) return code.includes('individual') && !code.includes('nonindividual'); + const value = String(legalForm.value ?? legalForm.name ?? '').toLowerCase(); + return value === 'individual'; +} + +/** + * The senior-citizen decision for a whole client record: individual, known DOB, and old enough. + * Returns the derived age alongside the verdict so callers can show "why" without recomputing it. + */ +export function deriveSeniorCitizenStatus( + client: any, + asOf?: Date | null +): { isSeniorCitizen: boolean; age: number | null; isIndividual: boolean } { + const isIndividual = isIndividualClient(client); + const dob = parseClientDob(client?.dateOfBirth); + const age = dob ? calculateAge(dob, asOf) : null; + return { + isSeniorCitizen: isIndividual && isSeniorCitizenAge(age), + age, + isIndividual + }; +} diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html new file mode 100644 index 000000000..6e646f72a --- /dev/null +++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html @@ -0,0 +1,137 @@ + + +

+ description + Update {{ data.documentName }} +

+ + + + @if (data.reason) { +
+ schedule + {{ data.reason }} +
+ } + +
+ + Document Number + + @if (form.get('documentNumber')?.hasError('required') && form.get('documentNumber')?.touched) { + Document number is required + } + + + + Issued On + + + + + + + Valid Till + + + + Blank if it does not expire + + + + @if (data.requiresRemarks) { + + Reason for update + + Recorded against the verified KYC record + @if (form.get('remarks')?.hasError('required') && form.get('remarks')?.touched) { + A reason is required to change a verified KYC record + } + + } + + +
+
Scanned copy
+

+ Leave a side untouched to keep the scan already on file. The previous copy is kept either way. +

+ +
+ @for (slot of slots; track slot) { +
+
+ {{ slot === 'front' ? 'Front' : 'Back' }} + @if (isReplaced(slot)) { + New + } +
+ +
+ @if (previewOf(slot)) { + + } @else if (isReplaced(slot)) { + +
+ insert_drive_file + {{ pickedName(slot) }} +
+ } @else { +
Not on file
+ } +
+ +
+ + @if (isReplaced(slot)) { + + } +
+ +
+ } +
+
+
+ + + + + diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.scss b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.scss new file mode 100644 index 000000000..1837b342c --- /dev/null +++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.scss @@ -0,0 +1,172 @@ +/** + * 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/. + */ + +.kdu-title { + display: flex; + align-items: center; + gap: 8px; + + mat-icon { + color: #64748b; + } +} + +.kdu-body { + padding-top: 4px; +} + +.kdu-reason { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 16px; + padding: 10px 12px; + border: 1px solid #fde68a; + border-radius: 6px; + background: #fffbeb; + color: #92400e; + font-size: 13px; + line-height: 1.5; + + mat-icon { + flex: 0 0 auto; + font-size: 18px; + width: 18px; + height: 18px; + color: #d97706; + } +} + +.kdu-form { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.kdu-field { + flex: 1 1 180px; +} + +.kdu-field--wide { + flex: 1 1 100%; +} + +.kdu-scans { + margin-top: 8px; + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} + +.kdu-scans__head { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + color: #475569; +} + +.kdu-scans__hint { + margin: 4px 0 12px; + font-size: 12px; + color: #64748b; +} + +.kdu-sides { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.kdu-side { + display: flex; + flex-direction: column; + gap: 6px; + width: 210px; +} + +.kdu-side__label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: #334155; +} + +/* Marks the side the officer has just re-scanned, so it is obvious what will change on save. */ +.kdu-side__new { + padding: 1px 7px; + border-radius: 999px; + background: #dcfce7; + color: #15803d; + font-size: 10.5px; + font-weight: 700; +} + +.kdu-side__frame { + display: flex; + align-items: center; + justify-content: center; + height: 132px; + padding: 6px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #fff; + overflow: hidden; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 3px; + } + + &--new { + border-color: #86efac; + box-shadow: 0 0 0 1px #86efac; + } +} + +.kdu-side__none { + font-size: 12px; + color: #94a3b8; +} + +.kdu-side__file { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 0 6px; + font-size: 11px; + color: #475569; + text-align: center; + word-break: break-word; + + mat-icon { + color: #64748b; + } +} + +.kdu-side__actions { + display: flex; + align-items: center; + gap: 4px; +} + +.kdu-side__btn { + flex: 1; + font-size: 12px; +} + +.kdu-actions { + padding: 8px 24px 20px; + gap: 8px; +} diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.ts b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.ts new file mode 100644 index 000000000..a0add1167 --- /dev/null +++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.ts @@ -0,0 +1,215 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** What the caller hands in: the document as it stands, and the scans already on file. */ +export interface KycDocumentUpdateData { + documentName: string; + documentNumber: string; + issueDate: Date | string | null; + expiryDate: Date | string | null; + /** Why the document is being asked for, shown at the top so the officer sees the reason. */ + reason: string; + /** Previews of what is currently held, so the officer can see what they are replacing. */ + currentFront: string | null; + currentBack: string | null; + /** + * True when the KYC record is already verified. Changing data on one of those needs a reason on + * the record, which the server enforces - so the dialog asks for it rather than letting the + * officer fill the whole form in and be refused on submit. + */ + requiresRemarks: boolean; +} + +/** What the caller gets back: the corrected details, plus whichever sides were re-scanned. */ +export interface KycDocumentUpdateResult { + documentNumber: string; + issueDate: Date | null; + expiryDate: Date | null; + front: File | null; + back: File | null; + /** Why the document was re-collected. Blank unless the record was already verified. */ + remarks: string; +} + +/** + * Re-collects an officially valid document without leaving the KYC record. + * + * PAN and Aadhaar are each dealt with on their own row of the verification table. The OVD was the + * exception: its Update button put the whole KYC form into edit mode, so re-collecting one expiring + * document meant scrolling a form of unrelated fields and finding the same document again. The work + * is the same either way - a new number, new dates, a fresh scan of each side - so it is asked for + * in one place. + */ +@Component({ + selector: 'mifosx-kyc-document-update-dialog', + templateUrl: './kyc-document-update-dialog.component.html', + styleUrls: ['./kyc-document-update-dialog.component.scss'], + standalone: true, + // The shared set carries the date adapter the two datepickers below need; the sibling dialogs + // pull it in the same way. + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatDatepickerModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + MatTooltipModule + ] +}) +export class KycDocumentUpdateDialogComponent implements OnDestroy { + private formBuilder = inject(UntypedFormBuilder); + private dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + form: UntypedFormGroup; + + /** Newly chosen files, with a local preview so the officer sees what they picked. */ + picked: { front: File | null; back: File | null } = { front: null, back: null }; + pickedPreview: { front: string | null; back: string | null } = { front: null, back: null }; + + readonly slots: ('front' | 'back')[] = [ + 'front', + 'back' + ]; + + constructor() { + this.form = this.formBuilder.group({ + documentNumber: [ + this.data.documentNumber || '', + [Validators.required] + ], + issueDate: [this.toDate(this.data.issueDate)], + // Left free rather than required: a passport expires, a PAN card does not. + expiryDate: [this.toDate(this.data.expiryDate)], + // Required only where the server requires it, so completing a record that was never verified + // does not demand a justification for filling in blanks. + remarks: [ + '', + this.data.requiresRemarks ? [Validators.required] : [] + ] + }); + } + + private toDate(value: Date | string | null): Date | null { + if (!value) { + return null; + } + const d = value instanceof Date ? value : new Date(value); + return isNaN(d.getTime()) ? null : d; + } + + previewOf(slot: 'front' | 'back'): string | null { + return this.pickedPreview[slot] || (slot === 'front' ? this.data.currentFront : this.data.currentBack); + } + + /** True once this side has been re-scanned in this dialog, so the label can say so. */ + isReplaced(slot: 'front' | 'back'): boolean { + return !!this.picked[slot]; + } + + pickedName(slot: 'front' | 'back'): string { + return this.picked[slot]?.name || ''; + } + + onFilePicked(event: Event, slot: 'front' | 'back'): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0] || null; + // Cleared so re-picking the same filename still fires a change event. + input.value = ''; + if (!file) { + return; + } + if (this.pickedPreview[slot]) { + URL.revokeObjectURL(this.pickedPreview[slot]!); + } + this.picked[slot] = file; + this.pickedPreview[slot] = file.type.startsWith('image/') ? URL.createObjectURL(file) : null; + } + + clearPick(slot: 'front' | 'back'): void { + if (this.pickedPreview[slot]) { + URL.revokeObjectURL(this.pickedPreview[slot]!); + } + this.picked[slot] = null; + this.pickedPreview[slot] = null; + } + + /** + * Something has to change for the update to mean anything - either a corrected detail or a fresh + * scan. Submitting an untouched form would reopen a verified KYC record for no reason. + */ + get hasChanges(): boolean { + if (this.picked.front || this.picked.back) { + return true; + } + const v = this.form.value; + const sameNumber = (v.documentNumber || '').trim() === (this.data.documentNumber || '').trim(); + const sameIssue = this.sameDay(v.issueDate, this.toDate(this.data.issueDate)); + const sameExpiry = this.sameDay(v.expiryDate, this.toDate(this.data.expiryDate)); + return !(sameNumber && sameIssue && sameExpiry); + } + + private sameDay(a: Date | null, b: Date | null): boolean { + if (!a && !b) { + return true; + } + if (!a || !b) { + return false; + } + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + } + + submit(): void { + if (this.form.invalid || !this.hasChanges) { + this.form.markAllAsTouched(); + return; + } + const v = this.form.value; + const result: KycDocumentUpdateResult = { + documentNumber: (v.documentNumber || '').trim(), + issueDate: v.issueDate || null, + expiryDate: v.expiryDate || null, + front: this.picked.front, + back: this.picked.back, + remarks: (v.remarks || '').trim() + }; + this.dialogRef.close(result); + } + + cancel(): void { + this.dialogRef.close(); + } + + ngOnDestroy(): void { + // Only the previews this dialog created; the caller owns the ones it passed in. + for (const slot of this.slots) { + if (this.pickedPreview[slot]) { + URL.revokeObjectURL(this.pickedPreview[slot]!); + } + } + } +} diff --git a/src/app/core/http/error-handler.interceptor.ts b/src/app/core/http/error-handler.interceptor.ts index 0e47aaa09..76c11fbf5 100644 --- a/src/app/core/http/error-handler.interceptor.ts +++ b/src/app/core/http/error-handler.interceptor.ts @@ -47,7 +47,12 @@ export class ErrorHandlerInterceptor implements HttpInterceptor { private handleError(response: HttpErrorResponse, request: HttpRequest): Observable> { const status = response.status; const errorBody = response.error; - let errorMessage = errorBody?.developerMessage || response.message; + // defaultUserMessage first: it is the sentence written for the person reading it, while + // developerMessage is for the log. Falling back to response.message last, since that is + // Angular's own "Http failure response for : 409 Conflict" — a description of the + // transport, not of what went wrong, and useless to whoever is looking at the dialog. + let errorMessage = + errorBody?.defaultUserMessage || errorBody?.developerMessage || errorBody?.message || response.message; if (errorBody?.errors?.[0]) { errorMessage = errorBody.errors[0].defaultUserMessage || errorBody.errors[0].developerMessage; } @@ -104,6 +109,15 @@ export class ErrorHandlerInterceptor implements HttpInterceptor { type: this.translate.instant('error.resource.not.found'), message: errorMessage || 'Resource does not exist!' }; + } else if (status === 409) { + // A 409 is not a failure of permission or of the request itself — the action is simply not + // allowed in the record's current state (already approved, maker cannot check their own work, + // a guarantee still outstanding). The server always explains which, so its message is shown + // as-is under a title that does not contradict it. + alert = { + type: 'Action Not Allowed', + message: errorMessage || 'This action is not allowed in the current state.' + }; } else if (status === 500) { alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' }; } else if (status === 501) { diff --git a/src/app/core/services/keyboard-shortcut.service.ts b/src/app/core/services/keyboard-shortcut.service.ts new file mode 100644 index 000000000..331c220ba --- /dev/null +++ b/src/app/core/services/keyboard-shortcut.service.ts @@ -0,0 +1,228 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject, OnDestroy } from '@angular/core'; +import { Router, ActivatedRoute } from '@angular/router'; +import { fromEvent, Subscription, BehaviorSubject } from 'rxjs'; +import { KeyboardShortcutsConfiguration } from '../../keyboards-shortcut-config'; +import { AuthenticationService } from '../authentication/authentication.service'; +import { UserPreferenceService } from './user-preference.service'; +import { DocumentationLinksService } from '../../shared/services/documentation-links.service'; + +export interface ShortcutItem { + title: string; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + key: string; + route: string; + id?: string; + custom?: boolean; + action?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class KeyboardShortcutService implements OnDestroy { + private router = inject(Router); + private activatedRoute = inject(ActivatedRoute); + private authenticationService = inject(AuthenticationService); + private userPreferenceService = inject(UserPreferenceService); + private documentationLinks = inject(DocumentationLinksService); + + private keydownSubscription: Subscription; + private activeShortcutsSubject = new BehaviorSubject([]); + public activeShortcuts$ = this.activeShortcutsSubject.asObservable(); + + constructor() { + this.keydownSubscription = fromEvent(window, 'keydown').subscribe((event) => { + this.handleKeydown(event); + }); + } + + ngOnDestroy(): void { + if (this.keydownSubscription) { + this.keydownSubscription.unsubscribe(); + } + } + + public initShortcuts(): void { + const credentials = this.authenticationService.getCredentials(); + if (!credentials || !credentials.userId) { + this.loadDefaults(); + return; + } + + this.userPreferenceService.getPreference(credentials.userId, 'keyboard_shortcuts').subscribe({ + next: (data) => { + if (data && data.value) { + try { + const parsed = JSON.parse(data.value); + if (Array.isArray(parsed)) { + parsed.forEach((shortcut: ShortcutItem) => { + if (shortcut.custom && shortcut.action === 'navigate' && shortcut.route) { + shortcut.route = this.normalizeRouteStr(shortcut.route); + } + }); + } + this.activeShortcutsSubject.next(parsed); + } catch (e) { + console.error('Error parsing shortcuts', e); + this.loadDefaults(); + } + } else { + this.loadDefaults(); + } + }, + error: () => { + this.loadDefaults(); + } + }); + } + + public loadDefaults(): void { + const config = new KeyboardShortcutsConfiguration(); + this.activeShortcutsSubject.next(config.buttonsArray); + } + + public updateShortcuts(shortcuts: ShortcutItem[]): void { + const credentials = this.authenticationService.getCredentials(); + if (credentials && credentials.userId) { + const cleanedShortcuts = shortcuts.map((s) => { + const clean: any = { + title: s.title, + ctrlKey: s.ctrlKey, + shiftKey: s.shiftKey, + altKey: s.altKey, + key: s.key, + route: s.route + }; + if (s.id !== undefined) clean.id = s.id; + if (s.custom !== undefined) clean.custom = s.custom; + if (s.action !== undefined) clean.action = s.action; + return clean; + }); + + this.userPreferenceService + .updatePreference(credentials.userId, 'keyboard_shortcuts', JSON.stringify(cleanedShortcuts)) + .subscribe(() => { + this.activeShortcutsSubject.next(shortcuts); + }); + } else { + this.activeShortcutsSubject.next(shortcuts); + } + } + + public resetToDefaults(): void { + const credentials = this.authenticationService.getCredentials(); + const config = new KeyboardShortcutsConfiguration(); + if (credentials && credentials.userId) { + this.userPreferenceService + .updatePreference(credentials.userId, 'keyboard_shortcuts', JSON.stringify(config.buttonsArray)) + .subscribe(() => { + this.activeShortcutsSubject.next(config.buttonsArray); + }); + } else { + this.activeShortcutsSubject.next(config.buttonsArray); + } + } + + private handleKeydown(event: KeyboardEvent): void { + const activeShortcuts = this.activeShortcutsSubject.getValue(); + const routeD = activeShortcuts.find( + (x) => + x.ctrlKey === event.ctrlKey && + x.shiftKey === event.shiftKey && + x.altKey === event.altKey && + x.key.toLowerCase() === event.key.toLowerCase() + ); + + if (routeD !== undefined) { + event.preventDefault(); // Prevent default browser actions for matched shortcuts + + if (routeD.custom) { + if (routeD.action === 'navigate' && routeD.route) { + this.router.navigate([routeD.route]); + } + } else { + switch (routeD.id) { + case 'logout': + this.authenticationService.logout().subscribe(() => this.router.navigate(['/login'], { replaceUrl: true })); + break; + case 'help': + this.documentationLinks.open('userManual'); + break; + case 'runReport': + const runReportElement = document.getElementById('runReport'); + if (runReportElement) runReportElement.click(); + break; + case 'cancel': + const cancelEl = document.querySelector('[data-shortcut-action="cancel"]') as HTMLElement; + if (cancelEl) { + cancelEl.click(); + } else { + const cancelButtons = Array.from(document.querySelectorAll('button')).filter( + (el) => el.textContent && el.textContent.trim() === 'Cancel' + ); + if (cancelButtons.length > 0) cancelButtons[0].click(); + } + break; + case 'submit': + const submitEl = document.querySelector('[data-shortcut-action="submit"]') as HTMLElement; + if (submitEl) { + submitEl.click(); + } else { + const submitButtons = Array.from(document.querySelectorAll('button')).filter( + (el) => el.textContent && el.textContent.trim() === 'Submit' + ); + if (submitButtons.length > 0) submitButtons[0].click(); + } + break; + default: + this.router.navigate([routeD.route], { relativeTo: this.activatedRoute }); + } + } + } + } + + public normalizeRouteStr(route: string | undefined | null): string { + if (!route) return ''; + let r = route.trim(); + + try { + if (r.startsWith('http://') || r.startsWith('https://')) { + const url = new URL(r); + if (url.hash && url.hash.startsWith('#')) { + let path = url.hash.substring(1); + if (!path.startsWith('/')) { + path = '/' + path; + } + return path; + } + return url.pathname + url.search; + } + } catch (e) { + // Ignore + } + + if (r.includes('#/')) { + r = r.substring(r.indexOf('#/') + 1); + } + + if (/^[a-zA-Z0-9]+:/.test(r)) { + return '/'; + } + + if (!r.startsWith('/')) { + r = '/' + r; + } + + return r; + } +} diff --git a/src/app/core/services/shortcut-suggestion.service.ts b/src/app/core/services/shortcut-suggestion.service.ts new file mode 100644 index 000000000..02243e682 --- /dev/null +++ b/src/app/core/services/shortcut-suggestion.service.ts @@ -0,0 +1,161 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject, OnDestroy } from '@angular/core'; +import { Router, NavigationEnd } from '@angular/router'; +import { Title } from '@angular/platform-browser'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog } from '@angular/material/dialog'; +import { AuthenticationService } from '../authentication/authentication.service'; +import { KeyboardShortcutService } from './keyboard-shortcut.service'; +import { UserPreferenceService } from './user-preference.service'; +import { KeyboardShortcutsDialogComponent } from '../../shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component'; +import { filter, take } from 'rxjs/operators'; +import { Subscription } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class ShortcutSuggestionService implements OnDestroy { + private router = inject(Router); + private titleService = inject(Title); + private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + private authenticationService = inject(AuthenticationService); + private keyboardShortcutService = inject(KeyboardShortcutService); + private userPreferenceService = inject(UserPreferenceService); + + private readonly FREQUENCY_THRESHOLD = 5; + private readonly PREF_KEY = 'shortcut_suggestions_state'; + private routerSubscription!: Subscription; + + private state: { dismissed: string[] } = { dismissed: [] }; + + private get userId(): number | null { + const creds = this.authenticationService.getCredentials(); + return creds ? creds.userId : null; + } + + private getVisitsKey(): string { + return `route_visits_${this.userId}`; + } + + private getVisits(): Record { + const key = this.getVisitsKey(); + if (!key) return {}; + const val = localStorage.getItem(key); + return val ? JSON.parse(val) : {}; + } + + private saveVisits(visits: Record) { + const key = this.getVisitsKey(); + if (key) { + localStorage.setItem(key, JSON.stringify(visits)); + } + } + + private isSuggestionOpen = false; + + public init() { + const uid = this.userId; + if (uid) { + this.userPreferenceService.getPreference(uid, this.PREF_KEY).subscribe({ + next: (data) => { + if (data && data.value) { + try { + this.state = JSON.parse(data.value); + if (!this.state.dismissed) this.state.dismissed = []; + } catch (e) { + this.state = { dismissed: [] }; + } + } + }, + error: () => {} + }); + } + + this.routerSubscription = this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .subscribe((event: any) => { + this.handleNavigation(event.urlAfterRedirects); + }); + } + + private handleNavigation(url: string) { + if (!this.userId) return; + + let path = url.split('?')[0]; + path = this.keyboardShortcutService.normalizeRouteStr(path); + + const visits = this.getVisits(); + visits[path] = (visits[path] || 0) + 1; + this.saveVisits(visits); + } + + public getUsageData(): { route: string; pageName: string; visits: number; hasShortcut: boolean }[] { + const visits = this.getVisits(); + const result: { route: string; pageName: string; visits: number; hasShortcut: boolean }[] = []; + + // Get current shortcuts (sync) + let currentShortcuts: any[] = []; + this.keyboardShortcutService.activeShortcuts$.pipe(take(1)).subscribe((s) => (currentShortcuts = s)); + + for (const route of Object.keys(visits)) { + const count = visits[route]; + const hasShortcut = currentShortcuts.some( + (s) => + (s.custom && s.action === 'navigate' && this.keyboardShortcutService.normalizeRouteStr(s.route) === route) || + (!s.custom && this.keyboardShortcutService.normalizeRouteStr(s.route) === route) + ); + + let pageName = route.split('/').pop() || route; + // Capitalize and clean + pageName = pageName.replace(/-/g, ' '); + pageName = pageName.charAt(0).toUpperCase() + pageName.slice(1); + + result.push({ route, pageName, visits: count, hasShortcut }); + } + + return result.sort((a, b) => b.visits - a.visits); + } + + public markAsDismissed(route: string) { + if (!this.state.dismissed.includes(route)) { + this.state.dismissed.push(route); + const uid = this.userId; + if (uid) { + this.userPreferenceService.updatePreference(uid, this.PREF_KEY, JSON.stringify(this.state)).subscribe(); + } + } + } + + public openCreateShortcutDialog(pageName: string, route: string) { + this.dialog + .open(KeyboardShortcutsDialogComponent, { + data: { + prefill: { + title: pageName, + route: route, + action: 'navigate' + } + } + }) + .afterClosed() + .subscribe((saved) => { + if (saved) { + this.markAsDismissed(route); + } + }); + } + + ngOnDestroy() { + if (this.routerSubscription) { + this.routerSubscription.unsubscribe(); + } + } +} diff --git a/src/app/core/services/user-preference.service.ts b/src/app/core/services/user-preference.service.ts new file mode 100644 index 000000000..1802e2d7b --- /dev/null +++ b/src/app/core/services/user-preference.service.ts @@ -0,0 +1,28 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +@Injectable({ + providedIn: 'root' +}) +export class UserPreferenceService { + private http = inject(HttpClient); + + getPreference(userId: number, preferenceName: string): Observable<{ name: string; value: string }> { + const timestamp = new Date().getTime(); + return this.http.get<{ name: string; value: string }>( + `/users/${userId}/preferences/${preferenceName}?_t=${timestamp}` + ); + } + + updatePreference(userId: number, preferenceName: string, value: string): Observable<{ name: string; value: string }> { + return this.http.put<{ name: string; value: string }>(`/users/${userId}/preferences/${preferenceName}`, { value }); + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index b91425694..c6bc12365 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -86,7 +86,7 @@ - My Requests + Withdrawal Request - My Disbursements + Disbursements & Transfer My Share Requests + + + + + + My Closure Requests + @if (clerkAccountLoaded) {
} + + + + + + + My Vouchers + + + + + + + + Received Vouchers + + + + + + + + + Payment Vouchers + ESC Show Header } @else { @@ -234,6 +283,23 @@ > Cash History + + My Vouchers + + + Received + + + Payment + } } - - - - 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% - │ - - - @if (!hideShellLayout) { + @if (!hideShellLayout && menuAccessReady) {
- -
- - - LIVE - -
- - - -
-
+ } @@ -582,6 +610,9 @@ + @@ -601,12 +632,29 @@ Accounting + + + + + +
Audit & Compliance
- @@ -993,6 +1044,79 @@ } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1075,6 +1202,9 @@ + @@ -1121,12 +1251,6 @@ - - @@ -1181,72 +1305,79 @@
Share Operations
- - - - -
Share Documents
- - - - -
Share Reports
- - - - +
Share Admin
- - - - + + -
@@ -1631,7 +1762,7 @@ -
+ +
+
Registers & Reports
+ + +
+
Others
@@ -1915,6 +2057,16 @@ > OSS 3 XBRL/CIMS + + + Members & Share Capital +
diff --git a/src/app/core/shell/shell.component.ts b/src/app/core/shell/shell.component.ts index 9ce65daac..8460bcee5 100644 --- a/src/app/core/shell/shell.component.ts +++ b/src/app/core/shell/shell.component.ts @@ -13,6 +13,7 @@ import { MatDialog } from '@angular/material/dialog'; import { Router, NavigationEnd } from '@angular/router'; import { AuthenticationService } from '../authentication/authentication.service'; import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service'; +import { TenantMenuAccessService } from '../../directives/has-permission/tenant-menu-access.service'; /** rxjs Imports */ import { Observable, Subscription } from 'rxjs'; @@ -66,6 +67,7 @@ export class ShellComponent implements OnInit, OnDestroy { private router = inject(Router); private authService = inject(AuthenticationService); private clerkUiStateService = inject(ClerkUiStateService); + private tenantMenuAccessService = inject(TenantMenuAccessService); /** Subscription to breakpoint observer for handset. */ isHandset$: Observable = this.breakpointObserver @@ -79,6 +81,10 @@ export class ShellComponent implements OnInit, OnDestroy { progressBar$: Subscription; /** Hides nav/sidebar for full-screen cashier routes. */ hideShellLayout = false; + /** True once this tenant's own menu-access ceiling has loaded — the nav waits on this so a menu + * the tenant has turned off is never shown for an instant before disappearing. See + * TenantMenuAccessService. */ + menuAccessReady = false; private routeSub: Subscription; /** Account Facilities — populated from ClerkUiStateService when account loads */ @@ -143,6 +149,11 @@ export class ShellComponent implements OnInit, OnDestroy { this.cdr.detectChanges(); }); + this.tenantMenuAccessService.load().subscribe(() => { + this.menuAccessReady = true; + this.cdr.detectChanges(); + }); + // Cashier-only users: hide shell. Clerk/dashboard: also fullscreen by default. const checkLayout = () => { const url = this.router.url; diff --git a/src/app/core/shell/sidenav/sidenav.component.html b/src/app/core/shell/sidenav/sidenav.component.html index 95778bb7c..a8ae77c84 100644 --- a/src/app/core/shell/sidenav/sidenav.component.html +++ b/src/app/core/shell/sidenav/sidenav.component.html @@ -299,32 +299,38 @@ {{ 'labels.menus.Notifications' | translate }} - - - - - {{ 'labels.menus.Frequent Postings' | translate }} - - - - - - {{ 'labels.menus.Create Journal Entry' | translate }} - + + @if (!disabledFeatures.frequentPostings) { + + + + + {{ 'labels.menus.Frequent Postings' | translate }} + + } + @if (!disabledFeatures.createJournalEntries) { + + + + + {{ 'labels.menus.Create Journal Entry' | translate }} + + } -
-

+
+

Compliance & AML Triage

-
- +
+ Flag as suspicious (escalate to STR cases under RBI compliance) - + Mark as whitelisted (prevent future AML alerts for this account)
diff --git a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts index 9de8ec6fd..ab56dbecf 100644 --- a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts +++ b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts @@ -145,7 +145,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy { cases = []; } } - + const newRef = 101 + cases.length; const newCase: any = { reportType: 'STR', @@ -171,10 +171,10 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy { narrative: `CTS Cheque #${this.item.chequeNumber} for amount ₹${this.item.amount} flagged as suspicious by Clearing Officer during inward presentment review. Action: ${this.mode}. Reason/Notes: ${this.mode === 'RETURN' ? this.returnForm.value.reason : this.passForm.value.note || 'Manual compliance escalation'}`, status: 'po-review' }; - + cases.unshift(newCase); localStorage.setItem('fiu_str_cases', JSON.stringify(cases)); - + const storedCtr = localStorage.getItem('fiu_ctr_txns'); if (storedCtr) { try { @@ -201,7 +201,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy { whitelisted.push(account.toString()); localStorage.setItem('compliance_whitelisted_accounts', JSON.stringify(whitelisted)); } - + const storedCtr = localStorage.getItem('fiu_ctr_txns'); if (storedCtr) { try { diff --git a/src/app/current-accounts/create-current-account/create-current-account.component.html b/src/app/current-accounts/create-current-account/create-current-account.component.html index 900db006a..7a79e519a 100644 --- a/src/app/current-accounts/create-current-account/create-current-account.component.html +++ b/src/app/current-accounts/create-current-account/create-current-account.component.html @@ -12,9 +12,7 @@

Create Current Account

-
- Pick a product, confirm currency, set digital channels and review before submit. -
+
Pick a product, confirm currency and review before submit.
@@ -78,34 +76,7 @@
- - - - - Digital - -
- @if (currentAccountProductTemplate) { - - } - -
- - - -
-
-
- - + diff --git a/src/app/current-accounts/create-current-account/create-current-account.component.ts b/src/app/current-accounts/create-current-account/create-current-account.component.ts index 9aa74d548..6c682a919 100644 --- a/src/app/current-accounts/create-current-account/create-current-account.component.ts +++ b/src/app/current-accounts/create-current-account/create-current-account.component.ts @@ -57,8 +57,8 @@ export class CreateCurrentAccountComponent { currentAccountProductTemplate: any; /** Currently visible wizard tab (0-indexed). */ selectedTabIndex = 0; - /** Total wizard tabs — Product, Currency, Digital, Preview. */ - private readonly totalTabs = 4; + /** Total wizard tabs — Product, Currency, Preview. */ + private readonly totalTabs = 3; @ViewChild(CurrentAccountDetailsStepComponent, { static: true }) currentAccountDetailsStep: CurrentAccountDetailsStepComponent; diff --git a/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.html b/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.html index 7b9e69031..19abc5fdc 100644 --- a/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.html +++ b/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.html @@ -30,18 +30,6 @@ } - - - Opening Balance - - ₹  - @if (minRequiredOpeningBalance) { - Minimum required: ₹{{ minRequiredOpeningBalance }} - } - @if (activateCurrentAccountForm.controls.openingBalance.hasError('min')) { - Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }} - } -

diff --git a/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.ts b/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.ts index 98701b637..ae85c6d92 100644 --- a/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.ts +++ b/src/app/current-accounts/current-account-actions/activate-current-account/activate-current-account.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, OnInit, Input, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Dates } from 'app/core/utils/dates'; @@ -36,8 +36,6 @@ export class ActivateCurrentAccountComponent implements OnInit { private router = inject(Router); private settingsService = inject(SettingsService); - @Input() minRequiredOpeningBalance: number | null = null; - /** Minimum date allowed. */ minDate = new Date(2000, 0, 1); /** Maximum date allowed. */ @@ -71,18 +69,10 @@ export class ActivateCurrentAccountComponent implements OnInit { * Creates the activate current account form. */ createActivateCurrentAccountForm() { - const minBalance = this.minRequiredOpeningBalance ?? 0; this.activateCurrentAccountForm = this.formBuilder.group({ activatedOnDate: [ '', Validators.required - ], - openingBalance: [ - minBalance, - [ - Validators.required, - Validators.min(minBalance) - ] ] }); } diff --git a/src/app/current-accounts/current-account-actions/add-charge-current-account/add-charge-current-account.component.ts b/src/app/current-accounts/current-account-actions/add-charge-current-account/add-charge-current-account.component.ts index 671a5b8fb..32e8cce12 100644 --- a/src/app/current-accounts/current-account-actions/add-charge-current-account/add-charge-current-account.component.ts +++ b/src/app/current-accounts/current-account-actions/add-charge-current-account/add-charge-current-account.component.ts @@ -21,6 +21,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router'; import { CurrentAccountsService } from '../../current-accounts.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; +import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -40,6 +41,7 @@ export class AddChargeCurrentAccountComponent implements OnInit { private router = inject(Router); private dateUtils = inject(Dates); private currentAccountsService = inject(CurrentAccountsService); + private facilityChargeFilter = inject(FacilityChargeFilterService); private settingsService = inject(SettingsService); /** Minimum Due Date allowed. */ @@ -66,7 +68,9 @@ export class AddChargeCurrentAccountComponent implements OnInit { */ constructor() { this.route.data.subscribe((data: { currentAccountActionData: any }) => { - this.currentAccountChargeOptions = data.currentAccountActionData.chargeOptions; + this.facilityChargeFilter + .excludeFacilityCharges(data.currentAccountActionData.chargeOptions) + .subscribe((charges: any[]) => (this.currentAccountChargeOptions = charges)); }); this.currentAccountId = this.route.snapshot.params['currentAccountId']; } diff --git a/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.html b/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.html index 78322c36e..cfb7b6a87 100644 --- a/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.html +++ b/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.html @@ -10,6 +10,7 @@
+

This approves and activates the account in one step -- it goes straight to Active.

{{ 'labels.inputs.Approved On Date' | translate }} diff --git a/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.ts b/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.ts index ca8791c73..f71db9650 100644 --- a/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.ts +++ b/src/app/current-accounts/current-account-actions/approve-current-account/approve-current-account.component.ts @@ -81,8 +81,9 @@ export class ApproveCurrentAccountComponent implements OnInit { } /** - * Submits the form and approves the current account, - * if successful redirects to the current account. + * Submits the form, approves the current account, then immediately activates it on the same + * date -- Approve and Activate are still two separate Fineract commands underneath (each shows + * up in the audit trail), but the officer only sees and clicks through one step. */ submit() { const approveCurrentAccountFormData = this.approveCurrentAccountForm.value; @@ -98,7 +99,16 @@ export class ApproveCurrentAccountComponent implements OnInit { locale }; this.currentAccountsService.executeCurrentAccountCommand(this.accountId, 'approve', data).subscribe(() => { - this.router.navigate(['../../transactions'], { relativeTo: this.route }); + const activationData = { + activatedOnDate: approveCurrentAccountFormData.approvedOnDate, + dateFormat, + locale + }; + this.currentAccountsService + .executeCurrentAccountCommand(this.accountId, 'activate', activationData) + .subscribe(() => { + this.router.navigate(['../../transactions'], { relativeTo: this.route }); + }); }); } } diff --git a/src/app/current-accounts/current-account-actions/current-account-actions.component.html b/src/app/current-accounts/current-account-actions/current-account-actions.component.html index b89775310..b1442a445 100644 --- a/src/app/current-accounts/current-account-actions/current-account-actions.component.html +++ b/src/app/current-accounts/current-account-actions/current-account-actions.component.html @@ -13,9 +13,7 @@ } @if (actions['Activate']) { - + } @if (actions['Undo Approval']) { diff --git a/src/app/current-accounts/current-account-stepper/current-account-charges-step/current-account-charges-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-charges-step/current-account-charges-step.component.ts index c9c5a906d..37032404d 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-charges-step/current-account-charges-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-charges-step/current-account-charges-step.component.ts @@ -36,6 +36,7 @@ import { TranslateService } from '@ngx-translate/core'; import { MatButton, MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; +import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service'; import { ChargesFilterPipe } from '../../../pipes/charges-filter.pipe'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { FormatNumberPipe } from '../../../pipes/format-number.pipe'; @@ -73,6 +74,7 @@ export class CurrentAccountChargesStepComponent implements OnInit { private dialog = inject(MatDialog); private dateUtils = inject(Dates); private translateService = inject(TranslateService); + private facilityChargeFilter = inject(FacilityChargeFilterService); /** Current Account Product Template */ private _currentAccountProductTemplate: any; @@ -80,7 +82,9 @@ export class CurrentAccountChargesStepComponent implements OnInit { set currentAccountProductTemplate(value: any) { this._currentAccountProductTemplate = value; if (value) { - this.chargeData = value.chargeOptions || []; + this.facilityChargeFilter + .excludeFacilityCharges(value.chargeOptions) + .subscribe((charges: any[]) => (this.chargeData = charges)); // Only pre-populate from product template for new accounts if (!this.currentAccountTemplate?.id) { this.chargesDataSource = (value.charges || []).map((charge: any) => ({ ...charge, id: charge.chargeId })); diff --git a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html index 1cf4ac389..69528ae98 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html +++ b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html @@ -529,7 +529,7 @@ Age - Must be below 18 years + Must be below {{ minorAgeThreshold }} years diff --git a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts index 8c5592d1a..770117e66 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts @@ -15,6 +15,12 @@ import { Dates } from 'app/core/utils/dates'; import { CurrentAccountsService } from 'app/current-accounts/current-accounts.service'; import { SettingsService } from 'app/settings/settings.service'; import { ClientsService } from 'app/clients/clients.service'; +import { + AGE_OF_MAJORITY, + MINOR_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; import { MatDialog } from '@angular/material/dialog'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { MatIcon } from '@angular/material/icon'; @@ -99,6 +105,8 @@ export class CurrentAccountDetailsStepComponent implements OnInit { // ── Client info (auto-fetched) ───────────────────────────────────────── clientDob: Date | null = null; clientAge: number | null = null; + /** Exposed for tooltips so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; clientGender: string | null = null; primaryClientName = ''; primaryClientCif = ''; @@ -444,10 +452,10 @@ export class CurrentAccountDetailsStepComponent implements OnInit { return; } if (this.clientAge === null) return; - if (this.clientAge < 18) { + if (isMinorAge(this.clientAge)) { this.selectedAccountStatus = 'MINOR'; this.minorDob = this.clientDob; - } else if (this.clientAge >= 60) { + } else if (isSeniorCitizenAge(this.clientAge)) { this.selectedAccountStatus = 'SENIOR_CITIZEN'; } else if (this.clientGender === 'female') { this.selectedAccountStatus = 'INDIVIDUAL_FEMALE'; @@ -458,15 +466,15 @@ export class CurrentAccountDetailsStepComponent implements OnInit { isTileDisabled(value: string): boolean { if (this.readOnly) return true; - if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; - if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; + if (value === 'MINOR') return !isMinorAge(this.clientAge); + if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge); return false; } getTileTooltip(value: string): string { - if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) - return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`; - if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) + if (value === 'MINOR' && !isMinorAge(this.clientAge)) + return `Age ${this.clientAge} yrs — Minor only allowed below years`; + if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`; if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age'; if (value === 'SENIOR_CITIZEN' && this.clientAge === null) return 'DOB not available — cannot verify age'; @@ -609,7 +617,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit { const dob = this.minorDob ?? this.clientDob; if (!dob) return ''; const d = new Date(dob); - d.setFullYear(d.getFullYear() + 18); + d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY); return this.dateUtils.formatDate(d, 'M/d/yyyy') || ''; } diff --git a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html index ae448a85a..ec427c6c3 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html +++ b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html @@ -15,7 +15,6 @@

Digital & Account Features

Toggle the facilities available for this account (as per RBI guidelines)

- FROM PRODUCT
@@ -175,18 +174,6 @@
-
-
-
- Withdrawal Facility - {{ facilityConfig.withdrawalFacility ? 'Enabled' : 'Disabled' }} -
- -
-
diff --git a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts index 6e5cc09d5..b9159ace9 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts @@ -24,7 +24,6 @@ const DEFAULT_FACILITY_CONFIG = { neftFacility: false, rtgsFacility: false, interBankTotalCapFacility: false, - withdrawalFacility: false, depositFacilityKyc: false, virtualDebitCard: false, internetBanking: false, @@ -52,12 +51,11 @@ export class CurrentAccountDigitalStepComponent { } } + // Note: a new account always starts with every digital facility off, even if the product has + // its own defaults configured -- those are enabled deliberately, one at a time, after the + // account is created and active, not inherited automatically at opening. @Input() - set currentAccountProductTemplate(value: any) { - if (!this.hasExistingAccount && value?.accountFacilityConfig) { - this.facilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...value.accountFacilityConfig }; - } - } + currentAccountProductTemplate: any; get currentAccountDigital() { return { accountFacilityConfig: { ...this.facilityConfig } }; diff --git a/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts index dbddadaa1..86d0ac187 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts @@ -76,7 +76,6 @@ export class CurrentAccountPreviewStepComponent implements OnChanges { { key: 'neftFacility', label: 'NEFT Facility' }, { key: 'rtgsFacility', label: 'RTGS Facility' }, { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' }, - { key: 'withdrawalFacility', label: 'Withdrawal Facility' }, { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' }, { key: 'virtualDebitCard', label: 'Virtual Debit Card' }, { key: 'internetBanking', label: 'Internet Banking' }, @@ -136,12 +135,11 @@ export class CurrentAccountPreviewStepComponent implements OnChanges { return this.currentAccountSettingsForm?.getRawValue?.() || this.currentAccount || {}; } + /** Digital facility config as actually captured by a digital step in this wizard -- no fallback + * to the product's own defaults, since account creation no longer has a digital step at all and + * must not preview facilities the officer never actually chose. */ get digitalFacilityConfig(): any { - return ( - this.currentAccountDigital?.accountFacilityConfig ?? - this.currentAccountProductTemplate?.accountFacilityConfig ?? - null - ); + return this.currentAccountDigital?.accountFacilityConfig ?? null; } get hasDigitalSection(): boolean { diff --git a/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.ts b/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.ts index 71517bdfd..697d53a5c 100644 --- a/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.ts +++ b/src/app/current-accounts/current-account-view/passbook-tab/passbook-tab.component.ts @@ -152,46 +152,294 @@ export class PassbookTabComponent implements OnInit, OnDestroy { } printPassbook(): void { - const content = this.passbookContent?.nativeElement?.innerHTML; - if (!content) return; + const source = this.passbookContent?.nativeElement as HTMLElement; + if (!source) return; + + /** Work on a copy so the on-screen statement is untouched */ + const clone = source.cloneNode(true) as HTMLElement; + clone.querySelectorAll('.no-print').forEach((el: Element) => el.remove()); + + /** Material icon fonts are not available in the popup, so swap them for inline SVG */ + clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => { + const insideLogo = !!el.closest('.bank-logo-circle'); + if (insideLogo) { + el.outerHTML = PassbookTabComponent.BANK_LOGO_SVG; + } else { + el.remove(); + } + }); + + const printWindow = window.open('', 'Passbook', 'height=800,width=1100'); + if (!printWindow) return; - const printWindow = window.open('', 'Passbook', 'height=700,width=900'); printWindow.document.write(` + Current Account Passbook - ${this.accountNo} - + - ${content} +
${clone.outerHTML}
+ `); printWindow.document.close(); printWindow.focus(); - printWindow.print(); - printWindow.close(); } + /** Material "account_balance" glyph, inlined so the print window needs no icon font */ + private static readonly BANK_LOGO_SVG = ` + `; + + /** + * Mirrors passbook-tab.component.scss so the printed statement looks like the + * on-screen one. Kept in sync manually - component styles are scoped and are + * not reachable from the popup window. + */ + private static readonly PRINT_STYLES = ` + @page { size: A4 portrait; margin: 10mm; } + + * { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + + body { + font-family: 'Segoe UI', Arial, sans-serif; + color: #333; + background: #fff; + } + + .statement-print-page { padding: 0; } + + .statement-wrapper { + background: #fff; + border: 1px solid #d0d7e3; + border-radius: 6px; + overflow: hidden; + } + + /* Bank header */ + .bank-header { + background: #1a3a6b; + padding: 14px 20px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 4px solid #c8a951; + } + + .bank-logo-area { display: flex; align-items: center; gap: 12px; } + + .bank-logo-circle { + width: 44px; + height: 44px; + border-radius: 50%; + background: #c8a951; + display: flex; + align-items: center; + justify-content: center; + } + + .bank-logo-svg { fill: #1a3a6b; display: block; } + + .bank-name-block .bank-title { + color: #fff; + font-size: 16px; + font-weight: 700; + letter-spacing: 0.3px; + } + + .bank-name-block .bank-subtitle { + color: #c8a951; + font-size: 10px; + font-weight: 500; + letter-spacing: 1.5px; + text-transform: uppercase; + margin-top: 2px; + } + + .statement-label { text-align: right; } + + .statement-label .stmt-label-text { + color: #c8a951; + font-size: 12px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + } + + .statement-label .stmt-period { + color: rgba(255, 255, 255, 0.75); + font-size: 10px; + margin-top: 3px; + } + + /* Account details band */ + .account-details-band { + background: #f0f3f9; + border-bottom: 1px solid #d0d7e3; + padding: 12px 20px; + } + + .account-details-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px 24px; + } + + .detail-block .detail-label { + font-size: 8.5px; + font-weight: 700; + color: #7a8aaa; + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 2px; + } + + .detail-block .detail-value { font-size: 12px; color: #222; } + .detail-block .detail-value.bold { font-weight: 600; } + .detail-block .detail-value.mono { font-family: 'Courier New', monospace; letter-spacing: 1px; } + + .detail-block .detail-value.status-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + background: #eee; + color: #555; + } + + .detail-block .detail-value.status-badge.active { background: #d4edda; color: #155724; } + + /* Balance summary strip */ + .balance-summary-strip { + display: flex; + align-items: stretch; + background: #1a3a6b; + } + + .balance-box { flex: 1; padding: 10px 18px; text-align: center; } + + .balance-box .balance-box-label { + font-size: 9px; + font-weight: 600; + color: rgba(255, 255, 255, 0.65); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 3px; + } + + .balance-box .balance-box-value { font-size: 15px; font-weight: 700; color: #c8a951; } + + .balance-divider { width: 1px; background: rgba(255, 255, 255, 0.15); margin: 6px 0; } + + /* Transactions table */ + .statement-table { width: 100%; border-collapse: collapse; font-size: 11px; } + + .statement-table thead { display: table-header-group; } + .statement-table tfoot { display: table-row-group; } + .statement-table tr { page-break-inside: avoid; break-inside: avoid; } + + .statement-table thead tr { + background: #e8edf5; + border-top: 1px solid #d0d7e3; + border-bottom: 2px solid #1a3a6b; + } + + .statement-table thead th { + padding: 8px 9px; + font-size: 10px; + font-weight: 700; + color: #1a3a6b; + text-transform: uppercase; + letter-spacing: 0.4px; + white-space: nowrap; + text-align: left; + } + + .statement-table tbody tr { border-bottom: 1px solid #e8ecf3; } + .statement-table tbody tr:nth-child(even) { background: #f4f7fb; } + .statement-table tbody tr.reversed-row { opacity: 0.55; text-decoration: line-through; } + .statement-table tbody td { padding: 6px 9px; vertical-align: middle; color: #333; } + + .statement-table tfoot .total-row { background: #e8edf5; border-top: 2px solid #1a3a6b; } + .statement-table tfoot .total-row td { padding: 8px 9px; } + + /* Column widths */ + .col-sr { width: 34px; } + .col-date { width: 100px; white-space: nowrap; } + .col-desc { min-width: 150px; } + .col-ref { width: 74px; } + .col-amount { width: 100px; } + .col-balance { width: 110px; } + + /* Amount colors */ + .debit-amount { color: #c0392b; font-weight: 500; } + .credit-amount { color: #1a7a3f; font-weight: 500; } + .balance-amount { color: #1a3a6b; font-weight: 600; font-family: 'Courier New', monospace; } + + .total-label { + font-size: 11px; + font-weight: 700; + color: #1a3a6b; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .closing-balance { + font-size: 13px; + font-weight: 700; + color: #1a3a6b; + font-family: 'Courier New', monospace; + } + + /* Badges & misc */ + .txn-desc { font-weight: 500; } + + .reversed-badge { + display: inline-block; + margin-left: 6px; + font-size: 8px; + font-weight: 700; + color: #fff; + background: #e74c3c; + padding: 1px 5px; + border-radius: 3px; + letter-spacing: 0.5px; + } + + .text-right { text-align: right; } + .text-center { text-align: center; } + .text-muted { color: #999; } + .mono { font-family: 'Courier New', monospace; font-size: 10px; } + + /* No data */ + .no-data-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; } + + /* Footer */ + .statement-footer { + background: #f0f3f9; + border-top: 1px solid #d0d7e3; + padding: 10px 20px; + text-align: center; + } + + .statement-footer .footer-note { + font-size: 9px; + color: #888; + margin-bottom: 3px; + font-style: italic; + } + + .statement-footer .footer-meta { font-size: 9px; color: #aaa; letter-spacing: 0.3px; } + `; + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.ts b/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.ts index bca2ab003..9fcd83c44 100644 --- a/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.ts +++ b/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.ts @@ -43,7 +43,6 @@ const DEFAULT_FACILITY_CONFIG: Record = { neftFacility: false, rtgsFacility: false, interBankTotalCapFacility: false, - withdrawalFacility: false, depositFacilityKyc: false, virtualDebitCard: false, internetBanking: false, @@ -66,7 +65,6 @@ const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [ { key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' }, { key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' }, { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' }, - { key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' }, { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' }, { key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' }, { key: 'internetBanking', label: 'Internet Banking', icon: 'fa-globe' }, diff --git a/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.html b/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.html index a0b81c891..d0ac260e9 100644 --- a/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.html +++ b/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.html @@ -33,6 +33,7 @@ @@ -107,7 +108,40 @@
- + + + + + Terms + +
+ @if (fixedDepositsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + @@ -121,6 +155,7 @@ [fixedDepositAccountData]="fixedDepositAccount" [selectedTenure]="selectedTenure" [fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []" + [closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}" [hideButtons]="true" (submitEvent)="submit()" > diff --git a/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.ts b/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.ts index 99e4fcfef..d076cbf67 100644 --- a/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.ts +++ b/src/app/deposits/fixed-deposits/create-fixed-deposit-account/create-fixed-deposit-account.component.ts @@ -24,6 +24,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FixedDepositAccountInterestRateChartStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component'; import { FixedDepositAccountPreviewStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component'; +import { FixedDepositAccountMaturityTermsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; FixedDepositAccountDetailsStepComponent, FixedDepositAccountInterestRateChartStepComponent, FixedDepositAccountCurrencyStepComponent, + FixedDepositAccountMaturityTermsStepComponent, FixedDepositAccountPreviewStepComponent ] }) @@ -56,6 +58,10 @@ export class CreateFixedDepositAccountComponent { fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent; @ViewChild(FixedDepositAccountCurrencyStepComponent) fixedDepositAccountCurrencyStep: FixedDepositAccountCurrencyStepComponent; + @ViewChild(FixedDepositAccountPreviewStepComponent) + fixedDepositAccountPreviewStep: FixedDepositAccountPreviewStepComponent; + @ViewChild(FixedDepositAccountMaturityTermsStepComponent) + fixedDepositAccountMaturityTermsStep: FixedDepositAccountMaturityTermsStepComponent; /** Fixed Deposits Account Template */ fixedDepositsAccountTemplate: any; @@ -63,8 +69,8 @@ export class CreateFixedDepositAccountComponent { fixedDepositsAccountProductTemplate: any; /** Currently visible wizard tab (0-indexed). */ selectedTabIndex = 0; - /** Total wizard tabs — Product, Currency, Chart, Preview. */ - private readonly totalTabs = 4; + /** Total wizard tabs — Product, Currency, Chart, Terms, Preview. */ + private readonly totalTabs = 5; /** Active CBS base interest rate versions for the selected product's FD type */ cbsBaseRates: any[] = []; /** Tenure selected from the interest rate chart step */ @@ -243,6 +249,7 @@ export class CreateFixedDepositAccountComponent { ...this.fixedDepositAccount, ...terms, ...settings, + ...(this.fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}), ...(this.selectedTenure ? { depositPeriod: this.selectedTenure.depositPeriod, diff --git a/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.html b/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.html index 8cbf3f2df..c7e98d743 100644 --- a/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.html +++ b/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.html @@ -35,6 +35,7 @@ @@ -111,7 +112,40 @@
- + + + + + Terms + +
+ @if (fixedDepositsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + @@ -125,6 +159,7 @@ [fixedDepositAccountData]="fixedDepositAccount" [selectedTenure]="selectedTenure" [fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []" + [closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}" [hideButtons]="true" (submitEvent)="submit()" > diff --git a/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.ts b/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.ts index 7399b0efa..41c63cb0f 100644 --- a/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.ts +++ b/src/app/deposits/fixed-deposits/edit-fixed-deposit-account/edit-fixed-deposit-account.component.ts @@ -25,6 +25,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FixedDepositAccountInterestRateChartStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component'; import { FixedDepositAccountPreviewStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component'; +import { FixedDepositAccountMaturityTermsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -41,6 +42,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; FixedDepositAccountDetailsStepComponent, FixedDepositAccountInterestRateChartStepComponent, FixedDepositAccountCurrencyStepComponent, + FixedDepositAccountMaturityTermsStepComponent, FixedDepositAccountPreviewStepComponent ] }) @@ -58,6 +60,10 @@ export class EditFixedDepositAccountComponent { fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent; @ViewChild(FixedDepositAccountCurrencyStepComponent) fixedDepositAccountCurrencyStep: FixedDepositAccountCurrencyStepComponent; + @ViewChild(FixedDepositAccountPreviewStepComponent) + fixedDepositAccountPreviewStep: FixedDepositAccountPreviewStepComponent; + @ViewChild(FixedDepositAccountMaturityTermsStepComponent) + fixedDepositAccountMaturityTermsStep: FixedDepositAccountMaturityTermsStepComponent; /** Full account data + template from resolver */ fixedDepositsAccountAndTemplate: any; @@ -65,7 +71,7 @@ export class EditFixedDepositAccountComponent { fixedDepositsAccountProductTemplate: any; /** Currently visible wizard tab (0-indexed). */ selectedTabIndex = 0; - private readonly totalTabs = 4; + private readonly totalTabs = 5; /** Active CBS base interest rate versions for the selected product's FD type */ cbsBaseRates: any[] = []; /** Tenure selected from the interest rate chart step */ @@ -236,6 +242,7 @@ export class EditFixedDepositAccountComponent { ...this.fixedDepositAccount, ...terms, ...settings, + ...(this.fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}), ...(this.selectedTenure ? { depositPeriod: this.selectedTenure.depositPeriod, diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html index b5f865cbd..07482706b 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html @@ -618,7 +618,7 @@ Age - Must be below 18 years + Must be below {{ minorAgeThreshold }} years diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts index c4a42c210..146e5b877 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts @@ -7,7 +7,17 @@ */ /** Angular Imports */ -import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core'; +import { + Component, + OnInit, + OnChanges, + SimpleChanges, + Input, + Output, + EventEmitter, + inject, + ChangeDetectorRef +} from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; @@ -16,6 +26,12 @@ import { MatDialog } from '@angular/material/dialog'; /** Custom Services */ import { FixedDepositsService } from '../../fixed-deposits.service'; import { ClientsService } from 'app/clients/clients.service'; +import { + AGE_OF_MAJORITY, + MINOR_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; import { MatTooltip } from '@angular/material/tooltip'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -43,7 +59,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc IntroducerSearchComponent ] }) -export class FixedDepositAccountDetailsStepComponent implements OnInit { +export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChanges { private formBuilder = inject(UntypedFormBuilder); private fixedDepositsService = inject(FixedDepositsService); private settingsService = inject(SettingsService); @@ -56,6 +72,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { /** Fixed Deposits Account Template */ @Input() fixedDepositsAccountTemplate: any; @Input() hideButtons = false; + /** Linked-account id picked elsewhere in the wizard (e.g. the Terms step's Transfer to Savings + * account) -- pre-fills this card so the officer doesn't have to pick the same account twice. */ + @Input() externalLinkedAccountId: number | null = null; /** Minimum date allowed. */ minDate = new Date(2000, 0, 1); @@ -94,8 +113,16 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { selectedCustomerSegment: string | null = null; // ── Client info (auto-fetched) ───────────────────────────────────────── + /** + * The client's legal form ("Individual" / "Non-Individual") as captured at client creation. Customer + * Segment is derived from this rather than picked by hand, so an account can never disagree with the + * CIF about what kind of customer it belongs to. + */ + clientLegalForm: string | null = null; clientDob: Date | null = null; clientAge: number | null = null; + /** Exposed for tooltips so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; clientGender: string | null = null; primaryClientName = ''; primaryClientCif = ''; @@ -222,6 +249,23 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { this.dalSearch = ''; } + ngOnChanges(changes: SimpleChanges): void { + if (!changes.externalLinkedAccountId || this.selectedLinkedAccount) { + return; + } + const id = this.externalLinkedAccountId; + if (!id) return; + const savings = this.dalAllSavings.find((a: any) => a.id === id); + const current = this.dalAllCurrent.find((a: any) => a.id === id); + if (savings) { + this.dalSelectedType = 'SAVINGS'; + this.selectedLinkedAccount = savings; + } else if (current) { + this.dalSelectedType = 'CURRENT'; + this.selectedLinkedAccount = current; + } + } + private loadClientAccounts(): void { const clientId = String(this.fixedDepositsAccountTemplate?.clientId); if (!clientId) return; @@ -249,7 +293,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { currencyCode: a.currency?.code ?? '' })); this.dalLoading = false; - // Pre-select linked account in edit mode + // Pre-select linked account in edit mode. The GET response nests the Fineract association as + // linkedAccount.id, and separately carries the CBS column as linkedAccountId -- the latter is the + // fallback for accounts that have the CBS field set but no association row. const linkedId = this.fixedDepositsAccountTemplate?.linkedAccount?.id ?? this.fixedDepositsAccountTemplate?.linkedAccountId; if (linkedId) { @@ -329,6 +375,8 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { this.autoDetectAccountStatus(); } } + this.clientLegalForm = client?.legalForm?.value ?? client?.legalForm?.name ?? null; + this.applyInferredCustomerSegment(); this.cdr.detectChanges(); }); } @@ -356,8 +404,8 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { } isTileDisabled(value: string): boolean { - if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; - if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; + if (value === 'MINOR') return !isMinorAge(this.clientAge); + if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge); return false; } @@ -382,10 +430,10 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { } getTileTooltip(value: string): string { - if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) { - return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`; + if (value === 'MINOR' && !isMinorAge(this.clientAge)) { + return `Age ${this.clientAge} yrs — Minor only allowed below years`; } - if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) { + if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) { return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`; } if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age'; @@ -398,10 +446,10 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { return; } if (this.clientAge === null) return; - if (this.clientAge < 18) { + if (isMinorAge(this.clientAge)) { this.selectedAccountStatus = 'MINOR'; this.minorDob = this.clientDob; - } else if (this.clientAge >= 60) { + } else if (isSeniorCitizenAge(this.clientAge)) { this.selectedAccountStatus = 'SENIOR_CITIZEN'; } else if (this.clientGender === 'female') { this.selectedAccountStatus = 'INDIVIDUAL_FEMALE'; @@ -451,6 +499,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment); this.selectedAccountType = response.accountType ?? null; this.selectedCustomerSegment = response.customerSegment ?? null; + // The product default above is only a fallback -- the client's own legal form wins. Re-applied + // here as well as in the client callback because the two responses can arrive in either order. + this.applyInferredCustomerSegment(); this.fixedDepositsAccountProductTemplate.emit(response); this.fieldOfficerData = response.fieldOfficerOptions; this.isProductSelected = true; @@ -561,6 +612,24 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { return fallback ? [fallback] : []; } + /** + * Selects the Customer Segment that matches the client's legal form. The segment code values + * ("Individual" / "Non-Individual") are seeded to be byte-identical to the legal-form labels, so this + * is an exact, case-insensitive comparison on purpose -- a substring match would resolve + * "Non-Individual" to the "Individual" option, since one contains the other. Leaves the product + * default untouched when the client's form is unknown or has no matching option. + */ + private applyInferredCustomerSegment(): void { + if (!this.clientLegalForm || !this.customerSegmentOptions.length) { + return; + } + const target = this.clientLegalForm.trim().toLowerCase(); + const match = this.customerSegmentOptions.find((opt) => String(opt).trim().toLowerCase() === target); + if (match) { + this.selectedCustomerSegment = match; + } + } + formatTemplateDate(value: any): string { if (!value) return ''; let dateValue: Date | null = null; @@ -703,7 +772,7 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { const dob = this.minorDob ?? this.clientDob; if (!dob) return ''; const d = new Date(dob); - d.setFullYear(d.getFullYear() + 18); + d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY); return this.dateUtils.formatDate(d, 'M/d/yyyy') || ''; } @@ -767,6 +836,15 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit { } if (this.selectedLinkedAccount) { + // The same picked account has to be sent under BOTH names -- they are two different backend + // parameters feeding two different things, not a duplicate: + // linkAccountId (DepositsApiConstants.linkedAccountParamName) -> creates the Fineract + // AccountAssociations row that the monthly interest-transfer job reads, and that + // FD Special's linked-account requirement validates against. + // linkedAccountId (SavingsApiConstants.linkedAccountIdParamName) -> populates the CBS-specific + // m_savings_account.linked_account_id column via applyCbsFields. + // Only linkedAccountId was being sent before, so the association was never created. + value.linkAccountId = this.selectedLinkedAccount.id; value.linkedAccountId = this.selectedLinkedAccount.id; value.linkedAccountType = this.selectedLinkedAccount.accountType; value.linkedAccountNo = this.selectedLinkedAccount.accountNo; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html index 51bf1b47f..8eab0d0bc 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html @@ -170,12 +170,12 @@
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts index a54cf9e1d..beff3f51d 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts @@ -6,7 +6,17 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, Input, OnChanges, SimpleChanges, Output, EventEmitter, ViewChild, inject } from '@angular/core'; +import { + Component, + Input, + OnChanges, + SimpleChanges, + Output, + EventEmitter, + ViewChild, + ChangeDetectorRef, + inject +} from '@angular/core'; import { MatTableDataSource, MatTable, @@ -26,6 +36,9 @@ import { FormsModule } from '@angular/forms'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FixedDepositsService } from '../../fixed-deposits.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util'; export type FdPeriodUnit = 'Days' | 'Months' | 'Years'; @@ -71,6 +84,9 @@ export interface SlabMatchResult { }) export class FixedDepositAccountInterestRateChartStepComponent implements OnChanges { private fixedDepositsService = inject(FixedDepositsService); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + private cdr = inject(ChangeDetectorRef); @Input() fixedDepositsAccountTemplate: any; @Input() fixedDepositsAccountProductTemplate: any; @@ -110,7 +126,19 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan 'actions' ]; + /** + * Derived from the client's own date of birth -- never staff-editable. Setting this by hand is what + * let a 24-year-old's FD get priced at the senior-citizen rate, so the checkbox is display-only and + * this value comes solely from {@link deriveSeniorCitizenStatus}. + */ isSeniorCitizen = false; + readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD; + /** Client age in completed years, or null when the DOB is unknown / the client is an entity. */ + clientAge: number | null = null; + /** False for a company/entity client, which has no age and so can never qualify. */ + clientIsIndividual = true; + /** True once the client lookup has come back, so the UI can explain the locked checkbox honestly. */ + clientAgeResolved = false; incentivesDisplayedColumns: string[] = [ 'entityType', 'attributeName', @@ -171,6 +199,9 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan } ngOnChanges(changes: SimpleChanges) { + if (changes['fixedDepositsAccountTemplate']) { + this.resolveSeniorCitizenFromClient(); + } // Only reset/re-apply when TEMPLATE or cbsBaseRates changes, NOT when initialTenure/submittedOnDate changes alone const templateChanged = changes['fixedDepositsAccountProductTemplate'] || changes['cbsBaseRates']; if (templateChanged && this.fixedDepositsAccountProductTemplate) { @@ -206,10 +237,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan if (this.initialTenure.depositAmount) { this.principalAmount = this.initialTenure.depositAmount; } - // Pre-check senior citizen if it was set during create - if (this.initialTenure.isSeniorCitizen) { - this.isSeniorCitizen = true; - } + // Deliberately NOT restoring initialTenure.isSeniorCitizen: the flag is derived from the client's + // date of birth in resolveSeniorCitizenFromClient(). Re-applying a stored value here would carry a + // previously mis-set flag forward on every edit, which is exactly how a 24-year-old's FD kept its + // senior-citizen rate. // Auto-calculate when tenure is pre-filled (edit mode) setTimeout(() => this.calculateRate(), 0); } @@ -435,6 +466,45 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan this.validationWarning = null; } + /** + * Derives the senior-citizen flag from the client's date of birth. Runs whenever the account template + * arrives (create and edit alike), so the flag always reflects the customer on record rather than + * whatever was ticked -- or previously saved -- by hand. + */ + private resolveSeniorCitizenFromClient(): void { + const clientId = this.fixedDepositsAccountTemplate?.clientId ?? this.fixedDepositsAccountTemplate?.id; + if (!clientId) { + return; + } + this.clientsService.getClientData(String(clientId)).subscribe((client: any) => { + const status = deriveSeniorCitizenStatus(client, this.settingsService.businessDate); + this.clientAge = status.age; + this.clientIsIndividual = status.isIndividual; + this.isSeniorCitizen = status.isSeniorCitizen; + this.clientAgeResolved = true; + // Re-price against the now-known status if a tenure was already entered. + this.onSeniorCitizenChange(); + this.cdr.detectChanges(); + }); + } + + /** Explains the locked checkbox to staff, so a missing benefit never looks like a system fault. */ + get seniorCitizenHint(): string { + if (!this.clientAgeResolved) { + return 'Determined from the client’s date of birth'; + } + if (!this.clientIsIndividual) { + return 'Not applicable to a non-individual client'; + } + if (this.clientAge === null) { + return 'Client has no date of birth on record'; + } + if (this.isSeniorCitizen) { + return `Client is ${this.clientAge} years old — higher rate applied`; + } + return `Client is ${this.clientAge} — applies from age ${this.seniorCitizenAgeThreshold}`; + } + onSeniorCitizenChange() { if (this.tenureValue && this.tenureValue > 0) { this.calculateRate(); diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html new file mode 100644 index 000000000..17d6f00d7 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.html @@ -0,0 +1,108 @@ + + + +

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

+ + + {{ 'labels.inputs.Maturity Instructions' | translate }} + + @for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) { + + {{ maturityInstruction.value | translateKey: 'catalogs' }} + + } + + + + @if (closureSettingsForm.contains('transferToSavingsId')) { +
+
+
+
+ savings +
+
+

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

+ Maturity proceeds will be credited to this account +
+
+
+ +
+ @if (selectedTransferAccount) { +
+
+ + savings + Savings + +
+ {{ selectedTransferAccount.accountNo }} + {{ selectedTransferAccount.savingsProductName }} +
+ @if (selectedTransferAccount.summary?.accountBalance != null) { +
+ Balance + + {{ selectedTransferAccount.currency?.code }} + {{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }} + +
+ } +
+ +
+ } @else { + + {{ 'labels.inputs.Transfer to savings account' | translate }} + + @for (account of savingsAccountsData; track account) { + + ({{ account.accountNo }}) {{ account.savingsProductName }} + + } + + Transfer to savings account is required + + } +
+
+ } + + + {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + + + @if (closureSettingsForm.value.preClosurePenalApplicable) { + + {{ 'labels.inputs.Penal Interest' | translate }} (%) + + + + + {{ 'labels.inputs.Period' | translate }} + + @for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) { + + {{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }} + + } + + + } + diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.scss new file mode 100644 index 000000000..1f807bcce --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.scss @@ -0,0 +1,152 @@ +/** + * 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/. + */ + +// ── Transfer-to-Savings account card (mirrors the Deposit Account Link card) ── + +.dal-card { + margin-top: 16px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; + overflow: hidden; + + &__header { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 24px; + border-bottom: 1px solid #f1f5f9; + background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); + } + + &__header-left { + display: flex; + align-items: center; + gap: 14px; + } + + &__icon { + width: 44px; + height: 44px; + border-radius: 10px; + background: #0ea5e9; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: #0c4a6e; + } + + &__sub { + display: block; + font-size: 12px; + color: #64748b; + margin-top: 2px; + } + + &__body { + padding: 20px 24px; + } +} + +.dal-selected { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 20px; + border: 2px solid #0ea5e9; + border-radius: 12px; + background: #f0f9ff; + + &__info { + display: flex; + align-items: center; + gap: 14px; + flex: 1; + flex-wrap: wrap; + } + + &__details { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + } + + &__accno { + font-size: 15px; + font-weight: 700; + color: #0c4a6e; + font-family: monospace; + } + + &__product { + font-size: 12px; + color: #64748b; + } + + &__balance { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + } + + &__bal-label { + font-size: 10px; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__bal-amount { + font-size: 15px; + font-weight: 700; + color: #0c4a6e; + } +} + +.dal-change-btn { + white-space: nowrap; +} + +.dal-type-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + background: #f1f5f9; + color: #475569; + border: 1px solid #e2e8f0; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &--savings { + background: #eff6ff; + color: #1d4ed8; + border-color: #bfdbfe; + + mat-icon { + color: #2563eb; + } + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts new file mode 100644 index 000000000..bce8468a1 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts @@ -0,0 +1,117 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatIcon } from '@angular/material/icon'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Fixed Deposit Account Maturity Terms Step -- what happens to this account at maturity + * (Withdraw / Transfer to Savings / Reinvest) and the pre-mature-closure penalty, decided + * per account rather than silently inherited from the product. + */ +@Component({ + selector: 'mifosx-fixed-deposit-account-maturity-terms-step', + templateUrl: './fixed-deposit-account-maturity-terms-step.component.html', + styleUrls: ['./fixed-deposit-account-maturity-terms-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatIcon + ] +}) +export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges { + private formBuilder = inject(UntypedFormBuilder); + + @Input() fixedDepositsAccountTemplate: any; + @Input() fixedDepositsAccountProductTemplate: any; + /** Account already linked elsewhere in the wizard (the Details step's Deposit Account Link) -- + * pre-fills Transfer to Savings so the officer doesn't have to pick the same account twice. */ + @Input() linkedSavingsAccountId: number | null = null; + + closureSettingsForm: UntypedFormGroup; + maturityInstructionOptions: any[] = []; + savingsAccountsData: any[] = []; + preClosurePenalInterestOnTypeData: any[] = []; + + constructor() { + this.closureSettingsForm = this.formBuilder.group({ + maturityInstructionId: [''], + preClosurePenalApplicable: [false], + preClosurePenalInterest: [ + '', + Validators.min(0) + ], + preClosurePenalInterestOnTypeId: [''] + }); + this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => { + // 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both + // Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too. + if (value === 200) { + this.closureSettingsForm.addControl( + 'transferToSavingsId', + new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required) + ); + } else { + this.closureSettingsForm.removeControl('transferToSavingsId'); + } + }); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.linkedSavingsAccountId) { + const control = this.closureSettingsForm.get('transferToSavingsId'); + if (control && !control.value && this.linkedSavingsAccountId) { + control.setValue(this.linkedSavingsAccountId); + } + } + if (this.fixedDepositsAccountProductTemplate) { + // Withdraw Deposit (id 100) books the maturity proceeds out of the bank's cash/vault the moment + // the account matures, before the customer has actually collected it -- disabled everywhere. + this.maturityInstructionOptions = ( + this.fixedDepositsAccountProductTemplate.maturityInstructionOptions ?? [] + ).filter((option: any) => option.id !== 100); + this.savingsAccountsData = this.fixedDepositsAccountProductTemplate.savingsAccounts ?? []; + this.preClosurePenalInterestOnTypeData = + this.fixedDepositsAccountProductTemplate.preClosurePenalInterestOnTypeOptions ?? []; + if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) { + this.closureSettingsForm.patchValue({ + preClosurePenalApplicable: this.fixedDepositsAccountProductTemplate.preClosurePenalApplicable ?? false, + preClosurePenalInterest: this.fixedDepositsAccountProductTemplate.preClosurePenalInterest ?? '', + preClosurePenalInterestOnTypeId: + this.fixedDepositsAccountProductTemplate.preClosurePenalInterestOnType?.id ?? '' + }); + } + } + } + + /** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */ + get selectedTransferAccount(): any { + const id = this.closureSettingsForm.value.transferToSavingsId; + if (!id) return null; + return this.savingsAccountsData.find((account: any) => account.id === id) ?? null; + } + + clearTransferAccount(): void { + this.closureSettingsForm.get('transferToSavingsId')?.setValue(''); + } + + /** Chosen maturity/closure settings, ready to merge into the submit payload. */ + get closureSettings() { + const settings = this.closureSettingsForm.getRawValue(); + for (const key in settings) { + if (settings[key] === '') { + delete settings[key]; + } + } + return settings; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html index 7abd9466a..98644bdb4 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component.html @@ -683,6 +683,41 @@
+
+
+ + Maturity & Pre-mature Closure +
+
+
+
+ {{ 'labels.inputs.Maturity Instructions' | translate }} + {{ closureMaturityInstructionLabel }} +
+ @if (closureSettings?.transferToSavingsId) { +
+ {{ 'labels.inputs.Transfer to savings account' | translate }} + {{ closureTransferToSavingsLabel }} +
+ } +
+ {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + {{ closureSettings?.preClosurePenalApplicable | yesNo }} +
+ @if (closureSettings?.preClosurePenalApplicable) { +
+ {{ 'labels.inputs.Penal Interest' | translate }} (%) + {{ closureSettings?.preClosurePenalInterest }}% +
+
+ {{ 'labels.inputs.Period' | translate }} + {{ closurePenalInterestOnTypeLabel }} +
+ } +
+
+
+ @if (!hideButtons) {
{{ 'labels.inputs.Account No' | translate }} - + + @if (element.renewalCycleNo) { + Renewal {{ element.renewalCycleNo }} + }
+ + + + + + + + + + @for (period of schedule; track $index) { + + + + + + + } + + + + + + + + +
{{ 'labels.inputs.From Date' | translate }}{{ 'labels.inputs.To Date' | translate }}{{ 'labels.inputs.Interest Amount' | translate }}{{ 'labels.inputs.Status' | translate }}
{{ period.fromDate | dateFormat }}{{ period.toDate | dateFormat }} + {{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} + + @if (period.paid) { + + Paid + + } @else { + Pending + } +
{{ 'labels.inputs.Total Interest Payable' | translate }} + {{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} +
+
+
+ +
+ +
+ diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.scss new file mode 100644 index 000000000..f7784f675 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.scss @@ -0,0 +1,142 @@ +/** + * 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/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$green: #047857; +$green-bg: #ecfdf5; +$amber: #92400e; +$amber-bg: #fffbeb; + +.isd-dialog { + min-width: 560px; + max-width: 720px; +} + +.isd-header { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + + &__icon { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%); + color: #fff; + font-size: 13px; + flex-shrink: 0; + } + + &__text { + display: flex; + flex-direction: column; + } + + &__title { + font-size: 15px; + font-weight: 700; + color: $navy; + } + + &__sub { + font-size: 12px; + color: $muted; + } +} + +.isd-content { + max-height: 60vh; +} + +.isd-note { + margin: 4px 0 12px; + font-size: 12px; + color: $muted; + font-style: italic; +} + +.isd-scroll { + overflow-x: auto; +} + +.isd-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px dashed #eef2f7; + white-space: nowrap; + } + + th { + color: $slate; + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.03em; + background: $soft; + position: sticky; + top: 0; + } + + tbody tr:hover { + background: $soft; + } + + &__row--paid { + background: rgb(4 120 87 / 4%); + } + + tfoot td { + font-weight: 700; + color: $navy; + border-bottom: none; + border-top: 2px solid $border; + } + + &__amount-col { + text-align: right; + font-variant-numeric: tabular-nums; + } + + &__status-col { + text-align: center; + } +} + +.isd-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + + &--paid { + background: $green-bg; + color: $green; + } + + &--pending { + background: $amber-bg; + color: $amber; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts new file mode 100644 index 000000000..612cc78f3 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts @@ -0,0 +1,68 @@ +/** + * 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 { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { CurrencyPipe } from '@angular/common'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { DateFormatPipe } from '../../../../../pipes/date-format.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * FD Special's full month-wise interest schedule, in a popup rather than always inline on General -- + * a 24+ month term makes for a long table that would otherwise push every other card on the tab down + * the page. Each row also shows whether that period's interest has actually posted yet, not just what + * the engine projects it to be. + */ +@Component({ + selector: 'mifosx-interest-schedule-dialog', + templateUrl: './interest-schedule-dialog.component.html', + styleUrls: ['./interest-schedule-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatButton, + FaIconComponent, + CurrencyPipe, + DateFormatPipe + ] +}) +export class InterestScheduleDialogComponent { + dialogRef = inject>(MatDialogRef); + data: { + schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>; + currencyCode: string; + } = inject(MAT_DIALOG_DATA); + + get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> { + return this.data.schedule ?? []; + } + + get currencyCode(): string { + return this.data.currencyCode; + } + + get totalInterest(): number { + return this.schedule.reduce((sum, row) => sum + (row.interestAmount ?? 0), 0); + } + + get paidCount(): number { + return this.schedule.filter((row) => row.paid).length; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html new file mode 100644 index 000000000..659f79528 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html @@ -0,0 +1,191 @@ + + +
+
+ +
+

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

+

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

+
+
+ +
+ @if (loadingTemplate) { +
+ +
+ } @else if (loadFailed) { + + } @else { +
+ @if (reinvestCap !== null && reinvestCap !== undefined) { +
+ + {{ + remainderIsApproximate + ? 'Amount available on the selected date' + : ('labels.inputs.Maturity Amount' | translate) + }} + + {{ reinvestCap | formatNumber }} +
+ } + + + {{ 'labels.inputs.Closed On Date' | translate }} + + + + @if (paymentForm.controls.closedOnDate.hasError('required')) { + + {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + +
+ @for (closureOption of onAccountClosureOptions; track closureOption) { + + } +
+ @if ( + paymentForm.controls.onAccountClosureId.hasError('required') && + paymentForm.controls.onAccountClosureId.touched + ) { +
+ {{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} +
+ } + + @if (isPartialReinvest) { +
+ + {{ 'labels.inputs.Reinvest Amount' | translate }} + + @if (paymentForm.controls.reinvestAmount?.hasError('required')) { + + {{ 'labels.inputs.Reinvest Amount' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + @if (paymentForm.controls.reinvestAmount?.hasError('min')) { + {{ 'labels.inputs.Reinvest Amount must be greater than zero' | translate }} + } + @if (paymentForm.controls.reinvestAmount?.hasError('belowMaturityAmount')) { + {{ + 'labels.inputs.Reinvest Amount must be less than the maturity amount' | translate + }} + } + + +
+ {{ 'labels.inputs.Remainder to Transfer' | translate }} + {{ + remainderAmount !== null ? (remainderAmount | formatNumber) : '—' + }} +
+
+ @if (remainderIsApproximate) { +

+ + The selected date is before maturity. Interest up to that date is posted first, so the amount actually + transferred will be at least the figure shown. +

+ } + } + + @if (noDestinationAccounts) { + + } + + @if (paymentForm.contains('toSavingsAccountId')) { + + {{ 'labels.inputs.Transfer to Savings' | translate }} + + @for (account of savingsAccountsData; track account) { + + {{ account.accountNo }} + + } + + @if (paymentForm.controls.toSavingsAccountId?.hasError('required')) { + + {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + } + + @if (paymentForm.contains('transferDescription')) { + + {{ 'labels.inputs.Transfer Description' | translate }} + + + } + + + {{ 'labels.inputs.Note' | translate }} + + + + @if (submitError) { + + } +
+ } +
+ +
+ + +
+
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss new file mode 100644 index 000000000..48967437c --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss @@ -0,0 +1,296 @@ +/** + * 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/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$blue: #1e40af; +$blue-soft: #eff6ff; +$green: #166534; +$green-soft: #f0fdf4; + +.pmt { + display: flex; + flex-direction: column; + min-height: 320px; + max-height: 85vh; + overflow: hidden; + margin: -24px; +} + +.pmt__header { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 22px 28px 18px; + background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%); + color: #fff; + flex: 0 0 auto; +} + +.pmt__header-ico { + width: 40px; + height: 40px; + min-width: 40px; + border-radius: 10px; + background: rgb(255 255 255 / 15%); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.pmt__title { + margin: 0; + font-size: 19px; + font-weight: 700; +} + +.pmt__subtitle { + margin: 4px 0 0; + font-size: 12.5px; + color: rgb(255 255 255 / 82%); + line-height: 1.4; +} + +.pmt__content { + padding: 22px 28px 8px !important; + overflow-y: auto; + flex: 1 1 auto; +} + +.pmt__loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 260px; +} + +.pmt__stat { + display: flex; + flex-direction: column; + gap: 2px; + background: $blue-soft; + border: 1px solid #dbeafe; + border-radius: 10px; + padding: 12px 16px; + margin-bottom: 18px; +} + +.pmt__stat-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $blue; +} + +.pmt__stat-value { + font-size: 22px; + font-weight: 800; + color: $navy; + font-variant-numeric: tabular-nums; +} + +.pmt__date { + width: 100%; +} + +.pmt__section-label { + font-size: 11.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $slate; + margin: 4px 0 10px; +} + +.pmt__options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 10px; + margin-bottom: 6px; +} + +.pmt__option { + display: flex; + align-items: flex-start; + gap: 10px; + text-align: left; + background: #fff; + border: 1.5px solid $border; + border-radius: 10px; + padding: 12px 14px; + cursor: pointer; + font: inherit; + transition: + border-color 0.15s, + background 0.15s, + box-shadow 0.15s; +} + +.pmt__option:hover { + border-color: #93c5fd; + background: $blue-soft; +} + +.pmt__option--active { + border-color: $blue; + background: $blue-soft; + box-shadow: 0 0 0 1px $blue; +} + +.pmt__option-ico { + width: 30px; + height: 30px; + min-width: 30px; + border-radius: 8px; + background: $soft; + color: $blue; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; +} + +.pmt__option--active .pmt__option-ico { + background: $blue; + color: #fff; +} + +.pmt__option-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pmt__option-title { + font-size: 13px; + font-weight: 700; + color: $navy; +} + +.pmt__option-desc { + font-size: 11px; + color: $muted; + line-height: 1.35; +} + +.pmt__option:focus-visible { + outline: 2px solid $blue; + outline-offset: 2px; +} + +.pmt__option-check { + margin-left: auto; + color: $blue; + font-size: 13px; +} + +.pmt__option-error { + color: #b91c1c; + font-size: 12px; + margin: 4px 0 14px; +} + +.pmt__error { + display: flex; + align-items: flex-start; + gap: 4px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #b91c1c; + font-size: 12.5px; + line-height: 1.45; + padding: 10px 14px; + margin: 4px 0 8px; +} + +.pmt__hint { + display: flex; + align-items: flex-start; + gap: 4px; + background: $soft; + border: 1px solid $border; + border-radius: 8px; + color: $slate; + font-size: 12px; + line-height: 1.45; + padding: 9px 13px; + margin: 0 0 10px; +} + +.pmt__hint--warn { + background: #fffbeb; + border-color: #fde68a; + color: #92400e; +} + +.pmt__split { + display: flex; + align-items: center; + gap: 12px; + background: $soft; + border: 1px dashed #cbd5e1; + border-radius: 10px; + padding: 14px 16px 0; + margin: 4px 0 8px; + flex-wrap: wrap; +} + +.pmt__split-input { + flex: 1 1 180px; + min-width: 160px; +} + +.pmt__split-arrow { + color: $muted; + font-size: 14px; + margin-bottom: 20px; +} + +.pmt__split-remainder { + display: flex; + flex-direction: column; + gap: 2px; + background: $green-soft; + border: 1px solid #bbf7d0; + border-radius: 8px; + padding: 8px 14px; + margin-bottom: 14px; + flex: 1 1 180px; + min-width: 160px; +} + +.pmt__split-remainder-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $green; +} + +.pmt__split-remainder-value { + font-size: 16px; + font-weight: 800; + color: $green; + font-variant-numeric: tabular-nums; +} + +.pmt__content mat-form-field { + width: 100%; +} + +.pmt__actions { + padding: 14px 28px 20px !important; + border-top: 1px solid $border; + flex: 0 0 auto; +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts new file mode 100644 index 000000000..cd433f83c --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts @@ -0,0 +1,288 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { CdkTextareaAutosize } from '@angular/cdk/text-field'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn, Validators } from '@angular/forms'; + +/** Custom Services */ +import { FixedDepositsService } from '../../../fixed-deposits.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and + * Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice, + * Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the + * entered amount renews on this same account, the rest transfers to a savings account picked here. + */ +@Component({ + selector: 'mifosx-payment-dialog', + templateUrl: './payment-dialog.component.html', + styleUrls: ['./payment-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatDialogClose, + CdkTextareaAutosize, + MatProgressSpinnerModule, + FaIconComponent, + FormatNumberPipe + ] +}) +export class PaymentDialogComponent implements OnInit { + /** The `onAccountClosureId` codes each closure type is registered under on the backend. */ + private static readonly WITHDRAW_DEPOSIT_ID = 100; + private static readonly TRANSFER_TO_SAVINGS_ID = 200; + private static readonly REINVEST_FULL_ID = 300; + private static readonly REINVEST_PRINCIPAL_ID = 400; + private static readonly PARTIAL_REINVEST_ID = 500; + + private static readonly ACTION_ICONS: Record = { + [PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave', + [PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet', + [PaymentDialogComponent.REINVEST_FULL_ID]: 'sync-alt', + [PaymentDialogComponent.REINVEST_PRINCIPAL_ID]: 'piggy-bank', + [PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'arrows-alt-h' + }; + + private static readonly ACTION_DESCRIPTIONS: Record = { + [PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash', + [PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account', + [PaymentDialogComponent.REINVEST_FULL_ID]: 'Renew the full amount, same account number', + [PaymentDialogComponent.REINVEST_PRINCIPAL_ID]: + 'Renew only the principal; interest transferred to a savings account', + [PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'Renew part of it here; transfer the rest' + }; + + dialogRef = inject>(MatDialogRef); + data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA); + private formBuilder = inject(UntypedFormBuilder); + private fixedDepositsService = inject(FixedDepositsService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + paymentForm: UntypedFormGroup; + /** Loaded once, on open, from the same closure template the existing Close action already uses. */ + onAccountClosureOptions: any[] = []; + savingsAccountsData: any[] = []; + maturityAmount: number = null; + loadingTemplate = true; + loadFailed = false; + submitting = false; + submitError: string = null; + maxDate: Date; + + ngOnInit(): void { + this.maxDate = this.settingsService.businessDate; + this.paymentForm = this.formBuilder.group({ + closedOnDate: [ + '', + Validators.required + ], + onAccountClosureId: [ + '', + Validators.required + ], + note: [''] + }); + this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id)); + // The cap depends on whether the chosen date is on or after maturity, so a date change can make an + // already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date. + this.paymentForm + .get('closedOnDate') + .valueChanges.subscribe(() => + this.paymentForm.get('reinvestAmount')?.updateValueAndValidity({ emitEvent: false }) + ); + + this.fixedDepositsService.getFixedDepositsAccountPaymentTemplate(this.data.accountId as string).subscribe({ + next: (response: any) => { + // Withdraw Deposit books cash out of the vault immediately, which isn't appropriate for a + // staff-triggered Payment that may run well before or after the actual counter transaction -- + // Transfer to Savings or a Reinvest option covers every real case instead. + this.onAccountClosureOptions = (response.onAccountClosureOptions ?? []).filter( + (option: any) => option.id !== PaymentDialogComponent.WITHDRAW_DEPOSIT_ID + ); + this.savingsAccountsData = response.savingsAccounts ?? []; + this.maturityAmount = response.maturityAmount; + this.loadingTemplate = false; + }, + // Without this the spinner would run forever on a failed load, leaving Cancel as the only way out + // and no indication of why the form never appeared. + error: () => { + this.loadingTemplate = false; + this.loadFailed = true; + } + }); + } + + /** + * The amount being split, and the bound the entered partial amount is checked against. + *

+ * The server derives this from the account's balance AFTER it posts interest up to the chosen date, so which figure + * matches depends on that date. On or after maturity it posts the remaining full-rate interest, landing on the + * maturity amount. Before maturity it posts penalty-aware interest instead, which lands somewhere between the current + * balance and the maturity amount -- unknowable here, so the current balance is used as a floor that can never accept + * an amount the server would reject, and {@link remainderIsApproximate} tells the user the transfer will be larger. + */ + get reinvestCap(): number | null { + if (this.processingOnOrAfterMaturity) { + return this.maturityAmount ?? this.data.accountBalance ?? null; + } + return this.data.accountBalance ?? this.maturityAmount ?? null; + } + + /** Whether the chosen closure date is on or after the deposit's maturity date. */ + private get processingOnOrAfterMaturity(): boolean { + const chosen = this.paymentForm?.get('closedOnDate')?.value; + if (!chosen || !this.data.maturityDate) { + return false; + } + return this.toTime(chosen) >= this.toTime(this.data.maturityDate); + } + + /** Dates arrive here either as a Date from the picker or as the API's [yyyy, mm, dd] array. */ + private toTime(value: any): number { + if (value instanceof Date) { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); + } + if (Array.isArray(value)) { + const [ + year, + month, + day + ] = value; + return new Date(year, (month ?? 1) - 1, day ?? 1).getTime(); + } + return new Date(value).getTime(); + } + + /** True when the cap is the pre-maturity balance, so the figures shown are a floor rather than exact. */ + get remainderIsApproximate(): boolean { + return !this.processingOnOrAfterMaturity; + } + + iconFor(id: number): string { + return PaymentDialogComponent.ACTION_ICONS[id] ?? 'circle'; + } + + descriptionFor(id: number): string { + return PaymentDialogComponent.ACTION_DESCRIPTIONS[id] ?? ''; + } + + selectClosureType(id: number): void { + this.paymentForm.get('onAccountClosureId').setValue(id); + this.paymentForm.get('onAccountClosureId').markAsTouched(); + } + + /** Adds/removes the destination-account and partial-amount controls to match what the chosen type needs. */ + private onClosureTypeChange(id: number): void { + const needsDestinationAccount = + id === PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID || + id === PaymentDialogComponent.PARTIAL_REINVEST_ID || + id === PaymentDialogComponent.REINVEST_PRINCIPAL_ID; + if (needsDestinationAccount) { + if (!this.paymentForm.contains('toSavingsAccountId')) { + this.paymentForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required)); + this.paymentForm.addControl('transferDescription', new UntypedFormControl('')); + } + } else { + this.paymentForm.removeControl('toSavingsAccountId'); + this.paymentForm.removeControl('transferDescription'); + } + + if (id === PaymentDialogComponent.PARTIAL_REINVEST_ID) { + if (!this.paymentForm.contains('reinvestAmount')) { + this.paymentForm.addControl( + 'reinvestAmount', + new UntypedFormControl('', [ + Validators.required, + Validators.min(0.01), + this.belowMaturityAmountValidator() + ]) + ); + } + } else { + this.paymentForm.removeControl('reinvestAmount'); + } + } + + /** Mirrors the backend's own `0 < reinvestAmount < accountBalance` rule, so a bad amount fails before submit. */ + private belowMaturityAmountValidator(): ValidatorFn { + return (control) => { + const cap = Number(this.reinvestCap); + if (!cap || control.value === null || control.value === '') { + return null; + } + return Number(control.value) >= cap ? { belowMaturityAmount: true } : null; + }; + } + + get isPartialReinvest(): boolean { + return this.paymentForm?.get('onAccountClosureId')?.value === PaymentDialogComponent.PARTIAL_REINVEST_ID; + } + + /** True when the chosen action needs a destination account but the customer has none to pick. */ + get noDestinationAccounts(): boolean { + return ( + !this.loadingTemplate && this.paymentForm?.contains('toSavingsAccountId') && this.savingsAccountsData.length === 0 + ); + } + + /** What leaves this FD when the entered partial amount is kept behind; null until both figures are known. */ + get remainderAmount(): number | null { + const reinvestAmount = this.paymentForm?.get('reinvestAmount')?.value; + if (!this.isPartialReinvest || reinvestAmount === null || reinvestAmount === '' || !this.reinvestCap) { + return null; + } + return Number(this.reinvestCap) - Number(reinvestAmount); + } + + submit(): void { + const formValue = { ...this.paymentForm.value }; + const locale = this.settingsService.language.code; + const dateFormat = this.settingsService.dateFormat; + if (formValue.closedOnDate instanceof Date) { + formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat); + } + const data = { ...formValue, dateFormat, locale }; + this.submitting = true; + this.submitError = null; + this.fixedDepositsService + .executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data) + .subscribe({ + next: () => { + this.dialogRef.close({ confirm: true }); + }, + // Keep the dialog open with the entered values and say what went wrong, rather than leaving a + // permanently disabled Confirm behind whatever modal the global interceptor threw up. + error: (error: any) => { + this.submitting = false; + this.submitError = + error?.error?.defaultUserMessage ?? + error?.error?.errors?.[0]?.defaultUserMessage ?? + 'This payment could not be processed. Please review the details and try again.'; + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html index 55da78695..0ac452a8e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html @@ -37,7 +37,7 @@ {{ 'labels.inputs.Account Number' | translate }} @@ -79,6 +79,16 @@

+ @if (!fixedDepositsAccountData.status.rejected && !fixedDepositsAccountData.status.submittedAndPendingApproval) { + + } @@ -220,6 +230,19 @@ {{ 'labels.inputs.Transactions' | translate }} } + @if (fixedDepositsAccountData.renewalHistory?.length) { + + + {{ 'labels.inputs.Renewal History' | translate }} + + } { + if (response?.confirm) { + this.reload(); + } + }); + } + /** * Deletes Fixed Deposits Account. */ @@ -358,6 +388,20 @@ export class FixedDepositAccountViewComponent implements OnInit { }); } + /** + * Opens a printable deposit certificate for this account. + */ + printCertificate() { + this.dialog.open(DepositCertificateDialogComponent, { + width: '900px', + data: { + accountType: 'FD', + accountData: this.fixedDepositsAccountData, + clientId: this.fixedDepositsAccountData.clientId + } + }); + } + /** * Disables withhold tax for fixed deposits account * Fixed deposits endpoint is not supported so using Savings endpoint. diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts index 271987c9e..3146a730c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts @@ -116,6 +116,9 @@ export class FixedDepositsButtonsConfiguration { }, { name: 'Add Charge' + }, + { + name: 'Payment' } ]; break; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html index 9ddcc0a99..29491a554 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html @@ -83,6 +83,22 @@ }}
} + @if (fixedDepositsAccountData.reinvestedFromAccountNo) { + + } + @if (fixedDepositsAccountData.reinvestedIntoAccountNo) { +
+ {{ 'labels.inputs.Reinvested Into' | translate }} + + {{ fixedDepositsAccountData.reinvestedIntoAccountNo }} + +
+ } @@ -151,11 +167,36 @@
- {{ 'labels.inputs.Maturity Amount' | translate }} - + + {{ + (isSpecialFixedDeposit + ? 'labels.inputs.Principal Returned at Maturity' + : 'labels.inputs.Maturity Amount' + ) | translate + }} + + {{ fixedDepositsAccountData.maturityAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
+ @if (isSpecialFixedDeposit && nextInterestPayout !== null) { +
+ {{ 'labels.inputs.Next Interest Payout' | translate }} + + {{ nextInterestPayout | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } + @if (isSpecialFixedDeposit && totalValueAtMaturity !== null) { +
+ {{ + 'labels.inputs.Total Value at Maturity (Principal + Interest)' | translate + }} + + {{ totalValueAtMaturity | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } @if (fixedDepositsAccountData.summary.totalDeposits) {
{{ 'labels.inputs.Total Deposits' | translate }} @@ -205,13 +246,63 @@
- {{ 'labels.inputs.Maturity Amount' | translate }} + + {{ + (isSpecialFixedDeposit + ? 'labels.inputs.Principal Returned at Maturity' + : 'labels.inputs.Maturity Amount' + ) | translate + }} + {{ fixedDepositsAccountData.maturityAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
+ @if (isSpecialFixedDeposit && nextInterestPayout !== null) { +
+ {{ 'labels.inputs.Next Interest Payout' | translate }} + + {{ nextInterestPayout | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } + @if (isSpecialFixedDeposit && totalValueAtMaturity !== null) { +
+ {{ + 'labels.inputs.Total Value at Maturity (Principal + Interest)' | translate + }} + + {{ totalValueAtMaturity | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } } + +@if (isSpecialFixedDeposit && projectedInterestSchedule.length) { +
+
+ +

{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}

+
+
+
+ {{ 'labels.inputs.Total Interest Payable' | translate }} + + {{ totalProjectedInterest | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+
+ {{ 'labels.inputs.Periods Paid So Far' | translate }} + {{ paidPeriodsCount }} / {{ projectedInterestSchedule.length }} +
+ +
+
+} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss index f1b16485e..a205c1c24 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss @@ -118,3 +118,13 @@ $soft: #f8fafc; font-weight: 500; font-style: italic; } + +/* ─── Monthly Interest Payout Schedule summary (FD Special only) ─────── */ +.gt-schedule { + margin-top: 16px; + + &__view-btn { + width: 100%; + margin-top: 8px; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts index a6175a18d..083888a2f 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts @@ -8,12 +8,14 @@ import { Component, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; import { Currency } from 'app/shared/models/general.model'; import { CurrencyPipe } from '@angular/common'; import { ExternalIdentifierComponent } from '../../../../shared/external-identifier/external-identifier.component'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { InterestScheduleDialogComponent } from '../custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component'; @Component({ selector: 'mifosx-general-tab', @@ -30,6 +32,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class GeneralTabComponent { private route = inject(ActivatedRoute); private router = inject(Router); + private dialog = inject(MatDialog); fixedDepositsAccountData: any; entityType: string; @@ -48,4 +51,79 @@ export class GeneralTabComponent { this.entityType = 'Center'; } } + + /** + * A reinvested FD always keeps the same client/group/center as the account it came from, so the + * counterpart account's view page sits at the same route -- only the account-id segment differs. + */ + private siblingAccountLink(targetAccountId: number): string | null { + const segments = this.router.url.split('?')[0].split('/').filter(Boolean); + const idx = segments.indexOf('fixed-deposits-accounts'); + if (idx === -1) return null; + return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general'; + } + + get reinvestedFromLink(): string | null { + const id = this.fixedDepositsAccountData?.reinvestedFromAccountId; + return id ? this.siblingAccountLink(id) : null; + } + + get reinvestedIntoLink(): string | null { + const id = this.fixedDepositsAccountData?.reinvestedIntoAccountId; + return id ? this.siblingAccountLink(id) : null; + } + + /** FD Special never compounds -- every month's interest is transferred out to the linked savings + * account instead of growing this account's own balance, so its "Maturity Amount" is just the + * principal, not principal-plus-interest (see FixedDepositAccount#updateMaturityDateAndAmount). + */ + get isSpecialFixedDeposit(): boolean { + return this.fixedDepositsAccountData?.fdType === 'SPECIAL'; + } + + /** Real, engine-computed period-by-period interest schedule for FD Special (see FixedDepositAccountsApiResource#retrieveOne). */ + get projectedInterestSchedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> { + return this.fixedDepositsAccountData?.projectedInterestSchedule ?? []; + } + + get paidPeriodsCount(): number { + return this.projectedInterestSchedule.filter((period) => period.paid).length; + } + + /** Opens the full month-wise schedule in a popup rather than always inline -- a 24+ month term makes + * for a long table that would otherwise push every other card on this tab down the page. */ + openInterestSchedule(): void { + this.dialog.open(InterestScheduleDialogComponent, { + width: '680px', + data: { + schedule: this.projectedInterestSchedule, + currencyCode: this.currency?.code + } + }); + } + + /** Backend-computed sum of the schedule (see FixedDepositAccountsApiResource#retrieveOne) -- the single + * source of truth, not a client-side re-sum of the schedule array. */ + get totalProjectedInterest(): number | null { + return this.fixedDepositsAccountData?.totalProjectedInterest ?? null; + } + + /** + * The first NOT-YET-PAID period's real amount -- not just index 0, which would keep showing an + * already-posted-and-transferred period's amount as "next" forever once the account has any real + * history. Deliberately has NO rate/12 fallback: that would quietly present an inaccurate money figure + * as if it were the real one. If the schedule is missing, or every period is already paid, the row is + * hidden instead. + */ + get nextInterestPayout(): number | null { + return this.projectedInterestSchedule.find((period) => !period.paid)?.interestAmount ?? null; + } + + /** What the customer ends up with overall across the FD's whole life: the principal returned by the FD + * itself at maturity, PLUS every month's interest already paid out to the linked savings account along + * the way. Backend-computed (see FixedDepositAccountsApiResource#retrieveOne), same as maturityAmount + * itself, rather than derived here from the schedule array. */ + get totalValueAtMaturity(): number | null { + return this.fixedDepositsAccountData?.totalValueAtMaturity ?? null; + } } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html index 5e9de8301..bf81fc96f 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html @@ -36,6 +36,17 @@ {{ chartSlab.annualInterestRate | formatNumber }} % + + Senior Citizen + + {{ + chartSlab.seniorCitizenRate !== null && chartSlab.seniorCitizenRate !== undefined + ? (chartSlab.seniorCitizenRate | formatNumber) + ' %' + : '-' + }} + + + {{ 'labels.inputs.Description' | translate }} {{ chartSlab.description }} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts index 7934d41b5..f400aa9f4 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts @@ -71,6 +71,7 @@ export class InterestRateChartTabComponent { 'period', 'amountRange', 'interest', + 'seniorCitizen', 'description', 'actions' ]; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html new file mode 100644 index 000000000..3648e2ee1 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html @@ -0,0 +1,139 @@ + + +
+ @if (renewalHistory.length) { + +
+
+ FD Number + {{ fdNumber }} +
+
+ Terms So Far + {{ renewalHistory.length }} +
+
+ Original Principal + {{ originalPrincipal | currency: currency.code : 'symbol-narrow' }} +
+
+ Current Value + {{ currentValue | currency: currency.code : 'symbol-narrow' }} +
+
+ Lifetime Interest + {{ lifetimeInterestEarned | currency: currency.code : 'symbol-narrow' }} +
+
+ + +
+ @for ( + term of renewalHistory; + track term.historyid ?? term.accountid + '-' + term.renewalcycleno; + let last = $last + ) { +
+
+ + @if (term.renewalstatus === 'ACTIVE') { + + } + + @if (!last) { + + } +
+ +
+
+
+

+ {{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }} +

+ + + {{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }} + + @if (renewalTypeLabel(term)) { + + + {{ renewalTypeLabel(term) }} + + } +
+ +
+ +
+ + {{ term.activatedondate | dateFormat }} + + {{ term.maturitydate | dateFormat }} + @if (tenureLabel(term)) { + ({{ tenureLabel(term) }}) + } +
+ +
+
+ Principal + {{ term.depositamount | currency: currency.code : 'symbol-narrow' }} +
+
+ Rate + {{ term.interestrate }}% +
+
+ {{ + term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned' + }} + + {{ + term.interestearned === null || term.interestearned === undefined + ? '—' + : (term.interestearned | currency: currency.code : 'symbol-narrow') + }} + +
+
+ {{ + term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At' + }} + + {{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }} + +
+
+ +
+ + + Ledger account: + @if (term.accountid === fixedDepositsAccountData.id) { + {{ term.accountno }} + } @else { + {{ term.accountno }} + } + +
+
+
+ } +
+ } @else { +
+ +

{{ 'labels.messages.No Renewal History Available' | translate }}

+
+ } +
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss new file mode 100644 index 000000000..bb6e474cd --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss @@ -0,0 +1,364 @@ +/** + * 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/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$positive: #047857; +$positive-soft: #ecfdf5; +$accent: #2563eb; +$accent-soft: #eff6ff; + +.rh-container { + max-width: 860px; + margin: 0 auto; + padding: 4px 0 24px; +} + +/* ─── Summary strip ─────────────────────────────────────────────────── */ +.rh-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 1px; + background: $border; + border: 1px solid $border; + border-radius: 14px; + overflow: hidden; + margin-bottom: 28px; + box-shadow: 0 1px 3px rgb(15 23 42 / 4%); + + &__item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 16px; + background: #fff; + } + + &__label { + font-size: 10.5px; + font-weight: 600; + color: $muted; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__value { + font-size: 15px; + font-weight: 700; + color: $navy; + font-variant-numeric: tabular-nums; + } + + &__item--highlight { + background: $accent-soft; + + .rh-summary__value { + color: $accent; + } + } + + &__item--positive { + background: $positive-soft; + + .rh-summary__value { + color: $positive; + } + } +} + +@media (width <= 720px) { + .rh-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* ─── Timeline rail ──────────────────────────────────────────────────── */ +.rh-timeline { + display: flex; + flex-direction: column; +} + +.rh-node { + display: flex; + gap: 18px; +} + +.rh-node__rail { + flex: none; + width: 20px; + display: flex; + flex-direction: column; + align-items: center; +} + +.rh-node__dot { + position: relative; + width: 16px; + height: 16px; + margin-top: 22px; + border-radius: 50%; + background: #cbd5e1; + border: 3px solid #fff; + box-shadow: 0 0 0 2px #cbd5e1; + flex: none; + + &--active { + background: $positive; + box-shadow: 0 0 0 2px $positive; + } +} + +.rh-node__pulse { + position: absolute; + inset: -6px; + border-radius: 50%; + border: 2px solid $positive; + opacity: 0.6; + animation: rh-pulse 1.8s ease-out infinite; +} + +@keyframes rh-pulse { + 0% { + transform: scale(0.6); + opacity: 0.7; + } + + 100% { + transform: scale(1.6); + opacity: 0; + } +} + +.rh-node__line { + flex: 1; + width: 2px; + min-height: 24px; + background: $border; + margin: 4px 0; +} + +/* ─── Term card ──────────────────────────────────────────────────────── */ +.rh-card { + flex: 1; + min-width: 0; + background: #fff; + border: 1px solid $border; + border-radius: 14px; + box-shadow: 0 1px 3px rgb(15 23 42 / 4%); + padding: 16px 18px; + margin-bottom: 20px; + transition: box-shadow 0.15s ease; + + &--active { + border-color: rgb(4 120 87 / 20%); + box-shadow: 0 2px 10px rgb(4 120 87 / 8%); + } + + &:hover { + box-shadow: 0 4px 14px rgb(15 23 42 / 8%); + } + + &__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + } + + &__title-group { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: $navy; + } + + &__period { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + font-size: 12.5px; + color: $slate; + font-weight: 500; + + &-ico { + color: $muted; + font-size: 11px; + } + + &-arrow { + color: $muted; + font-size: 10px; + } + } + + &__tenure { + color: $muted; + font-weight: 400; + } + + &__foot { + display: flex; + align-items: center; + gap: 6px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px dashed $border; + font-size: 11.5px; + color: $muted; + + &-ico { + font-size: 10px; + } + + a { + color: $accent; + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +} + +/* ─── Status badge / auto-renewed tag ───────────────────────────────── */ +.rh-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + background: $soft; + color: $muted; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + + &--active { + background: $positive-soft; + color: $positive; + } +} + +.rh-tag { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + background: $accent-soft; + color: $accent; + font-size: 10.5px; + font-weight: 600; +} + +/* ─── View Rate Chart button ─────────────────────────────────────────── */ +.rh-chart-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 14px; + border: 1px solid $border; + border-radius: 999px; + background: #fff; + color: $accent; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + + &:hover { + background: $accent-soft; + border-color: $accent; + } +} + +/* ─── Stat grid ──────────────────────────────────────────────────────── */ +.rh-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; +} + +@media (width <= 640px) { + .rh-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.rh-stat { + display: flex; + flex-direction: column; + gap: 3px; + padding: 9px 12px; + background: $soft; + border-radius: 10px; + + &__label { + font-size: 10px; + font-weight: 600; + color: $muted; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + &__value { + font-size: 13.5px; + font-weight: 700; + color: $navy; + font-variant-numeric: tabular-nums; + + &--positive { + color: $positive; + } + } + + &--highlight { + background: $accent-soft; + + .rh-stat__value { + color: $accent; + } + } +} + +/* ─── Empty state ────────────────────────────────────────────────────── */ +.rh-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 64px 16px; + color: $muted; + + &__icon { + font-size: 30px; + opacity: 0.4; + } + + &__text { + margin: 0; + font-size: 13px; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts new file mode 100644 index 000000000..34bd1bb2b --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts @@ -0,0 +1,235 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { Currency } from 'app/shared/models/general.model'; +import { CurrencyPipe } from '@angular/common'; +import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ProductsService } from 'app/products/products.service'; +import { TermRateChartDialogComponent } from './term-rate-chart-dialog/term-rate-chart-dialog.component'; + +/** + * Every term of one FD, oldest first, as a vertical timeline -- one card per renewal cycle. Its own tab + * rather than a card on General, since a long-lived FD can carry many terms and each one's full detail + * (dates, rate, principal, interest, and its own rate chart) needs real room to read comfortably. + */ +@Component({ + selector: 'mifosx-renewal-history-tab', + templateUrl: './renewal-history-tab.component.html', + styleUrls: ['./renewal-history-tab.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + CurrencyPipe, + DateFormatPipe + ] +}) +export class RenewalHistoryTabComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private dialog = inject(MatDialog); + private productsService = inject(ProductsService); + + fixedDepositsAccountData: any; + currency: Currency; + + /** + * The bank's currently published senior-citizen premium per tenure band, for this FD's type. Fetched once + * from the same CBS Base Interest Rates screen staff maintain, so a term's rate-chart popup can show the + * premium alongside the standard rate -- neither the account's own chart snapshot nor the archived history + * carries a senior-citizen column, since that concept only exists on the base rate master. + */ + private activeBaseSlabs: any[] = []; + + constructor() { + this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => { + this.fixedDepositsAccountData = data.fixedDepositsAccountData; + this.currency = this.fixedDepositsAccountData.currency; + }); + } + + ngOnInit(): void { + const fdType = this.fixedDepositsAccountData?.fdType; + if (!fdType) { + return; + } + this.productsService.getFdBaseInterestRates(fdType).subscribe({ + next: (rates: any[]) => { + const active = (rates || []).find((r: any) => r.isActive); + this.activeBaseSlabs = active?.slabs ?? []; + }, + error: () => {} + }); + } + + /** + * The published senior-citizen rate for a tenure band, matched by period type and range against the active + * base rate card. Returns null when nothing matches (no published base version for this FD type, or this + * particular band isn't on it, or the matching band simply carries no premium) -- displayed as a blank + * rather than a misleading zero. + */ + private seniorCitizenRateFor(periodTypeEnum: number, fromPeriod: number, toPeriod: number | null): number | null { + const match = this.activeBaseSlabs.find( + (s) => + s.periodTypeEnum === periodTypeEnum && + s.fromPeriod === fromPeriod && + (s.toPeriod ?? null) === (toPeriod ?? null) + ); + return match?.seniorCitizenRate > 0 ? match.seniorCitizenRate : null; + } + + /** + * A reinvested FD always keeps the same client/group/center as the account it came from, so the + * counterpart account's view page sits at the same route -- only the account-id segment differs. + */ + private siblingAccountLink(targetAccountId: number): string | null { + const segments = this.router.url.split('?')[0].split('/').filter(Boolean); + const idx = segments.indexOf('fixed-deposits-accounts'); + if (idx === -1) return null; + return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general'; + } + + siblingLink(targetAccountId: number): string | null { + return this.siblingAccountLink(targetAccountId); + } + + /** + * Every term of this FD, oldest first, as resolved by the backend. Keys are lower-cased because they + * arrive straight from the SQL result-set column labels. + */ + get renewalHistory(): any[] { + return this.fixedDepositsAccountData?.renewalHistory ?? []; + } + + get fdNumber(): string { + return this.fixedDepositsAccountData?.fdAccountNumber ?? this.fixedDepositsAccountData?.accountNo; + } + + /** The very first term's opening principal -- what the customer originally deposited. */ + get originalPrincipal(): number | null { + return this.renewalHistory[0]?.depositamount ?? null; + } + + /** The latest term's settled or projected value -- what the FD is worth right now. */ + get currentValue(): number | null { + const latest = this.renewalHistory[this.renewalHistory.length - 1]; + return latest ? this.maturityFor(latest) : null; + } + + /** + * Total interest earned across every term, settled terms plus the running term's interest-to-date. Null + * only when there is nothing to sum, so the summary strip can tell "no data yet" apart from "zero". + */ + get lifetimeInterestEarned(): number | null { + if (!this.renewalHistory.length) { + return null; + } + let total = 0; + for (const term of this.renewalHistory) { + if (term.interestearned != null) { + total += Number(term.interestearned); + } else { + const projected = this.maturityFor(term); + if (projected != null && term.depositamount != null) { + total += Number(projected) - Number(term.depositamount); + } + } + } + return total; + } + + /** + * A term's settled maturity amount, or its projection while it is still running. Resolved here rather + * than inline so the choice isn't left to operator precedence between `??` and the currency pipe. + */ + maturityFor(term: any): number | null { + return term?.maturedamount ?? term?.projectedmaturityamount ?? null; + } + + /** periodTypeEnum values as archived -- same encoding as SavingsPeriodFrequencyType (DAYS=0 ... YEARS=3). */ + private static readonly PERIOD_TYPE_LABELS: Record = { + 0: 'Days', + 1: 'Weeks', + 2: 'Months', + 3: 'Years' + }; + + /** "10 Years" rather than a bare number, using the same period-type encoding the archived charts use. */ + tenureLabel(term: any): string { + if (term?.tenure == null) { + return ''; + } + const unit = RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[term.tenurefrequencyenum] ?? ''; + return unit ? `${term.tenure} ${unit}` : `${term.tenure}`; + } + + /** How the renewal happened, for display as a small tag -- null when the term hasn't renewed (yet). */ + renewalTypeLabel(term: any): string | null { + if (term?.renewaltype === 'AUTO') return 'Auto-Renewed'; + if (term?.renewaltype === 'MANUAL') return 'Renewed by Staff'; + return null; + } + + renewalTypeIcon(term: any): string { + return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot'; + } + + /** + * The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source. + * + * A term's chart is only archived once it closes via an in-place renewal, so the term still running has no + * archived rows -- for that one term (recognisable as the one matching this account's own id), the + * account's own live chart is shown instead, since it is the same data. The two sources use different key + * casing and represent the period type differently (a raw enum int here, an expanded {value} object on the + * live chart), which this maps into one consistent shape the dialog can render without knowing which source + * it came from. + */ + private chartSlabsFor(term: any): any[] { + const raw = term?.chartSlabs?.length + ? term.chartSlabs + : term?.accountid === this.fixedDepositsAccountData?.id + ? (this.fixedDepositsAccountData?.accountChart?.chartSlabs ?? []) + : []; + return raw.map((slab: any) => { + const periodTypeEnum = slab.periodType?.id ?? slab.periodtypeenum; + const fromPeriod = slab.fromperiod ?? slab.fromPeriod; + const toPeriod = slab.toperiod ?? slab.toPeriod; + // The archived/live row's own senior_citizen_rate is the true figure as published when this term + // was actually priced -- preferred whenever present. The live lookup against today's base rate + // card is only a fallback, for the currently-running term (not archived yet) or an older archive + // row written before that column existed -- flagged as such so the dialog can say so. + const persistedSeniorRate = slab.seniorcitizenrate ?? slab.seniorCitizenRate ?? null; + return { + description: slab.description, + fromPeriod, + toPeriod, + periodTypeLabel: slab.periodType?.value ?? RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[periodTypeEnum] ?? '', + annualInterestRate: slab.annualinterestrate ?? slab.annualInterestRate, + seniorCitizenRate: + persistedSeniorRate ?? this.seniorCitizenRateFor(periodTypeEnum, fromPeriod, toPeriod ?? null), + seniorCitizenRateIsLive: persistedSeniorRate == null + }; + }); + } + + /** Opens the full rate chart for one term in a popup, rather than pushing every later term down the page. */ + openRateChart(term: any): void { + this.dialog.open(TermRateChartDialogComponent, { + width: '560px', + data: { + term, + slabs: this.chartSlabsFor(term), + isSeniorCitizen: !!this.fixedDepositsAccountData?.isSeniorCitizen + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html new file mode 100644 index 000000000..7eadb1179 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html @@ -0,0 +1,77 @@ + + +
+

+ + + {{ termLabel }} — Rate Chart + {{ term.activatedondate | dateFormat }} → {{ term.maturitydate | dateFormat }} + +

+ +
+ @if (slabs.length) { + + + + + + + + + + + @for (slab of slabs; track $index) { + + + + + + + } + +
DescriptionTenureRate + + Senior Citizen +
{{ slab.description }}{{ slab.fromPeriod }} – {{ slab.toPeriod || '∞' }} {{ slab.periodTypeLabel }} + {{ slab.annualInterestRate }}% + @if (isAppliedSlab(slab)) { + Applied + } + + {{ + slab.seniorCitizenRate !== null && slab.seniorCitizenRate !== undefined + ? slab.seniorCitizenRate + '%' + : '—' + }} + @if (isAppliedSeniorSlab(slab)) { + Applied + } +
+ @if (hasLiveSeniorRate) { +

+ + Senior citizen rate for this term wasn't recorded at the time; the figure shown is today's published rate, for + reference. +

+ } + } @else { +
+ +

No rate chart was archived for this term.

+
+ } +
+ +
+ +
+
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss new file mode 100644 index 000000000..a0f4ec851 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss @@ -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/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$positive: #047857; +$positive-soft: #ecfdf5; + +$accent: #b8860b; + +.trc-dialog { + min-width: 420px; + max-width: 540px; +} + +.trc-header { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 4px; + + &__icon { + flex: none; + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%); + color: #fff; + font-size: 15px; + } + + &__text { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__title { + font-size: 15px; + font-weight: 700; + color: $navy; + line-height: 1.3; + } + + &__sub { + font-size: 12px; + color: $muted; + font-weight: 500; + } +} + +.trc-content { + padding-top: 4px; +} + +.trc-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + padding: 8px 10px; + background: $soft; + color: $muted; + font-weight: 600; + text-align: left; + border-bottom: 1px solid $border; + } + + td { + padding: 9px 10px; + color: $slate; + border-bottom: 1px solid $border; + font-variant-numeric: tabular-nums; + } + + &__rate-col { + text-align: right; + } + + &__row--applied { + background: $positive-soft; + + td { + color: $positive; + font-weight: 700; + } + } + + &__sc-ico { + color: $accent; + margin-right: 4px; + } + + &__sc-col { + color: $accent; + font-weight: 600; + } +} + +.trc-applied-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 10px; + background: $positive; + color: #fff; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + vertical-align: middle; +} + +.trc-note { + display: flex; + align-items: flex-start; + gap: 6px; + margin: 10px 2px 0; + color: $muted; + font-size: 11px; + font-style: italic; + line-height: 1.4; +} + +.trc-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 32px 8px; + color: $muted; + text-align: center; + + &__icon { + font-size: 22px; + opacity: 0.5; + } + + p { + margin: 0; + font-size: 13px; + } +} + +.trc-actions { + padding-top: 6px; +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts new file mode 100644 index 000000000..3ce7957f9 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts @@ -0,0 +1,93 @@ +/** + * 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 { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * The full rate-by-tenure chart one term of an FD was priced on, in a popup rather than inline in the + * timeline: a chart can run to several tenure bands, and showing it in place would push every later term + * down the page every time one is opened. + */ +@Component({ + selector: 'mifosx-term-rate-chart-dialog', + templateUrl: './term-rate-chart-dialog.component.html', + styleUrls: ['./term-rate-chart-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatButton, + FaIconComponent + ] +}) +export class TermRateChartDialogComponent { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + get term(): any { + return this.data.term; + } + + get slabs(): any[] { + return this.data.slabs ?? []; + } + + get termLabel(): string { + return this.term?.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + this.term?.renewalcycleno; + } + + /** Whether the DEPOSITOR (not just the tenure band) was a senior citizen for this term. */ + get isSeniorCitizen(): boolean { + return !!this.data.isSeniorCitizen; + } + + /** + * True when at least one senior-citizen figure shown is a live lookup against today's base rate card rather than the + * true rate persisted for this specific term -- only possible for the currently-running term (not archived yet) or an + * older archive row that predates the persisted column. Drives whether the disclaimer note is shown at all. + */ + get hasLiveSeniorRate(): boolean { + return this.slabs.some((s) => s.seniorCitizenRateIsLive && s.seniorCitizenRate != null); + } + + /** + * True for the STANDARD-rate cell that was actually applied to this term. Never true for a senior-citizen + * term, since that term was priced off the senior column instead -- see {@link isAppliedSeniorSlab}. + * Compared as numbers since one side arrives as a string from the currency-agnostic archive row. + */ + isAppliedSlab(slab: any): boolean { + return ( + !this.isSeniorCitizen && + this.term?.interestrate != null && + Number(slab.annualInterestRate) === Number(this.term.interestrate) + ); + } + + /** True for the SENIOR-CITIZEN cell that was actually applied, for a term held by a senior-citizen depositor. */ + isAppliedSeniorSlab(slab: any): boolean { + return ( + this.isSeniorCitizen && + slab.seniorCitizenRate != null && + this.term?.interestrate != null && + Number(slab.seniorCitizenRate) === Number(this.term.interestrate) + ); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html index ae4dafdf5..6e447a59c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html @@ -36,7 +36,11 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ transaction.id }} + @if (transaction.isRenewalEvent) { + + } @else { + {{ transaction.id }} + }
@@ -72,7 +76,13 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }} + {{ + transaction.isRenewalEvent + ? '—' + : isDebit(transaction.transactionType) + ? (transaction.amount | formatNumber) + : 'N/A' + }} @@ -84,7 +94,13 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ !isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }} + {{ + transaction.isRenewalEvent + ? '—' + : !isDebit(transaction.transactionType) + ? (transaction.amount | formatNumber) + : 'N/A' + }} @@ -96,36 +112,40 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ transaction.runningBalance | formatNumber }} + {{ transaction.isRenewalEvent ? '—' : (transaction.runningBalance | formatNumber) }} {{ 'labels.inputs.Actions' | translate }} - - - - @if (!isFirstTransaction(idx)) { - - } - - + @if (!isFirstTransaction(idx)) { + + } + + + } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss index 44062e84c..57358a351 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss @@ -97,4 +97,32 @@ table { color: $muted; font-style: italic; } + + .mat-mdc-cell.renewal-marker { + background: #eff6ff; + color: $blue; + font-style: italic; + cursor: default; + } + + .mat-mdc-row:has(.renewal-marker):hover { + background: #eff6ff; + cursor: default; + } +} + +.txn-tab__renewal-ico { + color: $blue; +} + +.txn-tab__renewal-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + background: #dbeafe; + color: $blue; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts index 5caa3e47e..07691eaa6 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts @@ -113,13 +113,71 @@ export class TransactionsTabComponent implements OnInit { */ constructor() { this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => { - this.transactionsData = data.fixedDepositsAccountData.transactions; + this.transactionsData = this.withRenewalMarkers( + data.fixedDepositsAccountData.transactions, + data.fixedDepositsAccountData.renewalHistory + ); this.clientId = this.route.parent.snapshot.params['clientId']; this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId']; this.status = data.fixedDepositsAccountData.status.value; }); } + /** + * An in-place renewal moves no money -- the matured balance simply becomes the new term's opening + * principal -- so it posts no withdrawal, deposit, or journal entry (see + * DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure). Without something here, a renewal + * would be invisible in this list even though the account's rate and term just changed. These rows + * are synthesized purely for display from the renewal history already loaded for the Renewal History + * tab -- never written anywhere, never matched against a real transaction id -- so undo, journal-entry + * lookup, and every real transaction's running balance stay exactly as they were. + */ + private withRenewalMarkers(transactions: any[], renewalHistory: any[]): any[] { + const markers: any[] = (renewalHistory ?? []) + .filter((term: any) => term.renewalcycleno > 0) + .map((term: any) => { + const marker: any = { + isRenewalEvent: true, + id: null, + date: term.activatedondate, + amount: null, + runningBalance: null, + reversed: false, + transfer: null, + renewalCycleNo: term.renewalcycleno, + transactionType: { + value: term.renewaltype === 'MANUAL' ? 'FD Renewed (Staff)' : 'FD Auto-Renewed', + accrual: false, + withdrawal: false, + feeDeduction: false, + overdraftInterest: false, + withholdTax: false + } + }; + return marker; + }); + return [ + ...(transactions ?? []), + ...markers + ].sort((a, b) => this.toEpoch(b.date) - this.toEpoch(a.date)); + } + + /** Same [yyyy, MM, dd]-or-ISO-string shape DateFormatPipe already handles, resolved to a sortable epoch. */ + private toEpoch(value: any): number { + if (!value) { + return 0; + } + if (Array.isArray(value)) { + const [ + year, + month, + day + ] = value; + return new Date(year, (month ?? 1) - 1, day ?? 1).getTime(); + } + return new Date(value).getTime(); + } + ngOnInit() { this.hideAccrualsParam = new UntypedFormControl(false); this.hideReversedParam = new UntypedFormControl(false); @@ -161,6 +219,9 @@ export class TransactionsTabComponent implements OnInit { * @param {any} transactionsData Transactions Data */ showTransactions(transactionsData: any) { + if (transactionsData.isRenewalEvent) { + return; + } if (transactionsData.transfer) { this.router.navigate([`account-transfers/account-transfers/${transactionsData.transfer.id}`], { relativeTo: this.route @@ -191,7 +252,10 @@ export class TransactionsTabComponent implements OnInit { this.dataSource.sort = this.sort; } - savingsTransactionColor(transaction: SavingsAccountTransaction): string { + savingsTransactionColor(transaction: any): string { + if (transaction.isRenewalEvent) { + return 'renewal-marker'; + } if (transaction.reversed) { return 'strike'; } else if (transaction.transfer) { diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.html b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.html index 81998d8c9..5547c958e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.html @@ -10,6 +10,7 @@
+

This approves and activates the account in one step -- it goes straight to Active.

{{ 'labels.inputs.Approved On Date' | translate }} diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts index a7ae6c9b2..a517f98b5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/approve-fixed-deposits-account/approve-fixed-deposits-account.component.ts @@ -81,8 +81,9 @@ export class ApproveFixedDepositsAccountComponent implements OnInit { } /** - * Submits the form and approves the fixed deposit account, - * if successful redirects to the fixed deposit account. + * Submits the form, approves the fixed deposit account, then immediately activates it on the + * same date -- Approve and Activate are still two separate Fineract commands underneath (each + * shows up in the audit trail), but the officer only sees and clicks through one step. */ submit() { const approveFixedDepositsAccountFormData = this.approveFixedDepositsAccountForm.value; @@ -98,7 +99,16 @@ export class ApproveFixedDepositsAccountComponent implements OnInit { locale }; this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => { - this.router.navigate(['../../'], { relativeTo: this.route }); + const activationData = { + activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate, + dateFormat, + locale + }; + this.fixedDepositsService + .executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData) + .subscribe(() => { + this.router.navigate(['../../'], { relativeTo: this.route }); + }); }); } } diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts b/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts index 68861fff9..eb2f6e5c5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts @@ -31,6 +31,7 @@ import { FixedDepositsAccountActionsResolver } from './common-resolvers/fixed-de import { FixedDepositsAccountTemplateResolver } from './common-resolvers/fixed-deposit-account-template.resolver'; import { FixedDepositsAccountAndTemplateResolver } from './common-resolvers/fixed-deposit-account-and-template.resolver'; import { GeneralTabComponent } from './fixed-deposit-account-view/general-tab/general-tab.component'; +import { RenewalHistoryTabComponent } from './fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component'; import { AccountNomineesTabComponent } from 'app/shared/account-nominees-tab/account-nominees-tab.component'; const routes: Routes = [ @@ -80,6 +81,15 @@ const routes: Routes = [ routeParamBreadcrumb: false } }, + { + path: 'renewal-history', + component: RenewalHistoryTabComponent, + data: { + title: 'Fixed Deposit Account Renewal History', + breadcrumb: 'Renewal History', + routeParamBreadcrumb: false + } + }, { path: 'transactions', component: TransactionsTabComponent, diff --git a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts index 6d20ed276..dd82ede92 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts @@ -87,6 +87,17 @@ export class FixedDepositsService { return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams }); } + /** + * Template for the Payment action. Same shape as the closure template, but its `onAccountClosureOptions` also + * include Partial Re-Invest, which the closure commands exclude because only Payment implements it. + * @param {string} accountId Fixed Deposits Account Id + * @returns {Observable} + */ + getFixedDepositsAccountPaymentTemplate(accountId: string): Observable { + const httpParams = new HttpParams().set('command', 'payment'); + return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams }); + } + /** * @param {string} accountId Fixed Deposits Account Id * @returns {Observable} diff --git a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.html b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.html index d4f2d0b6c..7b55c02bd 100644 --- a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.html +++ b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.html @@ -33,6 +33,7 @@ @@ -111,7 +112,40 @@
- + + + + + Terms + +
+ @if (recurringDepositsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + @@ -132,6 +166,7 @@ [rdDaysInYear]="rdDaysInYear" [rdAppliedRate]="rdAppliedRate" [rdIsSeniorCitizen]="rdIsSeniorCitizen" + [closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}" [hideButtons]="true" (submitEvent)="submit()" > diff --git a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts index 8ab2a000c..d68fd0229 100644 --- a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts @@ -24,6 +24,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { RecurringDepositsAccountCurrencyStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component'; import { RecurringDepositsAccountInterestRateChartStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component'; import { RecurringDepositsAccountPreviewStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component'; +import { RecurringDepositsAccountMaturityTermsStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; RecurringDepositsAccountDetailsStepComponent, RecurringDepositsAccountCurrencyStepComponent, RecurringDepositsAccountInterestRateChartStepComponent, + RecurringDepositsAccountMaturityTermsStepComponent, RecurringDepositsAccountPreviewStepComponent ] }) @@ -61,6 +63,12 @@ export class CreateRecurringDepositsAccountComponent { @ViewChild(RecurringDepositsAccountInterestRateChartStepComponent) recurringDepositsAccountChartStep: RecurringDepositsAccountInterestRateChartStepComponent; + @ViewChild(RecurringDepositsAccountPreviewStepComponent) + recurringDepositsAccountPreviewStep: RecurringDepositsAccountPreviewStepComponent; + + @ViewChild(RecurringDepositsAccountMaturityTermsStepComponent) + recurringDepositsAccountMaturityTermsStep: RecurringDepositsAccountMaturityTermsStepComponent; + /** Recurring Deposits Account Template */ recurringDepositsAccountTemplate: any; /** Recurring Deposit Account Product Template */ @@ -68,7 +76,7 @@ export class CreateRecurringDepositsAccountComponent { /** Currently visible wizard tab (0-indexed). */ selectedTabIndex = 0; /** Total wizard tabs — Product, Currency, Chart, Preview. */ - private readonly totalTabs = 4; + private readonly totalTabs = 5; /** Active CBS base interest rate versions */ cbsBaseRates: any[] = []; /** Selected tenure value and frequency from calculator */ @@ -258,6 +266,7 @@ export class CreateRecurringDepositsAccountComponent { ...details, ...terms, ...settings, + ...(this.recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}), ...productDefaults, clientId: this.recurringDepositsAccountTemplate.clientId, mandatoryRecommendedDepositAmount: pt?.minDepositAmount ?? 0, diff --git a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.html b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.html index f3d7d5225..e03d3542d 100644 --- a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.html +++ b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.html @@ -35,6 +35,7 @@ @@ -115,7 +116,40 @@ - + + + + + Terms + +
+ @if (recurringDepositsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + @@ -136,6 +170,7 @@ [rdDaysInYear]="rdDaysInYear" [rdAppliedRate]="rdAppliedRate" [rdIsSeniorCitizen]="rdIsSeniorCitizen" + [closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}" [hideButtons]="true" (submitEvent)="submit()" > diff --git a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts index 4076b4661..7b5bb33f3 100644 --- a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts +++ b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts @@ -25,6 +25,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { RecurringDepositsAccountCurrencyStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component'; import { RecurringDepositsAccountInterestRateChartStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component'; import { RecurringDepositsAccountPreviewStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component'; +import { RecurringDepositsAccountMaturityTermsStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -41,6 +42,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; RecurringDepositsAccountDetailsStepComponent, RecurringDepositsAccountCurrencyStepComponent, RecurringDepositsAccountInterestRateChartStepComponent, + RecurringDepositsAccountMaturityTermsStepComponent, RecurringDepositsAccountPreviewStepComponent ] }) @@ -63,13 +65,19 @@ export class EditRecurringDepositAccountComponent { @ViewChild(RecurringDepositsAccountInterestRateChartStepComponent) recurringDepositsAccountChartStep: RecurringDepositsAccountInterestRateChartStepComponent; + @ViewChild(RecurringDepositsAccountPreviewStepComponent) + recurringDepositsAccountPreviewStep: RecurringDepositsAccountPreviewStepComponent; + + @ViewChild(RecurringDepositsAccountMaturityTermsStepComponent) + recurringDepositsAccountMaturityTermsStep: RecurringDepositsAccountMaturityTermsStepComponent; + /** Full account data + template from resolver */ recurringDepositsAccountAndTemplate: any; /** Product template emitted by details step */ recurringDepositsAccountProductTemplate: any; /** Currently visible wizard tab (0-indexed). */ selectedTabIndex = 0; - private readonly totalTabs = 4; + private readonly totalTabs = 5; /** Active CBS base interest rate versions */ cbsBaseRates: any[] = []; /** Selected tenure value and frequency from calculator */ @@ -277,6 +285,7 @@ export class EditRecurringDepositAccountComponent { ...details, ...terms, ...settings, + ...(this.recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}), ...productDefaults, clientId: this.recurringDepositsAccountAndTemplate.clientId, mandatoryRecommendedDepositAmount: pt?.minDepositAmount ?? 0, diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.html index 381399099..1519ff51f 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.html @@ -10,6 +10,7 @@ +

This approves and activates the account in one step -- it goes straight to Active.

{{ 'labels.inputs.Approved On Date' | translate }} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts index 8dc8c427c..847aa80e5 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts @@ -81,8 +81,9 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit { } /** - * Submits the form and approves the recurring deposit account, - * if successful redirects to the recurring deposit account. + * Submits the form, approves the recurring deposit account, then immediately activates it on + * the same date -- Approve and Activate are still two separate Fineract commands underneath + * (each shows up in the audit trail), but the officer only sees and clicks through one step. */ submit() { const approveRecurringDepositsAccountFormData = this.approveRecurringDepositsAccountForm.value; @@ -103,7 +104,16 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit { this.recurringDepositsService .executeRecurringDepositsAccountCommand(this.accountId, 'approve', data) .subscribe(() => { - this.router.navigate(['../../'], { relativeTo: this.route }); + const activationData = { + activatedOnDate: approveRecurringDepositsAccountFormData.approvedOnDate, + dateFormat, + locale + }; + this.recurringDepositsService + .executeRecurringDepositsAccountCommand(this.accountId, 'activate', activationData) + .subscribe(() => { + this.router.navigate(['../../'], { relativeTo: this.route }); + }); }); } } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html index 702defd3a..5fd042184 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html @@ -613,7 +613,7 @@ Age - Must be below 18 years + Must be below {{ minorAgeThreshold }} years diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts index 6ba4bea37..18035cd4b 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts @@ -7,7 +7,17 @@ */ /** Angular Imports */ -import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core'; +import { + Component, + OnInit, + OnChanges, + SimpleChanges, + Input, + Output, + EventEmitter, + inject, + ChangeDetectorRef +} from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; @@ -16,6 +26,12 @@ import { MatDialog } from '@angular/material/dialog'; /** Custom Services */ import { RecurringDepositsService } from '../../recurring-deposits.service'; import { ClientsService } from 'app/clients/clients.service'; +import { + AGE_OF_MAJORITY, + MINOR_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; import { MatTooltip } from '@angular/material/tooltip'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -43,7 +59,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc IntroducerSearchComponent ] }) -export class RecurringDepositsAccountDetailsStepComponent implements OnInit { +export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges { private formBuilder = inject(UntypedFormBuilder); private recurringDepositsService = inject(RecurringDepositsService); private settingsService = inject(SettingsService); @@ -56,6 +72,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit { /** Recurring Deposits Account Template */ @Input() recurringDepositsAccountTemplate: any; + /** Linked-account id picked elsewhere in the wizard (e.g. the Terms step's Transfer to Savings + * account) -- pre-fills this card so the officer doesn't have to pick the same account twice. */ + @Input() externalLinkedAccountId: number | null = null; + /** Minimum date allowed. */ minDate = new Date(2000, 0, 1); /** Maximum date allowed. */ @@ -83,8 +103,16 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit { selectedCustomerSegment: string | null = null; // ── Client info (auto-fetched) ───────────────────────────────────────── + /** + * The client's legal form ("Individual" / "Non-Individual") as captured at client creation. Customer + * Segment is derived from this rather than picked by hand, so an account can never disagree with the + * CIF about what kind of customer it belongs to. + */ + clientLegalForm: string | null = null; clientDob: Date | null = null; clientAge: number | null = null; + /** Exposed for tooltips so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; clientGender: string | null = null; primaryClientName = ''; primaryClientCif = ''; @@ -198,6 +226,23 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit { this.dalSearch = ''; } + ngOnChanges(changes: SimpleChanges): void { + if (!changes.externalLinkedAccountId || this.selectedLinkedAccount) { + return; + } + const id = this.externalLinkedAccountId; + if (!id) return; + const savings = this.dalAllSavings.find((a: any) => a.id === id); + const current = this.dalAllCurrent.find((a: any) => a.id === id); + if (savings) { + this.dalSelectedType = 'SAVINGS'; + this.selectedLinkedAccount = savings; + } else if (current) { + this.dalSelectedType = 'CURRENT'; + this.selectedLinkedAccount = current; + } + } + private loadClientAccounts(): void { const clientId = String(this.recurringDepositsAccountTemplate?.clientId); if (!clientId) return; @@ -306,6 +351,8 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit { this.autoDetectAccountStatus(); } } + this.clientLegalForm = client?.legalForm?.value ?? client?.legalForm?.name ?? null; + this.applyInferredCustomerSegment(); this.cdr.detectChanges(); }); } @@ -333,16 +380,16 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit { } isTileDisabled(value: string): boolean { - if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; - if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; + if (value === 'MINOR') return !isMinorAge(this.clientAge); + if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge); return false; } getTileTooltip(value: string): string { - if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) { - return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`; + if (value === 'MINOR' && !isMinorAge(this.clientAge)) { + return `Age ${this.clientAge} yrs — Minor only allowed below years`; } - if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) { + if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) { return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`; } if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age'; @@ -355,10 +402,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit { return; } if (this.clientAge === null) return; - if (this.clientAge < 18) { + if (isMinorAge(this.clientAge)) { this.selectedAccountStatus = 'MINOR'; this.minorDob = this.clientDob; - } else if (this.clientAge >= 60) { + } else if (isSeniorCitizenAge(this.clientAge)) { this.selectedAccountStatus = 'SENIOR_CITIZEN'; } else if (this.clientGender === 'female') { this.selectedAccountStatus = 'INDIVIDUAL_FEMALE'; @@ -410,6 +457,9 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit { this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment); this.selectedAccountType = response.accountType ?? null; this.selectedCustomerSegment = response.customerSegment ?? null; + // The product default above is only a fallback -- the client's own legal form wins. Re-applied + // here as well as in the client callback because the two responses can arrive in either order. + this.applyInferredCustomerSegment(); this.recurringDepositsAccountProductTemplate.emit(response); this.fieldOfficerData = response.fieldOfficerOptions; this.isProductSelected = true; @@ -526,6 +576,24 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit { return fallback ? [fallback] : []; } + /** + * Selects the Customer Segment that matches the client's legal form. The segment code values + * ("Individual" / "Non-Individual") are seeded to be byte-identical to the legal-form labels, so this + * is an exact, case-insensitive comparison on purpose -- a substring match would resolve + * "Non-Individual" to the "Individual" option, since one contains the other. Leaves the product + * default untouched when the client's form is unknown or has no matching option. + */ + private applyInferredCustomerSegment(): void { + if (!this.clientLegalForm || !this.customerSegmentOptions.length) { + return; + } + const target = this.clientLegalForm.trim().toLowerCase(); + const match = this.customerSegmentOptions.find((opt) => String(opt).trim().toLowerCase() === target); + if (match) { + this.selectedCustomerSegment = match; + } + } + formatTemplateDate(value: any): string { if (!value) return ''; let dateValue: Date | null = null; @@ -664,7 +732,7 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit { const dob = this.minorDob ?? this.clientDob; if (!dob) return ''; const d = new Date(dob); - d.setFullYear(d.getFullYear() + 18); + d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY); return this.dateUtils.formatDate(d, 'M/d/yyyy') || ''; } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html index 7fa51c3e8..237abd8dc 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html @@ -110,12 +110,12 @@
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts index 46cf19aa6..554d9d1ee 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts @@ -6,8 +6,21 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, Input, OnChanges, SimpleChanges, ViewChild, Output, EventEmitter, inject } from '@angular/core'; +import { + Component, + Input, + OnChanges, + SimpleChanges, + ViewChild, + Output, + EventEmitter, + ChangeDetectorRef, + inject +} from '@angular/core'; import { RecurringDepositsService } from '../../recurring-deposits.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util'; import { MatTable, MatColumnDef, @@ -142,7 +155,18 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O 'actions' ]; + /** + * Derived from the client's own date of birth -- never staff-editable, so a client who is not + * actually 60+ can never be priced at the senior-citizen rate by a mis-ticked box. + */ isSeniorCitizen = false; + readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD; + /** Client age in completed years, or null when the DOB is unknown / the client is an entity. */ + clientAge: number | null = null; + /** False for a company/entity client, which has no age and so can never qualify. */ + clientIsIndividual = true; + /** True once the client lookup has come back, so the UI can explain the locked checkbox honestly. */ + clientAgeResolved = false; incentivesDisplayedColumns: string[] = [ 'entityType', 'attributeName', @@ -172,8 +196,14 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O ]; private recurringDepositsService = inject(RecurringDepositsService); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + private cdr = inject(ChangeDetectorRef); ngOnChanges(changes: SimpleChanges) { + if (changes['recurringDepositsAccountTemplate']) { + this.resolveSeniorCitizenFromClient(); + } // Only reset/re-apply when the TEMPLATE or cbsBaseRates changes, NOT when initialTenure changes alone const templateChanged = changes['recurringDepositsAccountProductTemplate'] || @@ -201,10 +231,9 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O if (this.initialTenure.depositAmount) { this.principalAmount = this.initialTenure.depositAmount; } - // Pre-check senior citizen if it was set during create - if (this.initialTenure.isSeniorCitizen) { - this.isSeniorCitizen = true; - } + // Deliberately NOT restoring initialTenure.isSeniorCitizen: the flag is derived from the client's + // date of birth in resolveSeniorCitizenFromClient(). Re-applying a stored value here would carry a + // previously mis-set flag forward on every edit. // Auto-calculate when tenure is pre-filled (edit mode) setTimeout(() => this.calculateRate(), 0); } @@ -359,6 +388,45 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O this.validationWarning = null; } + /** + * Derives the senior-citizen flag from the client's date of birth. Runs whenever the account template + * arrives (create and edit alike), so the flag always reflects the customer on record rather than + * whatever was ticked -- or previously saved -- by hand. + */ + private resolveSeniorCitizenFromClient(): void { + const clientId = this.recurringDepositsAccountTemplate?.clientId ?? this.recurringDepositsAccountTemplate?.id; + if (!clientId) { + return; + } + this.clientsService.getClientData(String(clientId)).subscribe((client: any) => { + const status = deriveSeniorCitizenStatus(client, this.settingsService.businessDate); + this.clientAge = status.age; + this.clientIsIndividual = status.isIndividual; + this.isSeniorCitizen = status.isSeniorCitizen; + this.clientAgeResolved = true; + // Re-price against the now-known status if a tenure was already entered. + this.onSeniorCitizenChange(); + this.cdr.detectChanges(); + }); + } + + /** Explains the locked checkbox to staff, so a missing benefit never looks like a system fault. */ + get seniorCitizenHint(): string { + if (!this.clientAgeResolved) { + return 'Determined from the client’s date of birth'; + } + if (!this.clientIsIndividual) { + return 'Not applicable to a non-individual client'; + } + if (this.clientAge === null) { + return 'Client has no date of birth on record'; + } + if (this.isSeniorCitizen) { + return `Client is ${this.clientAge} years old — higher rate applied`; + } + return `Client is ${this.clientAge} — applies from age ${this.seniorCitizenAgeThreshold}`; + } + onSeniorCitizenChange() { if (this.tenureValue && this.tenureValue > 0) { this.calculateRate(); diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html new file mode 100644 index 000000000..17d6f00d7 --- /dev/null +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html @@ -0,0 +1,108 @@ + + + +

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

+ + + {{ 'labels.inputs.Maturity Instructions' | translate }} + + @for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) { + + {{ maturityInstruction.value | translateKey: 'catalogs' }} + + } + + + + @if (closureSettingsForm.contains('transferToSavingsId')) { +
+
+
+
+ savings +
+
+

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

+ Maturity proceeds will be credited to this account +
+
+
+ +
+ @if (selectedTransferAccount) { +
+
+ + savings + Savings + +
+ {{ selectedTransferAccount.accountNo }} + {{ selectedTransferAccount.savingsProductName }} +
+ @if (selectedTransferAccount.summary?.accountBalance != null) { +
+ Balance + + {{ selectedTransferAccount.currency?.code }} + {{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }} + +
+ } +
+ +
+ } @else { + + {{ 'labels.inputs.Transfer to savings account' | translate }} + + @for (account of savingsAccountsData; track account) { + + ({{ account.accountNo }}) {{ account.savingsProductName }} + + } + + Transfer to savings account is required + + } +
+
+ } + + + {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + + + @if (closureSettingsForm.value.preClosurePenalApplicable) { + + {{ 'labels.inputs.Penal Interest' | translate }} (%) + + + + + {{ 'labels.inputs.Period' | translate }} + + @for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) { + + {{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }} + + } + + + } + diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss new file mode 100644 index 000000000..1f807bcce --- /dev/null +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss @@ -0,0 +1,152 @@ +/** + * 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/. + */ + +// ── Transfer-to-Savings account card (mirrors the Deposit Account Link card) ── + +.dal-card { + margin-top: 16px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; + overflow: hidden; + + &__header { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 24px; + border-bottom: 1px solid #f1f5f9; + background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); + } + + &__header-left { + display: flex; + align-items: center; + gap: 14px; + } + + &__icon { + width: 44px; + height: 44px; + border-radius: 10px; + background: #0ea5e9; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: #0c4a6e; + } + + &__sub { + display: block; + font-size: 12px; + color: #64748b; + margin-top: 2px; + } + + &__body { + padding: 20px 24px; + } +} + +.dal-selected { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 20px; + border: 2px solid #0ea5e9; + border-radius: 12px; + background: #f0f9ff; + + &__info { + display: flex; + align-items: center; + gap: 14px; + flex: 1; + flex-wrap: wrap; + } + + &__details { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + } + + &__accno { + font-size: 15px; + font-weight: 700; + color: #0c4a6e; + font-family: monospace; + } + + &__product { + font-size: 12px; + color: #64748b; + } + + &__balance { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + } + + &__bal-label { + font-size: 10px; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__bal-amount { + font-size: 15px; + font-weight: 700; + color: #0c4a6e; + } +} + +.dal-change-btn { + white-space: nowrap; +} + +.dal-type-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + background: #f1f5f9; + color: #475569; + border: 1px solid #e2e8f0; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &--savings { + background: #eff6ff; + color: #1d4ed8; + border-color: #bfdbfe; + + mat-icon { + color: #2563eb; + } + } +} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts new file mode 100644 index 000000000..d32140665 --- /dev/null +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts @@ -0,0 +1,116 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatIcon } from '@angular/material/icon'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Recurring Deposit Account Maturity Terms Step -- what happens to this account at maturity + * (Withdraw / Transfer to Savings / Reinvest) and the pre-mature-closure penalty, decided + * per account rather than silently inherited from the product. + */ +@Component({ + selector: 'mifosx-recurring-deposits-account-maturity-terms-step', + templateUrl: './recurring-deposits-account-maturity-terms-step.component.html', + styleUrls: ['./recurring-deposits-account-maturity-terms-step.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCheckbox, + MatIcon + ] +}) +export class RecurringDepositsAccountMaturityTermsStepComponent implements OnChanges { + private formBuilder = inject(UntypedFormBuilder); + + @Input() recurringDepositsAccountTemplate: any; + @Input() recurringDepositsAccountProductTemplate: any; + /** Account already linked elsewhere in the wizard (the Details step's Deposit Account Link) -- + * pre-fills Transfer to Savings so the officer doesn't have to pick the same account twice. */ + @Input() linkedSavingsAccountId: number | null = null; + + closureSettingsForm: UntypedFormGroup; + maturityInstructionOptions: any[] = []; + savingsAccountsData: any[] = []; + preClosurePenalInterestOnTypeData: any[] = []; + + constructor() { + this.closureSettingsForm = this.formBuilder.group({ + maturityInstructionId: [''], + preClosurePenalApplicable: [false], + preClosurePenalInterest: [ + '', + Validators.min(0) + ], + preClosurePenalInterestOnTypeId: [''] + }); + this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => { + // 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both + // Reinvest options (300/400) open a brand-new RD instead, so they ignore this field too. + if (value === 200) { + this.closureSettingsForm.addControl( + 'transferToSavingsId', + new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required) + ); + } else { + this.closureSettingsForm.removeControl('transferToSavingsId'); + } + }); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.linkedSavingsAccountId) { + const control = this.closureSettingsForm.get('transferToSavingsId'); + if (control && !control.value && this.linkedSavingsAccountId) { + control.setValue(this.linkedSavingsAccountId); + } + } + const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; + if (template) { + // Withdraw Deposit (id 100) books the maturity proceeds out of the bank's cash/vault the moment + // the account matures, before the customer has actually collected it -- disabled everywhere. + this.maturityInstructionOptions = (template.maturityInstructionOptions ?? []).filter( + (option: any) => option.id !== 100 + ); + this.savingsAccountsData = template.savingsAccounts ?? []; + this.preClosurePenalInterestOnTypeData = template.preClosurePenalInterestOnTypeOptions ?? []; + if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) { + this.closureSettingsForm.patchValue({ + preClosurePenalApplicable: template.preClosurePenalApplicable ?? false, + preClosurePenalInterest: template.preClosurePenalInterest ?? '', + preClosurePenalInterestOnTypeId: template.preClosurePenalInterestOnType?.id ?? '' + }); + } + } + } + + /** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */ + get selectedTransferAccount(): any { + const id = this.closureSettingsForm.value.transferToSavingsId; + if (!id) return null; + return this.savingsAccountsData.find((account: any) => account.id === id) ?? null; + } + + clearTransferAccount(): void { + this.closureSettingsForm.get('transferToSavingsId')?.setValue(''); + } + + /** Chosen maturity/closure settings, ready to merge into the submit payload. */ + get closureSettings() { + const settings = this.closureSettingsForm.getRawValue(); + for (const key in settings) { + if (settings[key] === '') { + delete settings[key]; + } + } + return settings; + } +} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html index 2ff42a561..0a12ed363 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html @@ -298,10 +298,16 @@ {{ recurringDepositsAccountProductTemplate.accountType }}
} - @if (recurringDepositsAccountProductTemplate?.customerSegment) { + + @if ( + recurringDepositAccountData?.customerSegment || recurringDepositsAccountProductTemplate?.customerSegment + ) {
Customer Segment - {{ recurringDepositsAccountProductTemplate.customerSegment }} + {{ + recurringDepositAccountData?.customerSegment ?? recurringDepositsAccountProductTemplate.customerSegment + }}
} @if (recurringDepositsAccountProductTemplate?.productCode) { @@ -853,6 +859,41 @@ } +
+
+ + Maturity & Pre-mature Closure +
+
+
+
+ {{ 'labels.inputs.Maturity Instructions' | translate }} + {{ closureMaturityInstructionLabel }} +
+ @if (closureSettings?.transferToSavingsId) { +
+ {{ 'labels.inputs.Transfer to savings account' | translate }} + {{ closureTransferToSavingsLabel }} +
+ } +
+ {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} + {{ closureSettings?.preClosurePenalApplicable | yesNo }} +
+ @if (closureSettings?.preClosurePenalApplicable) { +
+ {{ 'labels.inputs.Penal Interest' | translate }} (%) + {{ closureSettings?.preClosurePenalInterest }}% +
+
+ {{ 'labels.inputs.Period' | translate }} + {{ closurePenalInterestOnTypeLabel }} +
+ } +
+
+
+ @if (!hideButtons) {
+ } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts index 24ca434e9..22d3aa3da 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts @@ -22,6 +22,7 @@ import { RecurringDepositsButtonsConfiguration } from './recurring-deposits-butt import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; import { RecurringDepositConfirmationDialogComponent } from './custom-dialogs/recurring-deposit-confirmation-dialog/recurring-deposit-confirmation-dialog.component'; +import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component'; import { Currency } from 'app/shared/models/general.model'; import { TranslateService } from '@ngx-translate/core'; import { AccountsService } from 'app/accounts/accounts.service'; @@ -426,6 +427,20 @@ export class RecurringDepositsAccountViewComponent implements OnInit { }); } + /** + * Opens a printable deposit certificate for this account. + */ + printCertificate() { + this.dialog.open(DepositCertificateDialogComponent, { + width: '900px', + data: { + accountType: 'RD', + accountData: this.recurringDepositsAccountData, + clientId: this.recurringDepositsAccountData.clientId + } + }); + } + /** * Disables withhold tax for recurring deposits account * Recurring deposits endpoint is not supported so using Savings endpoint. diff --git a/src/app/directives/has-permission/has-permission.directive.ts b/src/app/directives/has-permission/has-permission.directive.ts index bc64bc295..7ecc02001 100644 --- a/src/app/directives/has-permission/has-permission.directive.ts +++ b/src/app/directives/has-permission/has-permission.directive.ts @@ -11,6 +11,7 @@ import { Directive, Input, TemplateRef, ViewContainerRef, inject } from '@angula /** Custom Services */ import { AuthenticationService } from '../../core/authentication/authentication.service'; +import { TenantMenuAccessService } from './tenant-menu-access.service'; /** Environment */ import { environment } from '../../../environments/environment'; @@ -23,6 +24,7 @@ export class HasPermissionDirective { private templateRef = inject>(TemplateRef); private viewContainer = inject(ViewContainerRef); private authenticationService = inject(AuthenticationService); + private tenantMenuAccessService = inject(TenantMenuAccessService); /** User Permissions */ private userPermissions: any[]; @@ -75,6 +77,13 @@ export class HasPermissionDirective { // Menu & Sidebar permissions are ALWAYS enforced (hide/show nav items based on role) const isMenuPermission = permission.startsWith('MENU_') || permission.startsWith('SIDEBAR_'); + // The tenant's own ceiling on which menu sections it may ever show — checked before anything + // else below, ALL_FUNCTIONS included, because ALL_FUNCTIONS bypassing this is exactly the gap + // this exists to close (see TenantMenuAccessApiResource). Non-menu permissions never touch this. + if (isMenuPermission && !this.tenantMenuAccessService.isMenuAllowed(permission)) { + return false; + } + // For non-menu permissions: if RBAC is disabled, show everything (backward compatibility) if (!this.rbacEnabled && !isMenuPermission) { return true; diff --git a/src/app/directives/has-permission/tenant-menu-access.service.ts b/src/app/directives/has-permission/tenant-menu-access.service.ts new file mode 100644 index 000000000..55b5e78c9 --- /dev/null +++ b/src/app/directives/has-permission/tenant-menu-access.service.ts @@ -0,0 +1,61 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { catchError, of, tap } from 'rxjs'; + +/** + * The current tenant's own cap on which top-nav menu codes may ever show — see + * `TenantMenuAccessApiResource` on the backend for why this exists separately from a user's own + * permission list: a role carrying `ALL_FUNCTIONS` bypasses every ordinary permission check, so + * `HasPermissionDirective` needs a second, tenant-level list that check cannot see past. + * + * Loaded once per session, into a plain `Set` rather than an `Observable` the directive would have + * to subscribe to — `mifosxHasPermission`'s setter runs synchronously and once per binding, so + * `ShellComponent` loads this before the nav renders (see `ready`) and every directive instance + * afterwards just reads the already-populated set. + */ +@Injectable({ providedIn: 'root' }) +export class TenantMenuAccessService { + private http = inject(HttpClient); + + /** Menu codes this tenant has explicitly turned off. Absence from this set means "shown" — + * a tenant that never configured anything (every tenant, until an operator narrows one) has + * nothing here, which is what keeps every existing deployment behaving exactly as it does today. */ + private disabledCodes = new Set(); + + /** True once the initial load has settled, success or failure. `ShellComponent` waits on this + * before rendering the nav, so a menu is never shown-then-hidden a moment later. */ + private loaded = false; + + isReady(): boolean { + return this.loaded; + } + + isMenuAllowed(menuCode: string): boolean { + return !this.disabledCodes.has(menuCode); + } + + /** Fetches this tenant's list once. A failed request is treated as "nothing disabled" — the same + * as a tenant that never configured this at all — so a transient network hiccup on login never + * makes the whole nav disappear. */ + load() { + return this.http.get<{ menuCode: string; enabled: boolean }[]>('/tenant-menu-access').pipe( + tap((rows) => { + this.disabledCodes = new Set((rows || []).filter((r) => r.enabled === false).map((r) => r.menuCode)); + this.loaded = true; + }), + catchError(() => { + this.disabledCodes = new Set(); + this.loaded = true; + return of([]); + }) + ); + } +} diff --git a/src/app/dormancy-report/dormancy-report.component.html b/src/app/dormancy-report/dormancy-report.component.html new file mode 100644 index 000000000..52db5ef8d --- /dev/null +++ b/src/app/dormancy-report/dormancy-report.component.html @@ -0,0 +1,398 @@ + + +
+ +
+
+

Inactive, Dormant & DEAF Reports

+
+ Accounts with no customer-induced transaction, at each of the three marks RBI sets. Interest the bank posts does + not reset the clock — only a deposit or withdrawal the customer makes. +
+
+
+ + +
+
+ + @if (errorMessage) { +
+ +
{{ errorMessage }}
+
+ } + + + @if (summary.pendingRemittanceCount > 0) { +
+ +
+ ₹{{ summary.pendingRemittance | number: '1.0-0' }} from + {{ summary.pendingRemittanceCount }} escheated + {{ summary.pendingRemittanceCount === 1 ? 'account has' : 'accounts have' }} not been recorded as remitted to + RBI's DEAF. The balance has already left the customer's account, so until the remittance is recorded the money + sits with the bank against no fund. + +
+
+ } + + +
+
Report criteria
+
+
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + +
+ @for (t of tabs; track t) { + + } +
+ + +
+
+ +
+ {{ stageMeta[tab].label }}. {{ stageMeta[tab].effect }} +
Revival: {{ stageMeta[tab].revival }}
+
+
+
+ {{ visibleRows.length }} accounts · + ₹{{ visibleBalance | number: '1.0-0' }} total balance +
+
+ + @if (tab === 'ESCHEAT') { +
+ + + + ₹{{ summary.pendingRemittance | number: '1.0-0' }} not remitted + + ₹{{ summary.remittedBalance | number: '1.0-0' }} with the fund + @if (summary.claimedBackCount > 0) { + ₹{{ summary.claimedBackBalance | number: '1.0-0' }} claimed back + } + + + @if (selected.size > 0) { + {{ selected.size }} selected · ₹{{ selectedTotal | number: '1.0-0' }} + + } + +
+ } + + + @if (loading) { +
Loading…
+ } @else if (visibleRows.length === 0) { +
No accounts at this stage match the criteria above.
+ } @else { +
+ + + + @if (tab === 'ESCHEAT') { + + } + + + + + + + + @if (tab === 'ESCHEAT') { + + + } @else { + + } + + + + + @for (r of visibleRows; track r.accountId) { + + @if (tab === 'ESCHEAT') { + + } + + + + + + + + + + + + + + + @if (tab === 'ESCHEAT') { + + + } @else { + + } + + + + } + +
+ + Account NoCustomerBranchProduct{{ tab === 'ESCHEAT' ? 'Balance Escheated' : 'Balance' }}Last Customer TxnIdle ForEscheated OnRemittance to RBINext Stage
+ + + {{ r.accountNo }} + {{ r.clientName }} +
{{ r.clientId }} · {{ r.mobile }}
+
{{ branchName(r.branchCode) }}{{ r.productName }}{{ r.balance | number: '1.0-0' }}{{ r.lastCustomerTxnDate }} + {{ idleText(r.daysIdle) }} + {{ r.escheatedOn || '—' }} + + {{ r.remittance === 'REMITTED' ? 'Remitted' : 'Not remitted' }} + + @if (r.remittedOn) { +
{{ r.remittedOn }}
+ } + @if (r.deafReference) { +
{{ r.deafReference }}
+ } + @if (r.refundedOn) { +
Claimed back {{ r.refundedOn }}
+ } +
+
{{ r.nextStageLabel }}
+
+ {{ r.nextStageDate }} + @if (isNearingEscheat(r)) { + · {{ daysToNext(r) }} days + } +
+
+ + + @if (tab === 'ESCHEAT' && r.remittance === 'REMITTED' && !r.refundedOn) { + + } +
+
+ } + + + @if (tab === 'ESCHEAT') { +
+ +
+ Depositor Education and Awareness Fund. A balance unclaimed for ten years is transferred to + RBI's DEAF. The transfer itself is made outside this system, so an account shows as escheated here from the day + the balance leaves it — the remittance column records that the money actually reached RBI, and stays "not + remitted" until it does. A customer can still claim the amount at any time; the bank pays them and recovers it + from the fund, which shows here as claimed back. +
+
+ } +
+ + +@if (remitOpen) { +
+
+
+

Record remittance to RBI's DEAF

+ +
+ +
+
+
+ {{ selected.size }} + accounts +
+
+ ₹{{ selectedTotal | number: '1.0-0' }} + total remitted +
+
+ +
+ + This records a transfer that has already been made to RBI. It does not move any money — it marks these + balances as accounted for, so they stop showing as outstanding. +
+ + + + + + + + + + + @if (remitError) { +
{{ remitError }}
+ } +
+ +
+ + +
+
+
+} + + +@if (refundOpen && refundRow) { +
+
+
+

Record a claim against DEAF

+ +
+ +
+
+ {{ refundRow.clientName }} + {{ refundRow.accountNo }} +
+ +
+
+ ₹{{ refundRow.balance | number: '1.0-0' }} + claimed +
+
+ {{ refundRow.remittedOn }} + remitted to RBI +
+
+ +
+ + A depositor can claim an unclaimed balance at any time, however long it has been with the fund. The bank pays + the customer and recovers the amount from RBI. This records that, so the money is no longer counted as sitting + with the fund. +
+ + + + + @if (refundError) { +
{{ refundError }}
+ } +
+ +
+ + +
+
+
+} + +@if (toastVisible) { +
{{ toastMsg }}
+} diff --git a/src/app/dormancy-report/dormancy-report.component.scss b/src/app/dormancy-report/dormancy-report.component.scss new file mode 100644 index 000000000..8afc7605e --- /dev/null +++ b/src/app/dormancy-report/dormancy-report.component.scss @@ -0,0 +1,778 @@ +/** + * 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 { + --d-navy: #1a237e; + --d-blue: #1d4ed8; + --d-blue-l: #eff6ff; + --d-green: #15803d; + --d-green-l: #dcfce7; + --d-amber: #b45309; + --d-amber-l: #fef3c7; + --d-red: #c0392b; + --d-red-l: #fdecea; + --d-crit: #7f1d1d; + --d-crit-l: #fee2e2; + --d-g50: #f8fafc; + --d-g100: #f1f5f9; + --d-g200: #e2e8f0; + --d-g300: #cbd5e1; + --d-g500: #64748b; + --d-g700: #334155; + --d-g900: #0f172a; + + display: block; + font-family: 'DM Sans', system-ui, sans-serif; + color: var(--d-g900); + background: var(--d-g50); + min-height: 100%; + padding: 16px; +} + +.mono { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 11.5px; +} + +.num { + text-align: right; +} + +.strong { + font-weight: 700; +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ + +.dr-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.dr-head-t { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--d-navy); +} + +.dr-head-s { + margin-top: 3px; + font-size: 12px; + color: var(--d-g500); + max-width: 780px; + line-height: 1.5; +} + +.dr-head-a { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +/* The unremitted-money warning: the one number on this screen that is a live exposure. */ +.dr-alert { + display: flex; + gap: 10px; + padding: 11px 14px; + margin-bottom: 14px; + background: var(--d-red-l); + border: 1px solid #f5b7b1; + border-left: 4px solid var(--d-red); + border-radius: 7px; + font-size: 12px; + line-height: 1.55; + color: var(--d-g700); + + fa-icon { + color: var(--d-red); + margin-top: 2px; + } +} + +.dr-link { + display: inline; + margin-left: 4px; + padding: 0; + border: 0; + background: none; + font: inherit; + font-weight: 700; + color: var(--d-red); + text-decoration: underline; + cursor: pointer; +} + +/* ── Entry / criteria ───────────────────────────────────────────────────── */ + +.dr-entry { + margin-bottom: 14px; + background: #fff; + border: 1px solid var(--d-g200); + border-radius: 7px; + overflow: hidden; +} + +.dr-entry-h { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 14px; + background: var(--d-g100); + border-bottom: 1px solid var(--d-g200); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--d-g500); +} + +.dr-entry-b { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + padding: 13px 14px; +} + +.dr-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 160px; + + label { + font-size: 10.5px; + font-weight: 700; + color: var(--d-g500); + text-transform: uppercase; + letter-spacing: 0.3px; + } + + &--wide { + flex: 1; + min-width: 240px; + } + + &--btn { + min-width: 0; + } +} + +.dr-inp { + padding: 7px 10px; + border: 1px solid var(--d-g300); + border-radius: 5px; + font: inherit; + font-size: 12px; + background: #fff; +} + +.dr-chk { + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 10px; + font-size: 12px; + color: var(--d-g700); + cursor: pointer; +} + +/* ── The three reports, side by side ────────────────────────────────────── */ + +.dr-tabs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(255px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.dr-tab { + text-align: left; + padding: 12px 14px; + border: 1px solid var(--d-g200); + border-top: 3px solid var(--d-g300); + border-radius: 7px; + background: #fff; + font: inherit; + cursor: pointer; + transition: box-shadow 0.12s; + + &:hover { + box-shadow: 0 2px 8px rgb(15 23 42 / 9%); + } + + &--info { + border-top-color: var(--d-blue); + } + + &--warn { + border-top-color: var(--d-amber); + } + + &--critical { + border-top-color: var(--d-crit); + } + + /* The report on display stays obvious while its rows are being read. */ + &--on { + background: var(--d-g100); + box-shadow: inset 0 0 0 1px var(--d-g300); + } +} + +.dr-tab-h { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.dr-tab-l { + font-size: 13px; + font-weight: 700; +} + +.dr-tab-n { + font-size: 20px; + font-weight: 700; + line-height: 1; +} + +.dr-tab-d { + margin-top: 2px; + font-size: 10.5px; + font-weight: 600; + color: var(--d-g500); +} + +.dr-tab-e { + margin-top: 5px; + font-size: 10.5px; + color: var(--d-g500); + line-height: 1.45; +} + +/* ── Context strip ──────────────────────────────────────────────────────── */ + +.dr-context { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 14px; + margin-bottom: 10px; + background: var(--d-blue-l); + border: 1px solid #bfdbfe; + border-radius: 7px; + font-size: 11.5px; + line-height: 1.55; + color: var(--d-g700); +} + +.dr-context-i { + display: flex; + gap: 9px; + + fa-icon { + color: var(--d-blue); + margin-top: 2px; + } +} + +.dr-context-r { + margin-top: 3px; + color: var(--d-g500); +} + +.dr-context-t { + white-space: nowrap; + font-size: 11.5px; + color: var(--d-g500); +} + +.dr-context-c { + font-weight: 700; + color: var(--d-g900); +} + +/* ── Buttons ────────────────────────────────────────────────────────────── */ + +.dr-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 13px; + border: 1px solid var(--d-g300); + border-radius: 5px; + background: #fff; + font: inherit; + font-size: 12px; + font-weight: 600; + color: var(--d-g700); + cursor: pointer; + + &:hover:not(:disabled) { + border-color: var(--d-blue); + color: var(--d-blue); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + &--slim { + padding: 6px 11px; + font-size: 11.5px; + } + + &--primary { + background: var(--d-blue); + border-color: var(--d-blue); + color: #fff; + + &:hover:not(:disabled) { + background: #1e40af; + color: #fff; + } + } +} + +.dr-mini { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 9px; + border: 1px solid var(--d-g300); + border-radius: 5px; + background: #fff; + font: inherit; + font-size: 10.5px; + font-weight: 600; + color: var(--d-g700); + cursor: pointer; + + &:hover { + border-color: var(--d-blue); + color: var(--d-blue); + } +} + +/* ── Table ──────────────────────────────────────────────────────────────── */ + +.dr-tablewrap { + overflow-x: auto; + background: #fff; + border: 1px solid var(--d-g200); + border-radius: 7px; +} + +.dr-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + th { + text-align: left; + padding: 9px 10px; + background: var(--d-g100); + border-bottom: 1px solid var(--d-g200); + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--d-g500); + white-space: nowrap; + + &.num { + text-align: right; + } + } + + td { + padding: 9px 10px; + border-bottom: 1px solid var(--d-g100); + vertical-align: top; + } + + tbody tr:hover { + background: var(--d-g50); + } +} + +/* A dormant account inside a year of the DEAF mark is the one to act on now. */ +.dr-row--warn { + background: var(--d-amber-l); + + &:hover { + background: #fde68a; + } +} + +.dr-sub { + font-size: 10.5px; + color: var(--d-g500); + margin-top: 2px; + + &--red { + color: var(--d-amber); + font-weight: 700; + } +} + +.dr-prod { + max-width: 220px; + font-size: 11.5px; +} + +.dr-next { + font-size: 11px; + font-weight: 600; + color: var(--d-g700); +} + +.dr-ref { + max-width: 200px; +} + +.dr-badge { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + white-space: nowrap; + + &--info { + background: var(--d-blue-l); + color: var(--d-blue); + } + + &--warn { + background: var(--d-amber-l); + color: var(--d-amber); + } + + &--critical { + background: var(--d-crit-l); + color: var(--d-crit); + } +} + +.dr-rem { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + + &--ok { + background: var(--d-green-l); + color: var(--d-green); + } + + /* Money out of the customer's account but not yet with RBI — never shown as settled. */ + &--pending { + background: var(--d-red-l); + color: var(--d-red); + } +} + +.dr-empty { + padding: 34px; + text-align: center; + font-size: 12.5px; + color: var(--d-g500); + background: #fff; + border: 1px solid var(--d-g200); + border-radius: 7px; +} + +.dr-foot { + display: flex; + gap: 10px; + padding: 11px 14px; + margin-top: 12px; + background: #fff; + border: 1px solid var(--d-g200); + border-radius: 7px; + font-size: 11.5px; + line-height: 1.6; + color: var(--d-g500); + + fa-icon { + color: var(--d-g500); + margin-top: 2px; + } +} + +/* ── Selection & remittance bar ─────────────────────────────────────────── */ + +.dr-bar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.dr-spacer { + flex: 1; +} + +.dr-selinfo { + font-size: 11.5px; + font-weight: 700; + color: var(--d-g700); +} + +.dr-tick { + width: 34px; + text-align: center; + + input { + cursor: pointer; + } + + /* An already-remitted row cannot be picked again; the cursor should say so. */ + input:disabled { + cursor: not-allowed; + opacity: 0.4; + } +} + +.dr-row--sel { + background: var(--d-blue-l); +} + +.dr-act { + white-space: nowrap; + + .dr-mini + .dr-mini { + margin-left: 4px; + } +} + +/* ── Modal ──────────────────────────────────────────────────────────────── */ + +.dr-modal-bg { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 45%); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +} + +.dr-modal { + width: 100%; + max-width: 520px; + max-height: 90vh; + overflow-y: auto; + background: #fff; + border-radius: 9px; + box-shadow: 0 18px 50px rgb(15 23 42 / 26%); + + &--sm { + max-width: 450px; + } +} + +.dr-modal-h { + display: flex; + align-items: center; + justify-content: space-between; + padding: 13px 17px; + border-bottom: 1px solid var(--d-g200); + + h3 { + margin: 0; + font-size: 14px; + font-weight: 700; + color: var(--d-navy); + } +} + +.dr-x { + border: 0; + background: transparent; + font-size: 22px; + line-height: 1; + color: var(--d-g500); + cursor: pointer; +} + +.dr-modal-b { + padding: 15px 17px; +} + +.dr-modal-f { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 17px; + border-top: 1px solid var(--d-g200); +} + +.dr-modal-sum { + display: flex; + gap: 26px; + padding-bottom: 12px; + margin-bottom: 12px; + border-bottom: 1px solid var(--d-g100); +} + +.dr-modal-n { + display: block; + font-size: 20px; + font-weight: 700; + line-height: 1.1; +} + +.dr-modal-l { + font-size: 10.5px; + color: var(--d-g500); +} + +.dr-modal-who { + display: flex; + align-items: center; + gap: 9px; + flex-wrap: wrap; + padding-bottom: 11px; + margin-bottom: 11px; + border-bottom: 1px solid var(--d-g100); + font-size: 12.5px; +} + +.dr-modal-p { + margin: 12px 0 5px; + font-size: 11.5px; + font-weight: 700; + color: var(--d-g700); +} + +.dr-modal-ul { + margin: 0; + padding-left: 18px; + font-size: 11.5px; + line-height: 1.75; + color: var(--d-g700); +} + +/* Marking an account exempt is a decision on record, not a convenience. */ +.dr-modal-warn { + margin: 12px 0 0; + padding: 8px 11px; + background: var(--d-amber-l); + border-radius: 5px; + font-size: 11px; + line-height: 1.55; + color: var(--d-amber); +} + +.dr-lbl { + display: block; + margin: 12px 0 5px; + font-size: 11px; + font-weight: 700; + color: var(--d-g700); +} + +.dr-req { + color: var(--d-red); +} + +.dr-inp--full { + width: 100%; +} + +.dr-ta { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--d-g300); + border-radius: 5px; + font: inherit; + font-size: 12px; + resize: vertical; +} + +.dr-note-box { + display: flex; + gap: 8px; + padding: 9px 11px; + background: var(--d-blue-l); + border-radius: 5px; + font-size: 11px; + line-height: 1.55; + color: var(--d-g700); + + fa-icon { + color: var(--d-blue); + margin-top: 1px; + } +} + +.dr-err { + margin-top: 10px; + padding: 7px 10px; + background: var(--d-red-l); + border-radius: 5px; + font-size: 11.5px; + color: var(--d-red); +} + +.dr-toast { + position: fixed; + bottom: 22px; + left: 50%; + transform: translateX(-50%); + padding: 10px 20px; + background: var(--d-g900); + color: #fff; + border-radius: 6px; + font-size: 12.5px; + box-shadow: 0 8px 22px rgb(15 23 42 / 30%); + z-index: 1100; +} + +/* Where the escheated money currently stands, read at a glance above the table. */ +.dr-money { + display: inline-flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-left: 14px; +} + +.dr-money-i { + font-size: 11px; + font-weight: 700; + color: var(--d-g500); + + &--bad { + color: var(--d-red); + } + + &--ok { + color: var(--d-green); + } +} + +/* A balance the customer has taken back is no longer with the fund. */ +.dr-claimed { + margin-top: 3px; + font-size: 10px; + font-weight: 700; + color: var(--d-blue); +} diff --git a/src/app/dormancy-report/dormancy-report.component.ts b/src/app/dormancy-report/dormancy-report.component.ts new file mode 100644 index 000000000..39c794881 --- /dev/null +++ b/src/app/dormancy-report/dormancy-report.component.ts @@ -0,0 +1,621 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; + +/** Custom Modules */ +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** Custom Services */ +import { ClientsService } from '../clients/clients.service'; + +/** Shared report helpers */ +import { exportWorkbook } from '../cashier/cashier-export'; + +/** Report data + computation */ +import { + DORMANCY_POLICY, + DORMANCY_STAGES, + DormancyRow, + DormancyStage, + DormancySummary, + daysBetween, + dormancySummary, + nextStage, + sortByIdle, + stageFromSubStatus +} from './dormancy-report.compute'; +import { toIsoDate } from 'app/core/utils/api-date'; + +/** + * The three reports on accounts that have gone quiet. + * + * They are one screen rather than three because they are one progression: an account moves from + * inactive to dormant to escheat as the same silence lengthens, and an officer reading one usually + * wants to see what is coming behind it. The filters at the top therefore apply to all three, and + * the tabs only choose which stage is on display. + * + * The escheat tab carries the figure that matters most — money taken out of customers' accounts + * that has not yet reached RBI's DEAF. Nothing in this system performs that remittance, so without + * a report on it the gap is invisible. + */ +@Component({ + selector: 'mifosx-dormancy-report', + templateUrl: './dormancy-report.component.html', + styleUrls: ['./dormancy-report.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent + ] +}) +export class DormancyReportComponent implements OnInit { + private router = inject(Router); + private clientsService = inject(ClientsService); + + loading = false; + errorMessage = ''; + rows: DormancyRow[] = []; + today = new Date().toISOString().slice(0, 10); + + /** Which of the three reports is on display. */ + tab: DormancyStage = 'DORMANT'; + + /** Filters, shared by all three tabs. */ + branchCode = ''; + search = ''; + minBalance: number | null = null; + pendingRemittanceOnly = false; + + /** + * Branch options come from the rows themselves rather than a fixed list: the server returns the + * office name it holds, and a dropdown built from anywhere else would offer branches that match + * nothing and omit ones that do. + */ + get branches(): string[] { + return [...new Set(this.rows.map((r) => r.branchCode).filter((b) => !!b))].sort(); + } + readonly policy = DORMANCY_POLICY; + readonly stageMeta = DORMANCY_STAGES; + readonly tabs: DormancyStage[] = [ + 'INACTIVE', + 'DORMANT', + 'ESCHEAT' + ]; + + ngOnInit(): void { + this.load(); + } + + /** + * Loads the accounts the dormancy job has marked. + * + * The stage is taken from the sub-status the job wrote rather than recomputed from the idle + * period. The sub-status is what actually restricts the account, so a report that worked it out + * independently could disagree with the account's real state and send officers after the wrong + * ones. + */ + load(): void { + this.loading = true; + this.errorMessage = ''; + + this.clientsService.getDormantAccounts().subscribe({ + next: (accounts: any[]) => { + // Mapping is wrapped because it did once throw on a date the API returned in a shape this + // screen did not expect. The throw escaped the callback, so `loading` was never cleared and + // the report sat on "Loading..." with every control dead and nothing in the network tab to + // explain it. A bad row should cost the row, not the screen. + try { + this.rows = sortByIdle( + (accounts || []).map((a) => this.mapAccount(a)).filter((r): r is DormancyRow => r !== null) + ); + } catch (e) { + this.rows = []; + this.errorMessage = 'Could not read the dormancy reports.'; + console.error('Dormancy report: could not map the accounts returned by the server', e); + } + this.openFirstStageWithRows(); + this.loading = false; + }, + error: (err: any) => { + this.rows = []; + this.loading = false; + this.errorMessage = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the dormancy reports.'; + } + }); + } + + // ── Recording a DEAF remittance ──────────────────────────────────────────── + + /** Accounts ticked on the escheat tab, by account id. */ + selected = new Set(); + + remitOpen = false; + remitDate = new Date().toISOString().slice(0, 10); + remitReference = ''; + remitRemarks = ''; + remitSaving = false; + remitError = ''; + + toggleSelect(row: DormancyRow, event: Event): void { + event.stopPropagation(); + if (this.selected.has(row.accountId)) { + this.selected.delete(row.accountId); + } else { + this.selected.add(row.accountId); + } + } + + /** Ticks every unremitted row on display, or clears them if all are already ticked. */ + toggleSelectAll(): void { + const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED'); + if (selectable.every((r) => this.selected.has(r.accountId))) { + selectable.forEach((r) => this.selected.delete(r.accountId)); + } else { + selectable.forEach((r) => this.selected.add(r.accountId)); + } + } + + /** + * Drops the whole selection. + * + * A method rather than `selected.clear()` in the template — Angular's expression syntax does not + * allow calling built-in methods on a Set. + */ + clearSelection(): void { + this.selected.clear(); + } + + get allSelected(): boolean { + const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED'); + return selectable.length > 0 && selectable.every((r) => this.selected.has(r.accountId)); + } + + get selectedTotal(): number { + return this.rows.filter((r) => this.selected.has(r.accountId)).reduce((s, r) => s + r.balance, 0); + } + + openRemit(): void { + this.remitDate = new Date().toISOString().slice(0, 10); + this.remitReference = ''; + this.remitRemarks = ''; + this.remitError = ''; + this.remitOpen = true; + } + + /** + * Writes the remittance against every ticked account. + * + * The server skips anything not escheated or already recorded, so the result is reported back as + * counts rather than assumed — a batch that silently recorded fewer rows than were ticked would + * leave the officer believing money was accounted for when it was not. + */ + saveRemittance(): void { + if (this.selected.size === 0) { + this.remitError = 'Select at least one account.'; + return; + } + if (!this.remitDate) { + this.remitError = 'Enter the date the money was sent to RBI.'; + return; + } + + this.remitSaving = true; + this.remitError = ''; + + this.clientsService + .recordDeafRemittance({ + savingsAccountIds: [...this.selected], + remittedOn: this.remitDate, + deafReference: this.remitReference.trim() || undefined, + remarks: this.remitRemarks.trim() || undefined + }) + .subscribe({ + next: (res: any) => { + this.remitSaving = false; + this.remitOpen = false; + const recorded = res?.changes?.recorded ?? res?.recorded ?? this.selected.size; + const skipped = res?.changes?.skipped ?? res?.skipped ?? 0; + this.selected.clear(); + this.toast( + skipped > 0 + ? `${recorded} remittance(s) recorded, ${skipped} skipped — already recorded or not escheated.` + : `${recorded} remittance(s) recorded.` + ); + this.load(); + }, + error: (err: any) => { + this.remitSaving = false; + this.remitError = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the remittance.'; + } + }); + } + + // ── Customer claiming a remitted balance back ────────────────────────────── + + refundOpen = false; + refundRow: DormancyRow | null = null; + refundDate = new Date().toISOString().slice(0, 10); + refundSaving = false; + refundError = ''; + + /** + * Opens the claim dialog for a balance already sent to the fund. + * + * A customer can claim an unclaimed deposit at any time, however long it has been with DEAF. The + * bank pays them and recovers the amount from RBI, so recording it here keeps the remitted total + * honest rather than leaving money counted as still sitting in the fund. + */ + openRefund(row: DormancyRow, event: Event): void { + event.stopPropagation(); + this.refundRow = row; + this.refundDate = new Date().toISOString().slice(0, 10); + this.refundError = ''; + this.refundOpen = true; + } + + saveRefund(): void { + if (!this.refundRow) { + return; + } + if (!this.refundDate) { + this.refundError = 'Enter the date the customer was paid.'; + return; + } + + const row = this.refundRow; + this.refundSaving = true; + this.refundError = ''; + + this.clientsService.refundDeafRemittance(row.accountId, this.refundDate).subscribe({ + next: () => { + this.refundSaving = false; + this.refundOpen = false; + this.toast(`Claim recorded for ${row.clientName}.`); + this.load(); + }, + error: (err: any) => { + this.refundSaving = false; + this.refundError = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the claim.'; + } + }); + } + + toastMsg = ''; + toastVisible = false; + private toastTimer: any; + + toast(msg: string): void { + this.toastMsg = msg; + this.toastVisible = true; + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => (this.toastVisible = false), 4000); + } + + /** Maps one DormantAccountData row onto what the three reports render. */ + private mapAccount(a: any): DormancyRow | null { + const stage = stageFromSubStatus(Number(a?.subStatus)); + if (!stage) { + // Only the three dormancy states belong here; anything else is not on this ladder. + return null; + } + + const lastTxn = toIsoDate(a?.lastCustomerTransactionDate); + const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' }; + + return { + accountId: a?.accountId, + accountNo: a?.accountNo || '—', + clientId: a?.clientId, + clientName: a?.clientName || '—', + branchCode: a?.officeName || '', + productName: a?.productName || '—', + balance: Number(a?.balance ?? 0), + lastCustomerTxnDate: lastTxn, + daysIdle: Number(a?.daysIdle ?? 0), + stage, + subStatus: Number(a?.subStatus ?? 0), + nextStageDate: next.date, + nextStageLabel: next.label, + // An escheated account counts as remitted only when a remittance has actually been recorded + // against it. Absence of that row is the whole point of the report: the money has left the + // customer's account but is not yet accounted for as having reached RBI. + remittance: stage === 'ESCHEAT' ? (a?.deafRemittedOn ? 'REMITTED' : 'PENDING') : undefined, + escheatedOn: toIsoDate(a?.closedOnDate) || undefined, + remittedOn: toIsoDate(a?.deafRemittedOn) || undefined, + deafReference: a?.deafReference || undefined, + refundedOn: toIsoDate(a?.deafRefundedOn) || undefined, + mobile: a?.mobileNo || '' + }; + } + + // ── Filtering ────────────────────────────────────────────────────────────── + + /** Rows on the tab in view, after the shared filters. */ + get visibleRows(): DormancyRow[] { + return this.rowsForStage(this.tab); + } + + private rowsForStage(stage: DormancyStage): DormancyRow[] { + const needle = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (r.stage !== stage) { + return false; + } + if (this.branchCode && r.branchCode !== this.branchCode) { + return false; + } + if (this.minBalance != null && r.balance < this.minBalance) { + return false; + } + // Only meaningful on the escheat tab, where a row can be awaiting remittance. + if (this.pendingRemittanceOnly && r.remittance !== 'PENDING') { + return false; + } + if (needle) { + const hay = `${r.clientName} ${r.clientId} ${r.accountNo}`.toLowerCase(); + if (!hay.includes(needle)) { + return false; + } + } + return true; + }); + } + + /** Tab counts respect the filters, so the badge matches what the tab will show. */ + countFor(stage: DormancyStage): number { + return this.rowsForStage(stage).length; + } + + get summary(): DormancySummary { + return dormancySummary(this.rows, this.today); + } + + get visibleBalance(): number { + return this.visibleRows.reduce((s, r) => s + r.balance, 0); + } + + resetFilters(): void { + this.branchCode = ''; + this.search = ''; + this.minBalance = null; + this.pendingRemittanceOnly = false; + } + + showTab(stage: DormancyStage): void { + this.tab = stage; + // The remittance filter only exists on the escheat tab; carrying it elsewhere would silently + // empty a tab that has no remittance state at all. + if (stage !== 'ESCHEAT') { + this.pendingRemittanceOnly = false; + } + } + + /** + * Opens the first stage that actually has accounts on it. + * + * The tab that happens to be selected is not necessarily the one holding the data: the report + * opens on Dormant, and an account that has only reached Inactive — or that fell back to it + * because a customer transaction reset its clock — leaves the reader looking at "No accounts at + * this stage" beside a card counting 1. Only moves off a tab that is empty, so a reader who has + * deliberately opened a stage is not thrown off it when the data reloads. + */ + private openFirstStageWithRows(): void { + if (this.rowsForStage(this.tab).length > 0) { + return; + } + const ladder: DormancyStage[] = [ + 'INACTIVE', + 'DORMANT', + 'ESCHEAT' + ]; + const populated = ladder.find((stage) => this.rowsForStage(stage).length > 0); + if (populated) { + this.showTab(populated); + } + } + + // ── Presentation ─────────────────────────────────────────────────────────── + + /** The server already returns the office by name, so there is nothing to look up. */ + branchName(office: string): string { + return office || '—'; + } + + toneClass(stage: DormancyStage): string { + return `dr-badge dr-badge--${DORMANCY_STAGES[stage].tone}`; + } + + /** "3 years 2 months" reads better than a four-digit day count. */ + idleText(days: number): string { + const years = Math.floor(days / 365); + const months = Math.floor((days % 365) / 30); + if (years === 0) { + return `${months} month${months === 1 ? '' : 's'}`; + } + if (months === 0) { + return `${years} year${years === 1 ? '' : 's'}`; + } + return `${years}y ${months}m`; + } + + /** Days until the next mark, for the accounts approaching one. */ + daysToNext(row: DormancyRow): number | null { + return row.nextStageDate ? daysBetween(this.today, row.nextStageDate) : null; + } + + /** Flags a dormant account within a year of the ten-year mark. */ + isNearingEscheat(row: DormancyRow): boolean { + const days = this.daysToNext(row); + return row.stage === 'DORMANT' && days !== null && days <= 365; + } + + remittanceClass(state?: string): string { + return state === 'REMITTED' ? 'dr-rem dr-rem--ok' : 'dr-rem dr-rem--pending'; + } + + openClient(row: DormancyRow, event: Event): void { + event.stopPropagation(); + this.router.navigate([ + '/clients', + row.clientId, + 'general' + ]); + } + + // ── Export ───────────────────────────────────────────────────────────────── + + /** + * Exports all three reports, not just the tab in view. + * + * They are read together — an auditor asking about escheat invariably asks what is behind it — + * and a workbook holding one of the three would only have to be produced three times. + */ + exportExcel(): void { + const stamp = `As on ${this.today}${this.branchCode ? ` · ${this.branchName(this.branchCode)}` : ' · All branches'}`; + + exportWorkbook( + [ + { + sheetName: 'Inactive', + title: `Inactive Accounts — idle ${this.policy.inactiveDays}+ days`, + subtitle: stamp, + headers: [ + 'Account No', + 'Customer', + 'Customer ID', + 'Branch', + 'Product', + 'Balance', + 'Last Customer Txn', + 'Idle', + 'Becomes Dormant On' + ], + widths: [ + 18, + 28, + 12, + 22, + 30, + 14, + 18, + 12, + 20 + ], + rows: this.rowsForStage('INACTIVE').map((r) => [ + r.accountNo, + r.clientName, + String(r.clientId), + this.branchName(r.branchCode), + r.productName, + r.balance, + r.lastCustomerTxnDate, + this.idleText(r.daysIdle), + r.nextStageDate + ]) + }, + { + sheetName: 'Dormant', + title: `Dormant / Inoperative Accounts — idle ${this.policy.dormantDays}+ days`, + subtitle: stamp, + headers: [ + 'Account No', + 'Customer', + 'Customer ID', + 'Branch', + 'Product', + 'Balance', + 'Last Customer Txn', + 'Idle', + 'Due to DEAF On', + 'Nearing DEAF' + ], + widths: [ + 18, + 28, + 12, + 22, + 30, + 14, + 18, + 12, + 18, + 14 + ], + rows: this.rowsForStage('DORMANT').map((r) => [ + r.accountNo, + r.clientName, + String(r.clientId), + this.branchName(r.branchCode), + r.productName, + r.balance, + r.lastCustomerTxnDate, + this.idleText(r.daysIdle), + r.nextStageDate, + this.isNearingEscheat(r) ? 'Within 1 year' : '' + ]) + }, + { + sheetName: 'Escheat & DEAF', + title: `Escheated Accounts — idle ${this.policy.escheatDays}+ days`, + subtitle: `${stamp} · Not yet remitted to RBI: ₹${this.summary.pendingRemittance.toLocaleString('en-IN')}`, + headers: [ + 'Account No', + 'Customer', + 'Customer ID', + 'Branch', + 'Balance Escheated', + 'Last Customer Txn', + 'Idle', + 'Escheated On', + 'Remittance to RBI', + 'Remitted On', + 'DEAF Reference', + 'Claimed Back On' + ], + widths: [ + 18, + 28, + 12, + 22, + 18, + 18, + 12, + 14, + 16, + 14, + 24, + 16 + ], + rows: this.rowsForStage('ESCHEAT').map((r) => [ + r.accountNo, + r.clientName, + String(r.clientId), + this.branchName(r.branchCode), + r.balance, + r.lastCustomerTxnDate, + this.idleText(r.daysIdle), + r.escheatedOn || '', + r.remittance === 'REMITTED' ? 'Remitted' : 'Not remitted', + r.remittedOn || '', + r.deafReference || '', + r.refundedOn || '' + ]) + } + ], + `dormancy-reports-${this.today}` + ); + } +} diff --git a/src/app/dormancy-report/dormancy-report.compute.ts b/src/app/dormancy-report/dormancy-report.compute.ts new file mode 100644 index 000000000..bd491edb2 --- /dev/null +++ b/src/app/dormancy-report/dormancy-report.compute.ts @@ -0,0 +1,222 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Accounts that have gone quiet, and what has happened to them. + * + * RBI counts the silence from the last *customer-induced* transaction — a deposit or a withdrawal + * the customer made. Interest the bank posts and charges it recovers are the bank operating the + * account, not the customer using it, so they do not reset the clock. An account crosses three + * marks: inactive at a year, inoperative at two, and due to the Depositor Education and Awareness + * Fund at ten. + * + * The escheat stage is the one that needs watching. Sending the balance to DEAF is a transfer to + * RBI that happens outside this system, so an account can sit closed here with its money not yet + * remitted. That gap is what the third report exists to show. + */ + +/** The three marks an idle account passes. */ +export type DormancyStage = 'INACTIVE' | 'DORMANT' | 'ESCHEAT'; + +/** Sub-status codes as stored on the savings account. */ +export const SUB_STATUS = { + NONE: 0, + INACTIVE: 100, + DORMANT: 200, + ESCHEAT: 300 +}; + +/** RBI periods, in days from the last customer transaction. */ +export const DORMANCY_POLICY = { + inactiveDays: 365, + dormantDays: 730, + escheatDays: 3650 +}; + +export interface DormancyStageMeta { + stage: DormancyStage; + label: string; + tone: 'info' | 'warn' | 'critical'; + afterDays: number; + /** What the customer can still do. */ + effect: string; + /** How the account comes back, in the officer's words. */ + revival: string; +} + +export const DORMANCY_STAGES: Record = { + INACTIVE: { + stage: 'INACTIVE', + label: 'Inactive', + tone: 'info', + afterDays: DORMANCY_POLICY.inactiveDays, + effect: 'Nothing is restricted. The account is flagged internally so the branch can reach out.', + revival: 'Any customer deposit or withdrawal clears the flag at once.' + }, + DORMANT: { + stage: 'DORMANT', + label: 'Dormant / Inoperative', + tone: 'warn', + afterDays: DORMANCY_POLICY.dormantDays, + effect: 'Debits are stopped. Credits still land, so pension and benefit transfers are unaffected.', + revival: 'The customer completes KYC at the branch and makes one transaction — no reactivation charge.' + }, + ESCHEAT: { + stage: 'ESCHEAT', + label: 'Escheat — due to DEAF', + tone: 'critical', + afterDays: DORMANCY_POLICY.escheatDays, + effect: 'The account is closed and the balance is taken out, to be remitted to RBI’s DEAF.', + revival: 'The customer can still claim the money from the bank, which then recovers it from DEAF.' + } +}; + +/** Whether the balance taken out at escheat has actually reached RBI. */ +export type DeafRemittanceState = 'PENDING' | 'REMITTED'; + +export interface DormancyRow { + accountId: number; + accountNo: string; + clientId: number; + clientName: string; + branchCode: string; + productName: string; + balance: number; + /** Last transaction the customer themselves made. */ + lastCustomerTxnDate: string; + daysIdle: number; + stage: DormancyStage; + /** Sub-status currently on the account. */ + subStatus: number; + /** The day the next mark is crossed; blank once escheated. */ + nextStageDate: string; + nextStageLabel: string; + /** Escheat only — whether the money has gone to RBI. */ + remittance?: DeafRemittanceState; + /** Escheat only — the day the balance was taken out of the account. */ + escheatedOn?: string; + /** Escheat only — the day it was remitted, once it has been. */ + remittedOn?: string; + /** Escheat only — RBI's reference for the remittance. */ + deafReference?: string; + /** Escheat only — set if the customer claimed the balance back after it went to the fund. */ + refundedOn?: string; + mobile: string; +} + +function addDays(date: string, days: number): string { + const d = new Date(`${date}T00:00:00Z`); + if (Number.isNaN(d.getTime())) { + // Never let an unparseable date take the whole report down with it. + return ''; + } + return new Date(d.getTime() + days * 86400000).toISOString().slice(0, 10); +} + +export function daysBetween(earlier: string, later: string): number { + const a = new Date(`${earlier}T00:00:00Z`).getTime(); + const b = new Date(`${later}T00:00:00Z`).getTime(); + return Math.round((b - a) / 86400000); +} + +/** + * The stage an account is at, from the sub-status the dormancy job wrote on it. + * + * Preferred over deriving it from the idle period, because the sub-status is what actually + * restricts the account. Anything outside the three dormancy codes — a manual block, an active + * account — is not on this ladder and returns null. + */ +export function stageFromSubStatus(subStatus: number): DormancyStage | null { + if (subStatus === SUB_STATUS.INACTIVE) { + return 'INACTIVE'; + } + if (subStatus === SUB_STATUS.DORMANT) { + return 'DORMANT'; + } + if (subStatus === SUB_STATUS.ESCHEAT) { + return 'ESCHEAT'; + } + return null; +} + +/** Which mark an account has passed, from how long it has been idle. */ +export function dormancyStage(daysIdle: number): DormancyStage | null { + if (daysIdle >= DORMANCY_POLICY.escheatDays) { + return 'ESCHEAT'; + } + if (daysIdle >= DORMANCY_POLICY.dormantDays) { + return 'DORMANT'; + } + if (daysIdle >= DORMANCY_POLICY.inactiveDays) { + return 'INACTIVE'; + } + return null; +} + +/** The next mark and the day it falls due. */ +export function nextStage(lastTxnDate: string, stage: DormancyStage): { date: string; label: string } { + if (stage === 'INACTIVE') { + return { date: addDays(lastTxnDate, DORMANCY_POLICY.dormantDays), label: 'Becomes dormant' }; + } + if (stage === 'DORMANT') { + return { date: addDays(lastTxnDate, DORMANCY_POLICY.escheatDays), label: 'Balance due to DEAF' }; + } + // Escheat is the last mark — nothing further happens to the account on its own. + return { date: '', label: 'Awaiting DEAF remittance' }; +} + +export interface DormancySummary { + inactive: number; + dormant: number; + escheat: number; + inactiveBalance: number; + dormantBalance: number; + escheatBalance: number; + /** Escheated money not yet sent to RBI — the figure that must not be left to drift. */ + pendingRemittance: number; + pendingRemittanceCount: number; + /** Balances that reached the fund and are still with it. */ + remittedBalance: number; + /** Balances since claimed back by the customer and recovered from the fund. */ + claimedBackBalance: number; + claimedBackCount: number; + /** Dormant accounts approaching the ten-year mark within a year. */ + nearingEscheat: number; +} + +export function dormancySummary(rows: DormancyRow[], today: string): DormancySummary { + const inactive = rows.filter((r) => r.stage === 'INACTIVE'); + const dormant = rows.filter((r) => r.stage === 'DORMANT'); + const escheat = rows.filter((r) => r.stage === 'ESCHEAT'); + const pending = escheat.filter((r) => r.remittance === 'PENDING'); + const claimedBack = escheat.filter((r) => r.refundedOn); + // A balance the customer has since claimed back is no longer with the fund, so it is counted + // separately rather than left in the remitted total. + const stillWithFund = escheat.filter((r) => r.remittance === 'REMITTED' && !r.refundedOn); + const sum = (list: DormancyRow[]) => list.reduce((s, r) => s + r.balance, 0); + + return { + inactive: inactive.length, + dormant: dormant.length, + escheat: escheat.length, + inactiveBalance: sum(inactive), + dormantBalance: sum(dormant), + escheatBalance: sum(escheat), + pendingRemittance: sum(pending), + pendingRemittanceCount: pending.length, + remittedBalance: sum(stillWithFund), + claimedBackBalance: sum(claimedBack), + claimedBackCount: claimedBack.length, + nearingEscheat: dormant.filter((r) => r.nextStageDate && daysBetween(today, r.nextStageDate) <= 365).length + }; +} + +/** Idle longest first — the accounts closest to the next mark need attention first. */ +export function sortByIdle(rows: DormancyRow[]): DormancyRow[] { + return [...rows].sort((a, b) => b.daysIdle - a.daysIdle || b.balance - a.balance); +} diff --git a/src/app/fiu/fiu-data.ts b/src/app/fiu/fiu-data.ts index b423d948e..f87077470 100644 --- a/src/app/fiu/fiu-data.ts +++ b/src/app/fiu/fiu-data.ts @@ -65,6 +65,22 @@ export interface CtrTransaction { evidenceDocType?: string; evidenceReference?: string; evidencedOn?: string; + + /** + * Account and customer facts carried alongside the transaction, read off the books when the CTR + * line is loaded. + * + * Account_Detail and Account_Person_Relation are derived from these rows, and they used to fill + * these columns with constants — every account opened on 20/05/2018, every account ACTIVE, every + * customer reference '0'. Those are statements about a customer's account in a filing, so they + * come from the record rather than from a placeholder. + */ + accountOpenedOn?: string; + accountClosedOn?: string; + accountStatus?: string; + ckycNumber?: string; + clientExternalId?: string; + individual?: boolean; } /** Outcome of the Principal Officer's investigation into an STR case */ diff --git a/src/app/fiu/fiu.component.html b/src/app/fiu/fiu.component.html index 499752e34..3c4946314 100644 --- a/src/app/fiu/fiu.component.html +++ b/src/app/fiu/fiu.component.html @@ -14,8 +14,8 @@

Financial Intelligence Unit – India (FIU-IND)

- {{ bank.name }} · {{ scopeName }} · Reporting Month {{ bank.reportingMonth }} - · PMLA 2002 / FINnet 2.0 + {{ bank.name }} · {{ scopeName }} · Reporting Month + {{ reportingMonthLabel }} · PMLA 2002 / FINnet 2.0
@@ -33,12 +33,13 @@ {{ scopeName }} } -
- Principal Officer{{ bank.principalOfficer }} -
-
- REBANK BUID{{ bank.reBuid }} -
+ + @if (principalOfficerName) { +
+ Principal Officer{{ principalOfficerName }} +
+ } @@ -164,7 +165,11 @@
Threshold: ₹{{ bank.cashThreshold | number }} per single txn / aggregate per month
-
Due: 15 Feb 2026
+ +
+ Due: {{ ctrDueDateLabel }} +
@@ -206,11 +211,27 @@ with the same bundle and are worked on the Customer Cards tab.

- + + @if (ctrLinesLoading) { +
Loading the generated CTR lines…
+ } @else if (ctrLinesError) { +
{{ ctrLinesError }}
+ } @else if (!ctrTxns.length) { +
+ + No CTR has been generated yet. Pick a month under “Generate CTR” below and run it — every cash transaction + of ₹10,00,000 or more, and every customer whose smaller cash transactions add up to that within the month, + will be picked up from the books. +
+ } +
@@ -411,87 +432,17 @@ }
- -
-
-

Live AML Alerts (Awaiting Triage)

-

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

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

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

- -
@@ -523,7 +474,7 @@ {{ alert.transactionType }} {{ alert.ruleCode }} — {{ alert.ruleDescription }} ₹{{ alert.amount | number: '1.0-0' }} - {{ alert.raisedOn | date: 'medium' }} + {{ asDate(alert.raisedOn) | date: 'medium' }} {{ alert.reviewedByUsername || '—' }} {{ alert.reviewNotes || '—' }} @@ -569,7 +520,7 @@ - @for (sc of amlStrCases; track sc.id) { + @for (sc of liveStrCases; track sc.id) { {{ sc.id }} {{ sc.clientDisplayName }} @@ -586,13 +537,50 @@ } @empty { - No STR cases yet. + No STR cases outstanding. }
+ + @if (filedStrCases.length) { +
+
+

Filed with FIU-IND

+

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

+
+
+
+ + + + + + + + + + + + + @for (sc of filedStrCases; track sc.id) { + + + + + + + + + } + +
IDClientConclusionFiled OnFIU ReferenceAction
{{ sc.id }}{{ sc.clientDisplayName }}{{ sc.conclusion || '—' }}{{ asDate(sc.strFiledOn) | date: 'dd MMM yyyy' }}{{ sc.strReferenceNo || '—' }}
+
+ } + @if (selectedStrCase) {
@@ -744,295 +732,6 @@ Download STR XML
- -
-
- - -
-
- - -
- -
- -
- @for (s of filteredStr; track s.reportRefNo) { -
-
-
- STR-{{ s.reportRefNo }} - {{ statusLabel(s.status) }} - {{ riskLabel(s.riskCategory) }} -
-
- {{ s.depositOrWithdrawal }} - ₹{{ s.txnAmount | number: '1.0-0' }} -
-
-
-
-
- {{ s.customerName }} -
-
- {{ s.accountNumber }} -
-
- {{ s.txnDate }} {{ s.txnTime }} -
-
- {{ s.detectedOn }} -
-
- {{ s.flaggedBy }} -
-
- {{ s.instrumentType }} {{ s.instrumentId }} -
-
- {{ s.branchCode }} {{ branchNameFor(s.branchCode) }} -
-
-
- -

{{ s.narrative }}

-
- @if (s.poRemarks) { -
- -

{{ s.poRemarks }}

-
- } - @if (s.poFinding) { -
- -

{{ poFindingLabel(s.poFinding) }}

-
- } - - - @if (s.status === 'awaiting-filing') { -
- -

- Generate the FINnet STR XML, upload it on the FIU-IND portal under - {{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here. -

-
- - - -
-
- } - - @if (s.status === 'filed' && s.fiuReferenceNo) { -
- - - Filed with FIU-IND on {{ s.filedOn }} · acknowledgement - {{ s.fiuReferenceNo }} - -
- } - - @if (!s.poFinding && investigatingStr?.reportRefNo !== s.reportRefNo) { -
- - -
- } - - - @if (investigatingStr?.reportRefNo === s.reportRefNo) { -
-
- Principal Officer investigation — STR-{{ s.reportRefNo }} - -
- - -
-
1 Documentary evidence called for
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
2 Customer’s explanation of the source
- -
- - -
-
- 3 Verification checklist - - {{ answeredCount }} / {{ strChecklist.length }} answered - -
- - - - - - - - - - @for (item of strChecklist; track item.key) { - - - - - - } - -
Verification pointGenuineNot satisfied
-
{{ item.label }}
-
{{ item.hint }}
-
- - - -
-
- - -
- @if (!canConcludeLocal) { - - - Complete all three steps to determine the outcome. - @if (!evidenceRecorded) { - Evidence not yet recorded. - } - @if (!allAnswered) { - {{ strChecklist.length - answeredCount }} verification point(s) pending. - } - - } @else if (derivedConclusion === 'NOT_SUSPICIOUS') { - - - Source of funds established. Case closes as clean — the transaction stays in - the CTR report and no STR is filed with FIU-IND. - - } @else { - - - Source of funds not established. {{ failedFindings.length }} point(s) - unsatisfied — an STR will be filed with FIU-IND in addition to the CTR. - - } -
- - -
- } -
-
- } @empty { -
No STR records match the filters.
- } -
} diff --git a/src/app/fiu/fiu.component.scss b/src/app/fiu/fiu.component.scss index dbac07b0c..f8f0390c8 100644 --- a/src/app/fiu/fiu.component.scss +++ b/src/app/fiu/fiu.component.scss @@ -1086,6 +1086,17 @@ flex-shrink: 0; margin-top: 2px; } + + /* Nothing to file yet, or the lines could not be read — not the neutral blue of an explanation. */ + &--warn { + background: #fffbeb; + border-color: #fde68a; + color: #92400e; + + fa-icon { + color: #d97706; + } + } } /* TOAST */ diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts index c2dc8081b..e918bce81 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -7,29 +7,26 @@ */ import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { AmlService } from 'app/aml/aml.service'; import { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service'; import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { toApiDate, toIsoDate } from 'app/core/utils/api-date'; import { BRANCHES, BranchMaster, CardTransaction, CtrTransaction, - CTR_TXNS, - TC1_TXNS, FIU_BANK, FIU_BRANCH_CODE_TO_CANONICAL, - KC1_INDIVIDUALS, - KC2_ENTITIES, KycEntity, KycIndividual, PoFinding, StrStatus, - StrTransaction, - STR_TXNS + StrTransaction } from './fiu-data'; /** A FINnet file rendered as a grid — the same rows that go into the CSV. */ @@ -127,12 +124,13 @@ export const FIU_OBLIGATIONS: FiuObligation[] = [ ] }) export class FiuComponent implements OnInit { - /** Bump when CTR_TXNS / STR_TXNS change so cached rows are re-seeded. */ + /** Bump when the seeded STR set changes so cached rows are re-seeded. */ private static readonly SEED_VERSION = '2026-01-cash-threshold'; private amlService = inject(AmlService); private sanctionsLogsService = inject(SanctionsLogsService); private authenticationService = inject(AuthenticationService); + private route = inject(ActivatedRoute); /** Active tab */ activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard'; @@ -140,11 +138,42 @@ export class FiuComponent implements OnInit { readonly obligations = FIU_OBLIGATIONS; bank = FIU_BANK; - ctrTxns: CtrTransaction[] = CTR_TXNS; - tc1Txns: CardTransaction[] = TC1_TXNS; - strTxns: StrTransaction[] = STR_TXNS; - kc1: KycIndividual[] = KC1_INDIVIDUALS; - kc2: KycEntity[] = KC2_ENTITIES; + /** + * The CTR lines the bank has actually generated. Empty until the engine has produced a report — + * a filing screen that shows invented accounts is worse than one that shows none, because every + * count, total and CSV on it is then something nobody can submit. + */ + ctrTxns: CtrTransaction[] = []; + ctrLinesLoading = false; + ctrLinesError = ''; + /** + * ATM / card-rail cash, which feeds the same CTR as the counter transactions in TC2. + * + * Empty: the sample rows that used to sit here belonged to the sample accounts in TC2, and with + * those gone they referenced accounts this bank does not hold — twelve rows claiming to reconcile + * against a file that had none. Card transactions are not yet sourced from the books, so the file + * is honestly empty rather than populated with rows that cannot be filed. + */ + tc1Txns: CardTransaction[] = []; + /** + * The STR transaction file. + * + * Empty, for the same reason TC1 and the customer cards are. The four rows that sat here named customers this bank + * has never onboarded, against account numbers it does not hold, for sums it never received — on the screen an + * officer files suspicious-transaction reports from. A filing built on invented rows is not a draft; it is a false + * report to FIU-IND. The live alerts the engine has actually escalated are listed on this tab already, and the + * file fills from those when the STR export is wired to them. + */ + strTxns: StrTransaction[] = []; + /** + * KC1 / KC2 — the customer cards that travel with the CTR bundle. + * + * Empty for the same reason as TC1: these described sample customers of sample accounts. A card + * naming a person this bank has never onboarded cannot be filed, and leaving them in would put + * invented customers into a bundle an officer might submit. + */ + kc1: KycIndividual[] = []; + kc2: KycEntity[] = []; // -------------------- Head Office vs Branch scope -------------------- // CTR/STR filing itself is a bank-wide Principal-Officer function (one filing for the whole @@ -155,6 +184,12 @@ export class FiuComponent implements OnInit { readonly branches = BRANCHES; isHeadOffice = false; currentOfficeName = 'Branch Office'; + /** The month being filed, from the clock rather than a constant in the data file. */ + reportingMonthLabel = ''; + /** Blank unless the signed-in user actually holds the Principal Officer role. */ + principalOfficerName = ''; + /** CTR is due by the 15th of the month after the one being reported. */ + ctrDueDateLabel = ''; private ownBranchCode: string | null = null; /** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */ branchFilter = ''; @@ -185,13 +220,29 @@ export class FiuComponent implements OnInit { */ sanctionsStrCases: any[] = []; sanctionsStrFilter = ''; - amlAlertStatusFilter = ''; - amlReviewNotes: Record = {}; amlCtrReports: any[] = []; ctrGenerateMonth = ''; amlStrCases: any[] = []; + + /** + * The cases still carrying work, which is what "Live" means here. + * + *

+ * A case that has been filed with FIU-IND is finished: the report is gone, the reference number is on it, and there + * is nothing left for the Principal Officer to do. Leaving it in the live queue is how a working list stops being + * one — every filed case stays on it for good, and an officer opening the tab has to tell the outstanding work from + * the closed by reading a status column. Filed cases are listed below, where they can still be looked up. + */ + get liveStrCases(): any[] { + return this.amlStrCases.filter((c) => c?.status !== 'FILED'); + } + + /** Cases already filed with FIU-IND, kept for the record rather than as work. */ + get filedStrCases(): any[] { + return this.amlStrCases.filter((c) => c?.status === 'FILED'); + } selectedStrCase: any = null; concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS'; concludeInvestigationNotes = ''; @@ -372,6 +423,29 @@ export class FiuComponent implements OnInit { const creds = this.authenticationService.getCredentials(); this.isHeadOffice = this.authenticationService.isHeadOfficeUser(); this.currentOfficeName = creds?.officeName || 'Branch Office'; + + // Opened on a particular tab when another screen sends the officer here for a reason - the Risk + // Review Report links a case that is with the Principal Officer straight to the STR queue, so + // landing on the dashboard would make them find it themselves. + const wantedTab = this.route.snapshot.queryParamMap.get('tab'); + if (wantedTab === 'str' || wantedTab === 'ctr' || wantedTab === 'customers' || wantedTab === 'files') { + this.activeTab = wantedTab; + } + + // The header named a hard-coded officer and a hard-coded month — "Karthik Kamath" and + // "January 2026" — on a screen whose whole purpose is to say which month is being filed and + // by whom. Both now come from the session and the clock. + const today = new Date(); + this.reportingMonthLabel = today.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' }); + // The 15th of the following month, which is the PMLA deadline for the month being reported. + this.ctrDueDateLabel = new Date(today.getFullYear(), today.getMonth() + 1, 15).toLocaleDateString('en-IN', { + day: 'numeric', + month: 'short', + year: 'numeric' + }); + this.principalOfficerName = this.authenticationService.isPrincipalOfficerUser() + ? creds?.staffDisplayName || creds?.username || 'Principal Officer' + : ''; if (this.isHeadOffice) { this.branchFilter = ''; } else { @@ -384,22 +458,17 @@ export class FiuComponent implements OnInit { this.loadAmlAlerts(); this.loadAmlCtrReports(); this.loadAmlStrCases(); + // The CTR grids are built from the reports the bank generated, not from a sample set. Loaded + // after the cached rows below so a fresh read always wins over whatever was stored last. + this.loadCtrLines(); // Bumping SEED_VERSION discards previously cached rows so a new data set takes effect. if (localStorage.getItem('fiu_seed_version') !== FiuComponent.SEED_VERSION) { localStorage.setItem('fiu_seed_version', FiuComponent.SEED_VERSION); - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns)); return; } - const storedCtr = localStorage.getItem('fiu_ctr_txns'); - if (storedCtr) { - this.ctrTxns = JSON.parse(storedCtr); - } else { - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); - } - const storedStr = localStorage.getItem('fiu_str_cases'); if (storedStr) { this.strTxns = JSON.parse(storedStr); @@ -442,7 +511,7 @@ export class FiuComponent implements OnInit { txn.evidenceDocType = this.ctrEvidenceDocType; txn.evidenceReference = this.ctrEvidenceRef.trim(); txn.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-'); - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); + this.storeCtrCompliance(); this.evidencingTxn = null; this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`); } @@ -453,7 +522,7 @@ export class FiuComponent implements OnInit { */ referCtrTxnToStr(txn: CtrTransaction): void { txn.complianceStatus = 'REFERRED'; - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); + this.storeCtrCompliance(); const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1; const newStrCase: StrTransaction = { @@ -521,7 +590,7 @@ export class FiuComponent implements OnInit { origin.evidenceDocType = this.evidenceDocType; origin.evidenceReference = this.evidenceReference.trim(); origin.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-'); - localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); + this.storeCtrCompliance(); } this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`); } else { @@ -580,10 +649,9 @@ export class FiuComponent implements OnInit { } /** - * Always fetches every status. The triage table and the cleared register are two views of the - * same set, so filtering server-side would empty one whenever the other was narrowed — and the - * CTR tab reads this list too, which a status filter chosen on the STR tab should not touch. - * amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead. + * Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of + * the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty + * another. */ loadAmlAlerts(): void { this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || [])); @@ -598,51 +666,133 @@ export class FiuComponent implements OnInit { this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || [])); } - /** Surfaces a failed backend call — without this a rejected request looks like a dead button. */ - private apiError(action: string) { - return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`); + /** + * Fineract sends timestamps as [y, M, d, H, m, s]. Angular's date pipe renders an array as + * nothing, which left "Raised On" blank beside alerts that had obviously been raised. + */ + asDate(value: any): Date | null { + return toApiDate(value); } /** - * Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of - * why a flagged transaction was disposed of, and an empty string leaves no trace of the decision. + * Loads the generated CTR lines and shapes them into the TC2 rows every grid on this tab derives + * from — Account_Detail, Account_Person_Relation and TC1 are all computed off `ctrTxns`, so the + * one conversion here puts real data behind all four files. */ - private reviewNotesOf(alert: any): string | null { - const notes = (this.amlReviewNotes[alert.id] || '').trim(); - if (!notes) { - this.toast(`Enter review notes before disposing of alert #${alert.id}`); + loadCtrLines(): void { + this.ctrLinesLoading = true; + this.ctrLinesError = ''; + this.amlService.getCtrReportLines().subscribe({ + next: (lines) => { + this.ctrLinesLoading = false; + this.ctrTxns = (lines || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1)); + // Cached so the compliance flags an officer sets below survive a reload, keyed on the rows + // that actually came back rather than on a seeded set. + this.applyStoredCtrCompliance(); + }, + error: (err: any) => { + this.ctrLinesLoading = false; + this.ctrTxns = []; + this.ctrLinesError = + err?.error?.defaultUserMessage || + 'Could not load the generated CTR lines. Generate a report for the month first.'; + } + }); + } + + /** + * One reportable line as a TC2 row. + * + * A connected-group line is a month of smaller cash movements by one customer that together cross + * the threshold, so it has no single transaction, account or date behind it. It is still + * reportable, and is shown as the aggregate it is rather than dropped. + */ + private toCtrTransaction(line: any, refNo: number): CtrTransaction { + const branchCode = this.branchCodeOfName(line.branchName) || ''; + return { + reportType: 'CTR', + reportRefNo: refNo, + branchCode, + accountNumber: line.accountNo || '', + txnBranchCode: branchCode, + depositOrWithdrawal: line.direction === 'Withdrawal' ? 'Withdrawal' : 'Deposit', + txnId: line.transactionId || line.id, + txnDate: toIsoDate(line.transactionDate), + txnTime: '', + txnAmount: Number(line.amount) || 0, + instrumentType: 'Cash', + instrumentId: 'CASH', + nonCustomerRef: '', + relationshipFlag: 'Customer', + thirdPartyPan: '', + declaration: line.connectedGroup ? 'Connected transactions aggregated for the month' : '', + customerName: line.clientName || '', + complianceStatus: 'PENDING', + accountOpenedOn: toIsoDate(line.accountOpenedOn), + accountClosedOn: toIsoDate(line.accountClosedOn), + accountStatus: line.accountStatus || '', + ckycNumber: line.ckycNumber || '', + clientExternalId: line.clientExternalId || '', + individual: line.individual !== false + }; + } + + /** Branch master code for an office name, so the branch filter on this tab still works. */ + private branchCodeOfName(name: string | null): string | null { + if (!name) { return null; } - return notes; + const wanted = name.trim().toLowerCase(); + return this.branches.find((b: BranchMaster) => wanted.includes(b.name.toLowerCase()))?.code || null; } - whitelistAlert(alert: any): void { - const notes = this.reviewNotesOf(alert); - if (!notes) { + /** + * Re-applies the evidence an officer recorded against these transactions. + * + * The rows themselves come from the server every load; only the counter-side compliance fields + * are held locally, so they are matched back on transaction id rather than by replacing the row. + */ + private applyStoredCtrCompliance(): void { + const stored = localStorage.getItem('fiu_ctr_compliance'); + if (!stored) { return; } - this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({ - next: () => { - this.toast(`Alert #${alert.id} whitelisted — no STR raised`); - this.loadAmlAlerts(); - }, - error: this.apiError(`Whitelisting alert #${alert.id}`) - }); + try { + const byTxnId: Record = JSON.parse(stored); + for (const t of this.ctrTxns) { + const saved = byTxnId[String(t.txnId)]; + if (saved) { + t.complianceStatus = saved.complianceStatus; + t.evidenceDocType = saved.evidenceDocType; + t.evidenceReference = saved.evidenceReference; + t.evidencedOn = saved.evidencedOn; + } + } + } catch { + // A corrupted cache must not take the filing screen down with it. + localStorage.removeItem('fiu_ctr_compliance'); + } } - escalateAlert(alert: any): void { - const notes = this.reviewNotesOf(alert); - if (!notes) { - return; + /** Persists only the compliance fields, keyed on transaction id. */ + private storeCtrCompliance(): void { + const byTxnId: Record = {}; + for (const t of this.ctrTxns) { + if (t.complianceStatus && t.complianceStatus !== 'PENDING') { + byTxnId[String(t.txnId)] = { + complianceStatus: t.complianceStatus, + evidenceDocType: t.evidenceDocType, + evidenceReference: t.evidenceReference, + evidencedOn: t.evidencedOn + }; + } } - this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({ - next: () => { - this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`); - this.loadAmlAlerts(); - this.loadAmlStrCases(); - }, - error: this.apiError(`Escalating alert #${alert.id}`) - }); + localStorage.setItem('fiu_ctr_compliance', JSON.stringify(byTxnId)); + } + + /** Surfaces a failed backend call — without this a rejected request looks like a dead button. */ + private apiError(action: string) { + return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`); } generateCtrReport(): void { @@ -654,6 +804,9 @@ export class FiuComponent implements OnInit { next: () => { this.toast(`CTR report generated for ${this.ctrGenerateMonth}`); this.loadAmlCtrReports(); + // The grids above are built from these lines, so they are re-read rather than left showing + // the state before the run. + this.loadCtrLines(); }, error: this.apiError('CTR report generation') }); @@ -829,9 +982,16 @@ export class FiuComponent implements OnInit { get liveStrFiled(): number { return this.amlStrCases.filter((c) => c.status === 'FILED').length; } - /** Still needs PO action: untouched, or concluded suspicious but not yet filed with FIU-IND. */ + /** + * Still needs PO action: anything neither filed with FIU-IND nor concluded clear. + * + * Counted by what is unfinished rather than by naming the statuses that are. Adding up OPEN and CONCLUDED_SUSPICIOUS + * gives the same figure today, but it is the shape that fails quietly: a case in any status those two do not name + * drops out of the KPI while still sitting in the queue below it, and the dashboard then under-reports the work. + * That is exactly how five undecided alerts hid behind a pending count of 1 on the flagged-transactions screen. + */ get liveStrPending(): number { - return this.liveStrOpen + this.liveStrAwaitingFiling; + return this.amlStrCases.filter((c) => c?.status !== 'FILED' && c?.status !== 'CONCLUDED_CLEAR').length; } get totalCustomers(): number { return this.kc1.length + this.kc2.length; @@ -860,12 +1020,6 @@ export class FiuComponent implements OnInit { * The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed * with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}. */ - get amlSuspiciousAlerts(): any[] { - return this.behaviouralAlerts.filter( - (a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter) - ); - } - /** Cleared register: alerts closed as false positives, kept for audit with their evidence. */ get amlWhitelistedAlerts(): any[] { return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED'); @@ -1016,7 +1170,9 @@ export class FiuComponent implements OnInit { t.txnBranchCode, t.depositOrWithdrawal, t.txnId.toString(), - t.txnDate.replace(/-/g, '/'), + // dd/MM/yyyy, as FINnet prints it. A straight slash-swap on an ISO date gave 2026/09/11, + // which reads as a different date to anyone expecting the prescribed format. + this.toFinnetDate(t.txnDate), t.txnTime, t.txnAmount.toString(), t.instrumentType, @@ -1254,6 +1410,19 @@ export class FiuComponent implements OnInit { return { headers, rows }; } + /** FINnet prints dates as dd/MM/yyyy; the API sends them as yyyy-MM-dd. Blank stays blank. */ + private toFinnetDate(iso: string | undefined): string { + if (!iso) { + return ''; + } + const [ + y, + m, + d + ] = iso.split('-'); + return y && m && d ? `${d}/${m}/${y}` : ''; + } + /** Account_Detail — per-report account aggregates (12-month) */ private buildAccountDetail(): FinnetTable { const headers = [ @@ -1290,12 +1459,15 @@ export class FiuComponent implements OnInit { rows.push([ t.reportType, t.reportRefNo.toString(), + // 212 is FINnet's savings-account code; every CTR line this module produces is a savings + // transaction, and a loan line would need its own code before it could be filed. '212', t.accountNumber, t.branchCode, - '20/05/2018', - '', - 'ACTIVE', + // Read off the account, not assumed. Every row used to carry 20/05/2018 and ACTIVE. + this.toFinnetDate(t.accountOpenedOn), + this.toFinnetDate(t.accountClosedOn), + t.accountStatus || '', '', debits.length.toString(), debits.reduce((s, x) => s + x.txnAmount, 0).toString(), @@ -1333,16 +1505,16 @@ export class FiuComponent implements OnInit { const key = `${t.reportRefNo}|${t.accountNumber}`; if (seen.has(key)) continue; seen.add(key); - // Find matching KC1 by reportRefNo (best-effort dummy mapping) - const k = this.kc1.find((x) => x.reportRefNo === t.reportRefNo); rows.push([ t.reportType, t.reportRefNo.toString(), t.branchCode, t.accountNumber, '1', - k ? k.ucic : '0', - 'Individual', + // The customer's own reference: their CKYC number where one is on file, else the external + // id the bank knows them by. This used to read '0' for every row, which identified nobody. + t.ckycNumber || t.clientExternalId || '', + t.individual === false ? 'Non-individual' : 'Individual', t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : '' ]); } @@ -1488,14 +1660,58 @@ export class FiuComponent implements OnInit { } /** STR XML stub (FINnet 2.0 STR XML) */ + /** + * Writes the STR XML from the cases the Principal Officer has actually concluded as suspicious. + * + * It used to be built from the sample set, so the file named accounts and customers this bank + * does not hold — a return that could not be filed and should not have been offered. + */ exportStrXml(): void { - const suspects = this.strSuspects; + const suspects = (this.amlStrCases || []).filter( + (c: any) => c.conclusion === 'SUSPICIOUS' || c.status === 'CONCLUDED_SUSPICIOUS' || c.status === 'FILED' + ); if (!suspects.length) { - this.toast('No suspect cases to export — nothing matches the current filters'); + this.toast('No case has been concluded suspicious yet — nothing to file'); return; } - this.writeStrXml(suspects, `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`); - this.toast(`STR XML exported (${suspects.length} suspects)`); + this.writeLiveStrXml(suspects, `STR-${this.reportingMonthLabel.replace(/\s+/g, '-')}.xml`); + this.toast(`STR XML exported (${suspects.length} case${suspects.length === 1 ? '' : 's'})`); + } + + /** Serialises the concluded cases as FINnet 2.0 STR XML and downloads it. */ + private writeLiveStrXml(cases: any[], filename: string): void { + const xml: string[] = []; + xml.push(''); + xml.push( + `` + ); + xml.push(` `); + if (this.principalOfficerName) { + xml.push(` `); + } + for (const c of cases) { + xml.push(` `); + xml.push(` ${this.escapeXml(c.clientDisplayName || '')}`); + xml.push(` ${this.escapeXml(c.conclusion || '')}`); + if (c.investigationNotes) { + xml.push(` ${this.escapeXml(c.investigationNotes)}`); + } + if (c.sourceOfFundsNotes) { + xml.push(` ${this.escapeXml(c.sourceOfFundsNotes)}`); + } + if (c.fiuReferenceNo) { + xml.push(` `); + } + xml.push(' '); + } + xml.push(''); + const blob = new Blob([xml.join('\n')], { type: 'application/xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); } /** Serialises a set of suspect cases as FINnet 2.0 STR XML and downloads it. */ diff --git a/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.html b/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.html index d6da52a04..11caf2490 100644 --- a/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.html +++ b/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.html @@ -441,16 +441,18 @@ - Total Contingent Liabilities: ₹ {{ contingentLiab.current | number: '1.2-2' }} Lakh + Total Contingent Liabilities: ₹ {{ contingentLiab.current | number: '1.2-2' }} Lakh

-   Individual item-wise figures for this schedule are not broken - out here — only the audited total is shown, to avoid presenting a sub-item split that could not be - independently verified against the source statement. +   Individual item-wise figures for this schedule are not broken out + here — only the audited total is shown, to avoid presenting a sub-item split that could not be independently + verified against the source statement.

diff --git a/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.ts b/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.ts index 1d9f12a9a..eddee214a 100644 --- a/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.ts +++ b/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.ts @@ -150,7 +150,13 @@ export class AccountingReportsDialogComponent { // -------------------- P&L Data (₹ in Lakhs) — FY 2025-26 audited figures -------------------- pnl: PnlLine[] = [ - { code: 'I-01', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Advances & Bills', amount: 102641.73 }, + { + code: 'I-01', + group: 'INCOME', + subgroup: 'Interest Earned', + label: 'Interest on Advances & Bills', + amount: 102641.73 + }, { code: 'I-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Income on Investments', amount: 41936.75 }, { code: 'I-03', @@ -167,7 +173,13 @@ export class AccountingReportsDialogComponent { label: 'Net Income on Sale of Fixed Assets', amount: 0.3 }, - { code: 'I-06', group: 'INCOME', subgroup: 'Other Income', label: 'Other Income (ATM/POS/UPI/Rental/Misc)', amount: 280 }, + { + code: 'I-06', + group: 'INCOME', + subgroup: 'Other Income', + label: 'Other Income (ATM/POS/UPI/Rental/Misc)', + amount: 280 + }, { code: 'E-01', group: 'EXPENDITURE', diff --git a/src/app/home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component.ts b/src/app/home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component.ts index 85a72408d..9bcfe90ce 100644 --- a/src/app/home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component.ts +++ b/src/app/home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component.ts @@ -659,7 +659,7 @@ export class CimsXbrlDialogComponent { name: 'OSS-4', fullName: 'Connected Lending & Large Exposure Statement', description: - 'Quarterly statement of loans to directors/relatives, senior officers, and exposures exceeding prudential thresholds. Illustrative figures — no source filing was provided for this return; values are modelled on this bank\'s own capital/advances scale for consistency.', + "Quarterly statement of loans to directors/relatives, senior officers, and exposures exceeding prudential thresholds. Illustrative figures — no source filing was provided for this return; values are modelled on this bank's own capital/advances scale for consistency.", cimsCode: 'CIMS_OSS_004', taxonomy: 'rbi-cims-oss4-2024-04-01', periodicity: 'Quarterly', @@ -729,7 +729,7 @@ export class CimsXbrlDialogComponent { name: 'OSS-5', fullName: 'Institutional & Membership Profile', description: - 'Annual statement of branch network, staff strength, membership and depositor/borrower base. Illustrative figures — no source filing was provided for this return; membership share capital is aligned to this bank\'s real paid-up capital figure.', + "Annual statement of branch network, staff strength, membership and depositor/borrower base. Illustrative figures — no source filing was provided for this return; membership share capital is aligned to this bank's real paid-up capital figure.", cimsCode: 'CIMS_OSS_005', taxonomy: 'rbi-cims-oss5-2024-04-01', periodicity: 'Annual', diff --git a/src/app/home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component.ts b/src/app/home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component.ts index 3eed23f0b..380d44cf6 100644 --- a/src/app/home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component.ts +++ b/src/app/home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component.ts @@ -27,7 +27,15 @@ interface DemoClient { standalone: true, templateUrl: './fd-rd-entry-dialog.component.html', styleUrls: ['./fd-rd-entry-dialog.component.scss'], - imports: [CommonModule, DecimalPipe, FormsModule, MatDialogModule, MatButtonModule, MatTooltipModule, FaIconComponent] + imports: [ + CommonModule, + DecimalPipe, + FormsModule, + MatDialogModule, + MatButtonModule, + MatTooltipModule, + FaIconComponent + ] }) export class FdRdEntryDialogComponent { private dialogRef = inject(MatDialogRef); @@ -53,8 +61,16 @@ export class FdRdEntryDialogComponent { { id: 'C-10689', name: 'Vikram Traders' } ]; - payModes = ['Cash', 'Cheque', 'Transfer from Savings A/c']; - interestPayoutOptions = ['Cumulative (Paid on Maturity)', 'Monthly Payout', 'Quarterly Payout']; + payModes = [ + 'Cash', + 'Cheque', + 'Transfer from Savings A/c' + ]; + interestPayoutOptions = [ + 'Cumulative (Paid on Maturity)', + 'Monthly Payout', + 'Quarterly Payout' + ]; maturityInstructions = [ 'Renew Principal & Interest', 'Renew Principal Only, Payout Interest', diff --git a/src/app/interest-report/interest-report.component.html b/src/app/interest-report/interest-report.component.html new file mode 100644 index 000000000..3d7b36957 --- /dev/null +++ b/src/app/interest-report/interest-report.component.html @@ -0,0 +1,205 @@ + + +
+ +
+
+

Savings Interest Posted

+
+ Interest actually credited to members, grouped by the rhythm each product posts on. An account appears under its + own product's posting period and nowhere else. +
+
+
+ + +
+
+ + @if (errorMessage) { +
{{ errorMessage }}
+ } + + + @if (rhythmWarnings.length > 0) { +
+ +
+ Postings do not follow the product's rhythm. + @for (w of rhythmWarnings; track w.product) { +
+ {{ w.product }} is configured {{ w.label }} ({{ w.expected }}) but has paid on + {{ w.dates }} different dates. +
+ } +
+ These were written by the platform's own savings interest job, which posts a day at a time regardless of the + product's period. Every posting below is real money credited to a member — the figures are correct, but a + period total will not match the rhythm the product is set to. +
+
+
+ } + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ @for (p of periods; track p.enumValue) { + + } +
+ + +
+ + + + @if (periodPick[activePeriod]) { + + } + + + {{ availablePicks(activePeriod).length }} {{ activeMeta.pickUnit + }}{{ availablePicks(activePeriod).length === 1 ? '' : 's' }} with postings + +
+ + +
+
+ +
+ {{ activeMeta.label }}. {{ activeMeta.cadence }} — every account on a product configured to + post {{ activeMeta.label.toLowerCase() }}. +
+
+
+ {{ visibleAccounts }} accounts · + {{ visibleRows.length }} postings · + ₹{{ visibleTotal | number: '1.2-2' }} paid +
+
+ + + @if (loading) { +
Loading…
+ } @else if (visibleRows.length === 0) { +
+ No {{ activeMeta.label.toLowerCase() }} interest posted in this period. + @if (activePeriod !== 1) { + A cycle that has not ended yet has nothing to show. + } +
+ } @else { + @for (group of groupedByDate; track group.date) { +
+
+ {{ formatDate(group.date) }} + + {{ group.accounts }} account{{ group.accounts === 1 ? '' : 's' }} · + ₹{{ group.total | number: '1.2-2' }} + +
+ +
+ + + + + + + + + + + + + + + + @for (r of group.rows; track r.transaction_id) { + + + + + + + + + + + + } + +
Account NoCustomerProductRateBalance BeforeInterest PostedBalance AfterBranch
{{ r.account_no }}{{ r.client_name || '—' }}{{ r.product_name }}{{ r.interest_rate | number: '1.2-2' }}%{{ r.balance_before | number: '1.2-2' }}{{ r.interest_amount | number: '1.2-2' }}{{ r.balance_after | number: '1.2-2' }}{{ r.office_name || '—' }} + +
+
+
+ } + } +
diff --git a/src/app/interest-report/interest-report.component.scss b/src/app/interest-report/interest-report.component.scss new file mode 100644 index 000000000..0f7994039 --- /dev/null +++ b/src/app/interest-report/interest-report.component.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/. + */ + +:host { + --i-navy: #1a237e; + --i-blue: #1d4ed8; + --i-blue-l: #eff6ff; + --i-green: #15803d; + --i-green-l: #dcfce7; + --i-amber: #b45309; + --i-amber-l: #fef3c7; + --i-red: #c0392b; + --i-red-l: #fdecea; + --i-g50: #f8fafc; + --i-g100: #f1f5f9; + --i-g200: #e2e8f0; + --i-g300: #cbd5e1; + --i-g500: #64748b; + --i-g700: #334155; + --i-g900: #0f172a; + + display: block; + font-family: 'DM Sans', system-ui, sans-serif; + color: var(--i-g900); + background: var(--i-g50); + min-height: 100%; + padding: 16px; +} + +.mono { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 11.5px; +} + +.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.strong { + font-weight: 700; +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ + +.ir-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.ir-head-t { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--i-navy); +} + +.ir-head-s { + margin-top: 3px; + font-size: 12px; + color: var(--i-g500); + max-width: 760px; + line-height: 1.5; +} + +.ir-head-a { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.ir-alert { + padding: 10px 14px; + margin-bottom: 12px; + background: var(--i-red-l); + border: 1px solid #f5b7b1; + border-radius: 7px; + font-size: 12px; + color: var(--i-red); +} + +/* ── Filters ────────────────────────────────────────────────────────────── */ + +.ir-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + padding: 13px 14px; + margin-bottom: 14px; + background: #fff; + border: 1px solid var(--i-g200); + border-radius: 7px; +} + +.ir-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 150px; + + label { + font-size: 10.5px; + font-weight: 700; + color: var(--i-g500); + text-transform: uppercase; + letter-spacing: 0.3px; + } + + &--wide { + flex: 1; + min-width: 240px; + } + + &--btn { + min-width: 0; + } +} + +.ir-inp { + padding: 7px 10px; + border: 1px solid var(--i-g300); + border-radius: 5px; + font: inherit; + font-size: 12px; + background: #fff; +} + +/* ── Buttons ────────────────────────────────────────────────────────────── */ + +.ir-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 13px; + border: 1px solid var(--i-g300); + border-radius: 5px; + background: #fff; + font: inherit; + font-size: 12px; + font-weight: 600; + color: var(--i-g700); + cursor: pointer; + + &:hover:not(:disabled) { + border-color: var(--i-blue); + color: var(--i-blue); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + &--slim { + padding: 6px 11px; + font-size: 11.5px; + } + + &--primary { + background: var(--i-blue); + border-color: var(--i-blue); + color: #fff; + + &:hover:not(:disabled) { + background: #1e40af; + color: #fff; + } + } +} + +.ir-mini { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 9px; + border: 1px solid var(--i-g300); + border-radius: 5px; + background: #fff; + font: inherit; + font-size: 10.5px; + font-weight: 600; + color: var(--i-g700); + cursor: pointer; + + &:hover:not(:disabled) { + border-color: var(--i-blue); + color: var(--i-blue); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +/* ── One card per posting rhythm ────────────────────────────────────────── */ + +.ir-tabs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.ir-tab { + text-align: left; + padding: 11px 13px; + border: 1px solid var(--i-g200); + border-top: 3px solid var(--i-g300); + border-radius: 7px; + background: #fff; + font: inherit; + cursor: pointer; + transition: box-shadow 0.12s; + + &:hover { + box-shadow: 0 2px 8px rgb(15 23 42 / 9%); + } + + /* The rhythm on display stays obvious while its rows are being read. */ + &--on { + background: var(--i-blue-l); + border-top-color: var(--i-blue); + box-shadow: inset 0 0 0 1px #bfdbfe; + } +} + +.ir-tab-h { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.ir-tab-l { + font-size: 13px; + font-weight: 700; +} + +.ir-tab-n { + font-size: 19px; + font-weight: 700; + line-height: 1; +} + +.ir-tab-d { + margin-top: 2px; + font-size: 10px; + color: var(--i-g500); +} + +.ir-tab-t { + margin-top: 6px; + font-size: 12px; + font-weight: 700; + color: var(--i-green); +} + +/* ── Context strip ──────────────────────────────────────────────────────── */ + +.ir-context { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 14px; + margin-bottom: 12px; + background: var(--i-blue-l); + border: 1px solid #bfdbfe; + border-radius: 7px; + font-size: 11.5px; + line-height: 1.55; + color: var(--i-g700); +} + +.ir-context-i { + display: flex; + gap: 9px; + + fa-icon { + color: var(--i-blue); + margin-top: 2px; + } +} + +.ir-context-t { + white-space: nowrap; + color: var(--i-g500); +} + +.ir-context-c { + font-weight: 700; + color: var(--i-g900); +} + +/* ── One group per posting date ─────────────────────────────────────────── */ + +.ir-group { + margin-bottom: 14px; + background: #fff; + border: 1px solid var(--i-g200); + border-radius: 7px; + overflow: hidden; +} + +/* The date is the unit an officer reconciles against: a run pays a whole product on one day. */ +.ir-group-h { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 14px; + background: var(--i-g100); + border-bottom: 1px solid var(--i-g200); +} + +.ir-group-d { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 12.5px; + font-weight: 700; + color: var(--i-navy); +} + +.ir-group-m { + font-size: 11.5px; + color: var(--i-g500); + + strong { + color: var(--i-green); + } +} + +/* ── Table ──────────────────────────────────────────────────────────────── */ + +.ir-tablewrap { + overflow-x: auto; +} + +.ir-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + th { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--i-g200); + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--i-g500); + white-space: nowrap; + + &.num { + text-align: right; + } + } + + td { + padding: 8px 10px; + border-bottom: 1px solid var(--i-g100); + } + + tbody tr:hover { + background: var(--i-g50); + } + + tbody tr:last-child td { + border-bottom: 0; + } +} + +.ir-prod { + max-width: 210px; + font-size: 11.5px; +} + +/* The figure the report exists for. */ +.ir-amt { + color: var(--i-green); +} + +.ir-empty { + padding: 34px; + text-align: center; + font-size: 12.5px; + color: var(--i-g500); + background: #fff; + border: 1px solid var(--i-g200); + border-radius: 7px; +} + +/* ── Per-rhythm period control ──────────────────────────────────────────── */ + +.ir-pick { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 9px 14px; + margin-bottom: 10px; + background: #fff; + border: 1px solid var(--i-g200); + border-radius: 7px; +} + +.ir-pick-l { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--i-g500); +} + +.ir-spacer { + flex: 1; +} + +.ir-pick-hint { + font-size: 11px; + color: var(--i-g500); +} + +/* The dropdown carries a date, a count and a total, so it needs room. */ +.ir-inp--pick { + min-width: 20rem; +} + +/* ── Rhythm mismatch warning ────────────────────────────────────────────── */ + +.ir-warn { + display: flex; + gap: 10px; + padding: 11px 14px; + margin-bottom: 12px; + background: var(--i-amber-l); + border: 1px solid #fcd34d; + border-left: 4px solid var(--i-amber); + border-radius: 7px; + font-size: 12px; + line-height: 1.55; + color: var(--i-g700); + + fa-icon { + color: var(--i-amber); + margin-top: 2px; + } +} + +.ir-warn-row { + margin-top: 4px; +} + +.ir-warn-note { + margin-top: 6px; + font-size: 11px; + color: var(--i-g500); +} diff --git a/src/app/interest-report/interest-report.component.ts b/src/app/interest-report/interest-report.component.ts new file mode 100644 index 000000000..948dff9bd --- /dev/null +++ b/src/app/interest-report/interest-report.component.ts @@ -0,0 +1,445 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; + +/** Custom Modules */ +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** Custom Services */ +import { + SavingsDailyInterestService, + SavingsInterestPostingRow +} from '../savings-daily-interest/savings-daily-interest.service'; + +/** Shared report helpers */ +import { exportWorkbook, ExportSheetSpec } from '../cashier/cashier-export'; + +/** + * One posting rhythm, as the product configures it. + * + * The enum values are the product's own {@code interest_posting_period_enum}; they are not + * consecutive, and 2 and 3 are unused in this platform. + */ +interface PostingPeriod { + enumValue: number; + key: string; + label: string; + /** What "the end of the cycle" means for this rhythm, in the officer's words. */ + cadence: string; + /** What the period control is asking for, in the terms this rhythm is read in. */ + pickLabel: string; + /** The unit the control counts in, for the "N with postings" hint. */ + pickUnit: string; +} + +@Component({ + selector: 'mifosx-interest-report', + templateUrl: './interest-report.component.html', + styleUrls: ['./interest-report.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent + ] +}) +export class InterestReportComponent implements OnInit { + private service = inject(SavingsDailyInterestService); + private router = inject(Router); + + /** + * Each rhythm is its own report. + * + * A monthly product's month-end posting and a quarterly product's quarter-end posting are + * different events on different dates; listing them together reads as one uneven series and + * makes a month's total impossible to state. So an account appears under the rhythm its own + * product posts on, and nowhere else. + */ + readonly periods: PostingPeriod[] = [ + { + enumValue: 1, + key: 'daily', + label: 'Daily', + cadence: 'Posted every day', + pickLabel: 'Posted on', + pickUnit: 'day' + }, + { + enumValue: 4, + key: 'monthly', + label: 'Monthly', + cadence: 'Posted at each month end', + pickLabel: 'Month end', + pickUnit: 'month end' + }, + { + enumValue: 5, + key: 'quarterly', + label: 'Quarterly', + cadence: 'Posted at each quarter end', + pickLabel: 'Quarter end', + pickUnit: 'quarter end' + }, + { + enumValue: 6, + key: 'halfYearly', + label: 'Half-yearly', + cadence: 'Posted every six months', + pickLabel: 'Half-year end', + pickUnit: 'half-year end' + }, + { + enumValue: 7, + key: 'yearly', + label: 'Yearly', + cadence: 'Posted at each year end', + pickLabel: 'Year end', + pickUnit: 'year end' + } + ]; + + activePeriod = 4; + + /** Every posting, across all rhythms — the tabs read from this. */ + rows: SavingsInterestPostingRow[] = []; + loading = false; + errorMessage = ''; + + /** Filters, shared by every rhythm. */ + fromDate = ''; + toDate = ''; + search = ''; + + ngOnInit(): void { + this.load(); + } + + /** + * Loads every posting once and splits it by rhythm in the browser. + * + * One request rather than five: the tab counts have to be right before a tab is opened, and + * asking per rhythm would leave them blank until each was visited. + */ + load(): void { + this.loading = true; + this.errorMessage = ''; + + this.service + .getPostings({ + fromDate: this.fromDate || undefined, + toDate: this.toDate || undefined + }) + .subscribe({ + next: (data) => { + this.rows = data ?? []; + this.loading = false; + }, + error: (err: any) => { + this.rows = []; + this.loading = false; + this.errorMessage = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the interest postings.'; + } + }); + } + + // ── Per-rhythm views ─────────────────────────────────────────────────────── + + /** + * The period each rhythm is narrowed to, keyed by its enum. + * + * Each rhythm carries its own, because the unit an officer picks differs: a daily report is read + * one day at a time, a monthly one a month at a time. A single shared control would force the + * quarterly report to be filtered by a day, which selects one posting date and hides the rest of + * the quarter. + */ + periodPick: Record = {}; + + private matchesPick(row: SavingsInterestPostingRow, periodEnum: number): boolean { + const pick = this.periodPick[periodEnum]; + return !pick || row.posting_date === pick; + } + + /** + * The cycle ends a rhythm actually posted on, each with how many accounts were paid. + * + * A dropdown of real posting dates rather than a free date picker: a rhythm posts on a handful of + * dates a year, and a calendar would offer three hundred and sixty-odd days that hold nothing. + * The account count sits in the option itself, so the answer to "how many accounts were paid at + * this quarter end" is visible before the filter is even applied. + */ + availablePicks(periodEnum: number): { date: string; accounts: number; total: number }[] { + const byDate = new Map(); + for (const row of this.rows) { + if (row.posting_period_enum !== periodEnum || !row.posting_date) { + continue; + } + const list = byDate.get(row.posting_date); + if (list) { + list.push(row); + } else { + byDate.set(row.posting_date, [row]); + } + } + + return [...byDate.entries()] + .sort((a, b) => b[0].localeCompare(a[0])) + .map( + ([ + date, + rows + ]) => ({ + date, + accounts: new Set(rows.map((r) => r.savings_account_id)).size, + total: rows.reduce((s, r) => s + Number(r.interest_amount ?? 0), 0) + }) + ); + } + + clearPick(periodEnum: number): void { + delete this.periodPick[periodEnum]; + } + + /** + * Products whose postings do not follow the rhythm they are configured for. + * + * A quarterly product posts four times a year. Where one shows a hundred and thirty-five posting + * dates, something other than this ledger has paid it — stock Fineract's own savings interest job + * writes backdated postings a day at a time and pays no attention to the product's posting + * period. The report cannot hide those: the money reached the members and the transactions are + * real. What it can do is say so, so a total read off this screen is not mistaken for the rhythm + * the product is meant to keep. + * + * The threshold is generous — a rhythm is only called out at more than double the dates it could + * legitimately produce across the range on display, so a backdated correction or a re-run does + * not raise it. + */ + get rhythmWarnings(): { product: string; label: string; dates: number; expected: string }[] { + const expectedMax: Record = { 1: 100000, 4: 24, 5: 8, 6: 4, 7: 2 }; + const expectedWord: Record = { + 1: 'daily', + 4: 'about one a month', + 5: 'about four a year', + 6: 'two a year', + 7: 'one a year' + }; + + const byProduct = new Map }>(); + for (const row of this.rows) { + const entry = byProduct.get(row.product_name); + if (entry) { + entry.dates.add(row.posting_date); + } else { + byProduct.set(row.product_name, { + label: this.periods.find((p) => p.enumValue === row.posting_period_enum)?.label ?? '—', + period: row.posting_period_enum, + dates: new Set([row.posting_date]) + }); + } + } + + return [...byProduct.entries()] + .filter( + ([ + , + v + ]) => v.dates.size > (expectedMax[v.period] ?? 100000) + ) + .map( + ([ + product, + v + ]) => ({ + product, + label: v.label, + dates: v.dates.size, + expected: expectedWord[v.period] ?? '—' + }) + ); + } + + private rowsFor(periodEnum: number): SavingsInterestPostingRow[] { + const needle = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (r.posting_period_enum !== periodEnum) { + return false; + } + if (!this.matchesPick(r, periodEnum)) { + return false; + } + if (needle) { + const hay = `${r.account_no} ${r.client_name ?? ''} ${r.product_name}`.toLowerCase(); + if (!hay.includes(needle)) { + return false; + } + } + return true; + }); + } + + get visibleRows(): SavingsInterestPostingRow[] { + return this.rowsFor(this.activePeriod); + } + + countFor(periodEnum: number): number { + return this.rowsFor(periodEnum).length; + } + + totalFor(periodEnum: number): number { + return this.rowsFor(periodEnum).reduce((s, r) => s + Number(r.interest_amount ?? 0), 0); + } + + get visibleTotal(): number { + return this.totalFor(this.activePeriod); + } + + /** Distinct accounts paid on the tab in view — a count of rows would double-count re-postings. */ + get visibleAccounts(): number { + return new Set(this.visibleRows.map((r) => r.savings_account_id)).size; + } + + get activeMeta(): PostingPeriod { + return this.periods.find((p) => p.enumValue === this.activePeriod) ?? this.periods[1]; + } + + /** + * The postings on the tab in view, grouped by the day they were paid. + * + * A posting run pays a whole product on one date, so the date is the unit an officer reconciles + * against — "what went out at this month end" — rather than the individual row. + */ + get groupedByDate(): { date: string; rows: SavingsInterestPostingRow[]; total: number; accounts: number }[] { + const byDate = new Map(); + for (const row of this.visibleRows) { + const key = row.posting_date ?? ''; + const list = byDate.get(key); + if (list) { + list.push(row); + } else { + byDate.set(key, [row]); + } + } + + return [...byDate.entries()] + .sort((a, b) => b[0].localeCompare(a[0])) + .map( + ([ + date, + rows + ]) => ({ + date, + rows, + total: rows.reduce((s, r) => s + Number(r.interest_amount ?? 0), 0), + accounts: new Set(rows.map((r) => r.savings_account_id)).size + }) + ); + } + + showPeriod(periodEnum: number): void { + this.activePeriod = periodEnum; + } + + resetFilters(): void { + this.fromDate = ''; + this.toDate = ''; + this.search = ''; + this.periodPick = {}; + this.load(); + } + + // ── Presentation ─────────────────────────────────────────────────────────── + + /** Posting dates arrive as ISO yyyy-MM-dd. */ + formatDate(value: string | null | undefined): string { + if (!value) { + return '—'; + } + const [ + y, + m, + d + ] = value.split('-').map(Number); + if (!y || !m || !d) { + return value; + } + return new Date(y, m - 1, d).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); + } + + openAccount(row: SavingsInterestPostingRow): void { + if (row.client_id == null) { + return; + } + this.router.navigate([ + '/clients', + row.client_id, + 'savings-accounts', + row.savings_account_id, + 'transactions' + ]); + } + + // ── Export ───────────────────────────────────────────────────────────────── + + /** + * One sheet per rhythm, so the workbook mirrors the screen. + * + * A rhythm with nothing posted still gets its sheet: an empty Quarterly sheet says the quarter + * has not ended yet, which a missing sheet would leave the reader to guess at. + */ + exportExcel(): void { + const stamp = `${this.fromDate || 'from inception'} to ${this.toDate || 'date'}`; + + const sheets: ExportSheetSpec[] = this.periods.map((p) => { + const rows = this.rowsFor(p.enumValue); + return { + sheetName: p.label, + title: `Savings Interest Posted — ${p.label}`, + subtitle: `${p.cadence} · ${stamp} · ${rows.length} posting(s), total ${this.totalFor(p.enumValue).toFixed(2)}`, + headers: [ + 'Posting Date', + 'Account No', + 'Customer', + 'Product', + 'Rate %', + 'Balance Before', + 'Interest Posted', + 'Balance After', + 'Branch' + ], + widths: [ + 14, + 20, + 28, + 26, + 9, + 16, + 16, + 16, + 22 + ], + rows: rows.map((r) => [ + this.formatDate(r.posting_date), + r.account_no, + r.client_name || '—', + r.product_name, + Number(r.interest_rate ?? 0), + Number(r.balance_before ?? 0), + Number(r.interest_amount ?? 0), + Number(r.balance_after ?? 0), + r.office_name || '—' + ]) + }; + }); + + exportWorkbook(sheets, `savings-interest-posted-${new Date().toISOString().slice(0, 10)}`); + } +} diff --git a/src/app/keyboards-shortcut-config.ts b/src/app/keyboards-shortcut-config.ts index 20cb6cd10..13041f668 100644 --- a/src/app/keyboards-shortcut-config.ts +++ b/src/app/keyboards-shortcut-config.ts @@ -31,7 +31,8 @@ export class KeyboardShortcutsConfiguration { shiftKey: false, altKey: false, key: 'n', - route: 'navigation' + route: 'navigation', + id: 'navigation_page' }, // Run Report - ctrl + t { @@ -50,17 +51,9 @@ export class KeyboardShortcutsConfiguration { shiftKey: false, altKey: false, key: 'i', - route: 'checker-inbox-and-tasks/checker-inbox' + route: 'checker-inbox-and-tasks/checker-inbox', + id: 'checker_inbox' }, - // Collection Sheet- ctrl + alt + o - // { - // title: 'Collection Sheet', - // ctrlKey: true, - // shiftKey: false, - // altKey: true, - // key: 'o', - // route: '' - // }, // Create Client - ctrl + c { title: 'Create Client', @@ -68,7 +61,8 @@ export class KeyboardShortcutsConfiguration { shiftKey: false, altKey: true, key: 'c', - route: 'clients/create' + route: 'clients/create', + id: 'create_client' }, // Create Group - ctrl + g { @@ -77,7 +71,8 @@ export class KeyboardShortcutsConfiguration { shiftKey: false, altKey: false, key: 'g', - route: 'groups/create' + route: 'groups/create', + id: 'create_group' }, // Create Center - ctrl + q { @@ -86,7 +81,8 @@ export class KeyboardShortcutsConfiguration { shiftKey: false, altKey: false, key: 'q', - route: 'centers/create' + route: 'centers/create', + id: 'create_center' }, // Frequent Posting - ctrl + f { @@ -95,7 +91,8 @@ export class KeyboardShortcutsConfiguration { shiftKey: false, altKey: false, key: 'f', - route: 'accounting/journal-entries/frequent-postings' + route: 'accounting/journal-entries/frequent-postings', + id: 'frequent_posting' }, // Closure Entries - ctrl + e { @@ -104,7 +101,8 @@ export class KeyboardShortcutsConfiguration { shiftKey: false, altKey: false, key: 'e', - route: 'accounting/closing-entries' + route: 'accounting/closing-entries', + id: 'closure_entries' }, // Journal Entry - ctrl + j { @@ -113,7 +111,8 @@ export class KeyboardShortcutsConfiguration { shiftKey: false, altKey: false, key: 'j', - route: 'accounting/journal-entries/create' + route: 'accounting/journal-entries/create', + id: 'journal_entry' }, // Reports - ctrl + r { @@ -122,7 +121,8 @@ export class KeyboardShortcutsConfiguration { shiftKey: false, altKey: false, key: 'r', - route: 'reports' + route: 'reports', + id: 'reports_page' }, // Accounting - ctrl + a { @@ -131,7 +131,8 @@ export class KeyboardShortcutsConfiguration { shiftKey: false, altKey: true, key: 'a', - route: 'accounting' + route: 'accounting', + id: 'accounting_page' }, // Save/Submit Forms - ctrl + s { diff --git a/src/app/kyc-expiry/kyc-expiry.component.html b/src/app/kyc-expiry/kyc-expiry.component.html new file mode 100644 index 000000000..5879612c5 --- /dev/null +++ b/src/app/kyc-expiry/kyc-expiry.component.html @@ -0,0 +1,430 @@ + + +
+ +
+
+

Expired KYC & Customer Outreach

+
+ Customers whose periodic re-KYC has lapsed: what each restriction stops, and the record of every attempt to + reach them. A customer drops off this list the moment their re-KYC is completed. +
+
+ +
+ + +
+ +
+ RBI Master Direction on KYC. An account is not closed or restricted merely because periodic + re-KYC is pending. Nothing stops for the first {{ policy.graceDays }} days past the due date; + debits stop after that, and the account is marked inoperative after + {{ policy.inoperativeDays }} days. Restrictions are applied to the customer's savings accounts + overnight and lift as soon as the re-KYC is completed. These credits are never held up: + {{ alwaysAllowed.join(' · ') }} +
+
+ + @if (errorMessage) { +
{{ errorMessage }}
+ } + + +
+ @for (key of stageKeys; track key) { + + } +
+ + +
+ + + + + + + + + + {{ visibleRows.length }} of {{ summary.total }} customers +
+ + + @if (loading) { +
Loading worklist…
+ } @else if (visibleRows.length === 0) { +
No customers match these filters.
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (r of visibleRows; track r.clientId) { + + + + + + + + + + + + + + + + + + + + + + + + @if (expanded === r.clientId) { + + + + } + } + +
CustomerAccountRiskKYC ExpiryPositionWhat is blockedNext stepOutreachActions
+ + + {{ r.clientName }} +
{{ r.clientId }}
+
+ {{ r.accountNo || '—' }} +
Branch {{ r.branchCode || '—' }}
+
+ {{ r.riskLabel }} + + {{ r.expiryDate }} +
{{ overdueText(r) }}
+
+ {{ r.meta.label }} + + @if (r.meta.blocked.length === 0) { + Nothing blocked — fully operative + } @else { + @for (b of r.meta.blocked; track b) { + {{ b }} + } + } + + @if (r.nextActionDate) { +
{{ r.nextActionLabel }}
+
{{ r.nextActionDate }}
+ } @else { + {{ r.nextActionLabel }} + } +
+ @if (r.contactCount === 0) { + Not contacted + } @else { + {{ r.contactCount }} attempt{{ r.contactCount > 1 ? 's' : '' }} +
last {{ r.lastContactDate }}
+ } + @if (r.acknowledged) { +
Customer reached
+ } +
+
+ + + + +
+
+ + +
+
+
+
+

Still allowed

+
    + @for (a of r.meta.allowed; track a) { +
  • {{ a }}
  • + } +
+
{{ r.meta.summary }}
+
+ +
+

Blocked

+ @if (r.meta.blocked.length === 0) { +
+ Nothing is restricted at this stage. The account operates normally while the customer is given + time to complete re-KYC. +
+ } @else { +
    + @for (b of r.meta.blocked; track b) { +
  • {{ b }}
  • + } +
+ } +
+ Never held up: {{ alwaysAllowed.join(' · ') }} +
+
+ +
+

Contact on file

+
+ Mobile{{ r.mobile || 'Not on file' }} +
+
+ Email{{ r.email || 'Not on file' }} +
+ @if (r.acknowledged) { +
+ Customer reached on {{ r.acknowledgedOn }} by {{ r.acknowledgedBy }}. + @if (r.acknowledgedNote) { +
{{ r.acknowledgedNote }}
+ } +
+ } +
+
+ +

Outreach history

+ @if (r.contacts.length === 0) { +
+ No attempt recorded yet. Use the actions on the row to log an email, SMS, call or visit. +
+ } @else { + + + + + + + + + + + + + @for (c of r.contacts; track c.date + c.channel + c.notes) { + + + + + + + + + } + +
DateChannelDestinationOutcomeOfficerNotes
{{ c.date }}{{ c.channelLabel }}{{ c.destinationMasked || '—' }} + {{ deliveryText(c) }} + {{ c.officer }}{{ c.notes }}
+ } +
+
+ } +
+ + +@if (contactOpen && contactRow) { +
+
+
+

Record customer contact

+ +
+ +
+
+ {{ contactRow.clientName }} + {{ contactRow.accountNo }} + {{ contactRow.meta.label }} +
+ + +
+ @for (c of channels; track c.key) { + + } +
+ +
+ @if (contactDestinationMissing) { + + No destination on file for this channel — the attempt will be recorded but nothing is sent. + } @else { + + @if (contactChannelTransmits) { + Will be sent to {{ contactDestination }} + } @else { + Recorded as made {{ contactDestination }} + } + } +
+ + + + + + + + @if (contactError) { +
{{ contactError }}
+ } +
+ +
+ + +
+
+
+} + + +@if (ackOpen && ackRow) { +
+
+
+

Mark customer as contacted

+ +
+ +
+
+ {{ ackRow.clientName }} + {{ ackRow.accountNo }} +
+ +
+ + This records that the customer was reached and told what is required. It does not complete the re-KYC — the + restriction ladder keeps running until the KYC record itself is renewed. +
+ + + + + + +
+ +
+ + +
+
+
+} + +@if (toastVisible) { +
{{ toastMsg }}
+} diff --git a/src/app/kyc-expiry/kyc-expiry.component.scss b/src/app/kyc-expiry/kyc-expiry.component.scss new file mode 100644 index 000000000..45a37f6d6 --- /dev/null +++ b/src/app/kyc-expiry/kyc-expiry.component.scss @@ -0,0 +1,814 @@ +/** + * 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 { + --k-navy: #1a237e; + --k-blue: #1d4ed8; + --k-blue-l: #eff6ff; + --k-green: #15803d; + --k-green-l: #dcfce7; + --k-amber: #b45309; + --k-amber-l: #fef3c7; + --k-red: #c0392b; + --k-red-l: #fdecea; + --k-crit: #7f1d1d; + --k-crit-l: #fee2e2; + --k-g50: #f8fafc; + --k-g100: #f1f5f9; + --k-g200: #e2e8f0; + --k-g300: #cbd5e1; + --k-g500: #64748b; + --k-g700: #334155; + --k-g900: #0f172a; + + display: block; + font-family: 'DM Sans', system-ui, sans-serif; + color: var(--k-g900); + background: var(--k-g50); + min-height: 100%; + padding: 16px; +} + +.mono { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 11.5px; +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ + +.ke-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.ke-head-t { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--k-navy); +} + +.ke-head-s { + margin-top: 3px; + font-size: 12px; + color: var(--k-g500); + max-width: 760px; +} + +/* The rule the ladder implements, stated once at the top. */ +.ke-policy { + display: flex; + gap: 10px; + padding: 11px 14px; + margin-bottom: 14px; + background: var(--k-blue-l); + border: 1px solid #bfdbfe; + border-radius: 7px; + font-size: 12px; + line-height: 1.55; + color: var(--k-g700); + + fa-icon { + color: var(--k-blue); + margin-top: 2px; + } +} + +.ke-allow-list { + font-weight: 600; + color: var(--k-green); +} + +.ke-warnbar { + padding: 9px 13px; + margin-bottom: 12px; + background: var(--k-amber-l); + border: 1px solid #fcd34d; + border-radius: 6px; + font-size: 12px; + color: var(--k-amber); +} + +/* ── Ladder cards ───────────────────────────────────────────────────────── */ + +.ke-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(215px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.ke-stat { + text-align: left; + padding: 12px 14px; + border: 1px solid var(--k-g200); + border-left: 4px solid var(--k-g300); + border-radius: 7px; + background: #fff; + font: inherit; + cursor: pointer; + transition: box-shadow 0.12s; + + &:hover { + box-shadow: 0 2px 8px rgb(15 23 42 / 9%); + } + + &--info { + border-left-color: var(--k-blue); + } + + &--warn { + border-left-color: var(--k-amber); + } + + &--danger { + border-left-color: var(--k-red); + } + + &--critical { + border-left-color: var(--k-crit); + } + + /* The selected rung stays obvious while its rows are being worked. */ + &--on { + background: var(--k-g100); + box-shadow: inset 0 0 0 1px var(--k-g300); + } +} + +.ke-stat-n { + font-size: 22px; + font-weight: 700; + line-height: 1.1; +} + +.ke-stat-l { + font-size: 12px; + font-weight: 700; + margin-top: 1px; +} + +.ke-stat-w { + font-size: 10.5px; + color: var(--k-g500); + margin-top: 4px; + line-height: 1.45; +} + +/* ── Filters ────────────────────────────────────────────────────────────── */ + +.ke-filters { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 10px 12px; + margin-bottom: 12px; + background: #fff; + border: 1px solid var(--k-g200); + border-radius: 7px; +} + +.ke-inp, +.ke-sel { + padding: 6px 10px; + border: 1px solid var(--k-g300); + border-radius: 5px; + font: inherit; + font-size: 12px; + background: #fff; + + &--full { + width: 100%; + } +} + +.ke-inp { + min-width: 240px; +} + +.ke-chk { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--k-g700); + cursor: pointer; +} + +.ke-badge { + padding: 1px 7px; + border-radius: 999px; + background: var(--k-red-l); + color: var(--k-red); + font-size: 10px; + font-weight: 700; +} + +.ke-spacer { + flex: 1; +} + +.ke-count { + font-size: 11.5px; + color: var(--k-g500); +} + +/* ── Buttons ────────────────────────────────────────────────────────────── */ + +.ke-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 13px; + border: 1px solid var(--k-g300); + border-radius: 5px; + background: #fff; + font: inherit; + font-size: 12px; + font-weight: 600; + color: var(--k-g700); + cursor: pointer; + + &:hover:not(:disabled) { + border-color: var(--k-blue); + color: var(--k-blue); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + &--slim { + padding: 5px 11px; + font-size: 11.5px; + } + + &--primary { + background: var(--k-blue); + border-color: var(--k-blue); + color: #fff; + + &:hover:not(:disabled) { + background: #1e40af; + color: #fff; + } + } + + &--ghost { + background: transparent; + } +} + +/* ── Table ──────────────────────────────────────────────────────────────── */ + +.ke-tablewrap { + overflow-x: auto; + background: #fff; + border: 1px solid var(--k-g200); + border-radius: 7px; +} + +.ke-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + th { + text-align: left; + padding: 9px 10px; + background: var(--k-g100); + border-bottom: 1px solid var(--k-g200); + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--k-g500); + white-space: nowrap; + } + + td { + padding: 9px 10px; + border-bottom: 1px solid var(--k-g100); + vertical-align: top; + } +} + +.ke-row { + cursor: pointer; + + &:hover { + background: var(--k-g50); + } + + &.open { + background: var(--k-blue-l); + } +} + +.ke-sub { + font-size: 10.5px; + color: var(--k-g500); + margin-top: 2px; + + &--red { + color: var(--k-red); + font-weight: 700; + } +} + +/* ── Badges ─────────────────────────────────────────────────────────────── */ + +.ke-stage { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + white-space: nowrap; + + &--info { + background: var(--k-blue-l); + color: var(--k-blue); + } + + &--warn { + background: var(--k-amber-l); + color: var(--k-amber); + } + + &--danger { + background: var(--k-red-l); + color: var(--k-red); + } + + &--critical { + background: var(--k-crit-l); + color: var(--k-crit); + } +} + +.ke-risk { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 700; + + &--low { + background: var(--k-green-l); + color: var(--k-green); + } + + &--mid { + background: var(--k-amber-l); + color: var(--k-amber); + } + + &--high { + background: var(--k-red-l); + color: var(--k-red); + } +} + +.ke-block-cell { + max-width: 230px; +} + +.ke-blocked { + display: inline-block; + margin: 0 3px 3px 0; + padding: 1px 7px; + border-radius: 4px; + background: var(--k-red-l); + color: var(--k-red); + font-size: 10px; + font-weight: 600; +} + +/* Nothing restricted is good news, not an absence of information. */ +.ke-nothing { + font-size: 11px; + font-weight: 600; + color: var(--k-green); +} + +.ke-next { + font-size: 11px; + font-weight: 600; + color: var(--k-g700); +} + +.ke-none { + font-size: 11px; + font-weight: 700; + color: var(--k-red); +} + +.ke-times { + font-size: 11px; + font-weight: 600; +} + +.ke-ack { + margin-top: 3px; + font-size: 10px; + font-weight: 700; + color: var(--k-green); +} + +/* ── Row actions ────────────────────────────────────────────────────────── */ + +.ke-act-cell { + white-space: nowrap; +} + +.ke-acts { + display: flex; + gap: 4px; + margin-bottom: 4px; +} + +.ke-ico { + width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--k-g300); + border-radius: 5px; + background: #fff; + color: var(--k-g700); + font-size: 11px; + cursor: pointer; + + &:hover:not(:disabled) { + border-color: var(--k-blue); + color: var(--k-blue); + background: var(--k-blue-l); + } + + /* A channel with no destination on file cannot be attempted. */ + &:disabled { + opacity: 0.35; + cursor: not-allowed; + } +} + +.ke-mini { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border: 1px solid var(--k-g300); + border-radius: 5px; + background: #fff; + font: inherit; + font-size: 10.5px; + font-weight: 600; + color: var(--k-g700); + cursor: pointer; + + &:hover:not(:disabled) { + border-color: var(--k-blue); + color: var(--k-blue); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + &--go { + border-color: var(--k-blue); + color: var(--k-blue); + } +} + +/* ── Expanded detail ────────────────────────────────────────────────────── */ + +.ke-detail { + background: var(--k-g50); + + > td { + padding: 14px 16px; + } +} + +.ke-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.ke-panel { + padding: 11px 13px; + background: #fff; + border: 1px solid var(--k-g200); + border-radius: 6px; + + h4 { + margin: 0 0 7px; + font-size: 11.5px; + font-weight: 700; + color: var(--k-g700); + } + + ul { + margin: 0; + padding-left: 17px; + font-size: 11.5px; + line-height: 1.7; + } +} + +.ke-panel-note { + margin-top: 7px; + font-size: 10.5px; + color: var(--k-g500); + line-height: 1.5; + + &--allow { + color: var(--k-green); + font-weight: 600; + } +} + +.ke-kv { + display: flex; + justify-content: space-between; + gap: 10px; + padding: 3px 0; + font-size: 11.5px; + + span { + color: var(--k-g500); + } +} + +.ke-ack-box { + margin-top: 8px; + padding: 7px 9px; + background: var(--k-green-l); + border-radius: 5px; + font-size: 11px; + color: var(--k-green); +} + +.ke-hist-h { + margin: 0 0 8px; + font-size: 12px; + font-weight: 700; + color: var(--k-g700); +} + +.ke-subtable { + width: 100%; + border-collapse: collapse; + background: #fff; + border: 1px solid var(--k-g200); + border-radius: 6px; + font-size: 11.5px; + + th { + text-align: left; + padding: 7px 9px; + background: var(--k-g100); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--k-g500); + } + + td { + padding: 7px 9px; + border-top: 1px solid var(--k-g100); + vertical-align: top; + } +} + +.ke-notes { + max-width: 380px; + line-height: 1.5; +} + +.ke-dlv { + display: inline-block; + padding: 1px 7px; + border-radius: 4px; + font-size: 10px; + font-weight: 700; + + &--ok { + background: var(--k-green-l); + color: var(--k-green); + } + + /* Anything that is not SENT means the customer was not reached — it must not read as success. */ + &--bad { + background: var(--k-amber-l); + color: var(--k-amber); + } +} + +.ke-empty { + padding: 34px; + text-align: center; + font-size: 12.5px; + color: var(--k-g500); + background: #fff; + border: 1px solid var(--k-g200); + border-radius: 7px; +} + +/* ── Modal ──────────────────────────────────────────────────────────────── */ + +.ke-modal-bg { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 45%); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +} + +.ke-modal { + width: 100%; + max-width: 560px; + max-height: 90vh; + overflow-y: auto; + background: #fff; + border-radius: 9px; + box-shadow: 0 18px 50px rgb(15 23 42 / 26%); + + &--sm { + max-width: 460px; + } +} + +.ke-modal-h { + display: flex; + align-items: center; + justify-content: space-between; + padding: 13px 17px; + border-bottom: 1px solid var(--k-g200); + + h3 { + margin: 0; + font-size: 14px; + font-weight: 700; + color: var(--k-navy); + } +} + +.ke-x { + border: 0; + background: transparent; + font-size: 22px; + line-height: 1; + color: var(--k-g500); + cursor: pointer; +} + +.ke-modal-b { + padding: 15px 17px; +} + +.ke-modal-f { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 17px; + border-top: 1px solid var(--k-g200); +} + +.ke-modal-who { + display: flex; + align-items: center; + gap: 9px; + flex-wrap: wrap; + padding-bottom: 11px; + margin-bottom: 11px; + border-bottom: 1px solid var(--k-g100); + font-size: 12.5px; +} + +.ke-lbl { + display: block; + margin: 11px 0 5px; + font-size: 11px; + font-weight: 700; + color: var(--k-g700); +} + +.ke-req { + color: var(--k-red); +} + +.ke-chan { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ke-chan-b { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 11px; + border: 1px solid var(--k-g300); + border-radius: 999px; + background: #fff; + font: inherit; + font-size: 11.5px; + font-weight: 600; + color: var(--k-g700); + cursor: pointer; + + &:hover { + border-color: var(--k-blue); + } + + &.active { + background: var(--k-blue); + border-color: var(--k-blue); + color: #fff; + } +} + +/* Says plainly whether anything will actually be sent. */ +.ke-dest { + margin-top: 9px; + padding: 7px 10px; + background: var(--k-green-l); + border-radius: 5px; + font-size: 11px; + color: var(--k-green); + + &--missing { + background: var(--k-amber-l); + color: var(--k-amber); + } +} + +.ke-ta { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--k-g300); + border-radius: 5px; + font: inherit; + font-size: 12px; + resize: vertical; +} + +.ke-note-box { + display: flex; + gap: 8px; + padding: 9px 11px; + background: var(--k-blue-l); + border-radius: 5px; + font-size: 11px; + line-height: 1.55; + color: var(--k-g700); + + fa-icon { + color: var(--k-blue); + margin-top: 1px; + } +} + +.ke-err { + margin-top: 10px; + padding: 7px 10px; + background: var(--k-red-l); + border-radius: 5px; + font-size: 11.5px; + color: var(--k-red); +} + +.ke-toast { + position: fixed; + bottom: 22px; + left: 50%; + transform: translateX(-50%); + padding: 10px 20px; + background: var(--k-g900); + color: #fff; + border-radius: 6px; + font-size: 12.5px; + box-shadow: 0 8px 22px rgb(15 23 42 / 30%); + z-index: 1100; +} diff --git a/src/app/kyc-expiry/kyc-expiry.component.ts b/src/app/kyc-expiry/kyc-expiry.component.ts new file mode 100644 index 000000000..70054c079 --- /dev/null +++ b/src/app/kyc-expiry/kyc-expiry.component.ts @@ -0,0 +1,432 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; + +/** Custom Modules */ +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** Custom Services */ +import { ClientsService } from '../clients/clients.service'; +import { toIsoDate } from 'app/core/utils/api-date'; + +/** Report data + computation */ +import { + ALWAYS_ALLOWED, + CONTACT_CHANNELS, + KYC_EXPIRY_POLICY, + KYC_EXPIRY_STAGES, + KycContactRow, + KycExpiryRow, + KycExpiryStage, + KycExpirySummary, + buildExpiryRow, + channelLabel, + expirySummary, + sortWorklist +} from './kyc-expiry.compute'; + +/** + * The re-KYC expiry worklist. + * + * Answers two questions an officer has about an overdue customer: what has stopped working on the + * account, and what has been done to reach them. The restriction ladder is read-only — it is + * derived from the expiry date, not stored — so this screen never has to be kept in step with the + * account's real state; it says what the policy implies and leaves the account alone. + * + * Recording an outreach attempt does write: it posts to the contact-log API the KYC exceptions + * screen already uses, so both screens read the same history. + */ +@Component({ + selector: 'mifosx-kyc-expiry', + templateUrl: './kyc-expiry.component.html', + styleUrls: ['./kyc-expiry.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent + ] +}) +export class KycExpiryComponent implements OnInit { + private clientsService = inject(ClientsService); + private router = inject(Router); + + loading = false; + errorMessage = ''; + + rows: KycExpiryRow[] = []; + expanded: number | null = null; + + /** Filters */ + stageFilter: KycExpiryStage | '' = ''; + riskFilter = 0; + search = ''; + notContactedOnly = false; + + /** Contact dialog */ + contactOpen = false; + contactRow: KycExpiryRow | null = null; + contactChannel = 'EMAIL'; + contactNotes = ''; + contactOfficer = ''; + contactSaving = false; + contactError = ''; + + /** Acknowledge dialog */ + ackOpen = false; + ackRow: KycExpiryRow | null = null; + ackNote = ''; + ackOfficer = ''; + + toastMsg = ''; + toastVisible = false; + private toastTimer: any; + + readonly channels = CONTACT_CHANNELS; + readonly policy = KYC_EXPIRY_POLICY; + readonly alwaysAllowed = ALWAYS_ALLOWED; + readonly stageMeta = KYC_EXPIRY_STAGES; + /** DUE_SOON is absent by design: nothing on this screen has an unexpired KYC. */ + readonly stageKeys: KycExpiryStage[] = [ + 'INOPERATIVE', + 'PARTIAL_FREEZE', + 'GRACE' + ]; + + ngOnInit(): void { + this.load(); + } + + /** + * Loads the customers whose re-KYC has lapsed, then folds in each one's outreach history. + * + * The server already restricts the list to lapsed records, so nothing is filtered by date here — + * a customer whose renewal is still ahead never reaches this screen, and one who renews drops out + * of it on the next load. + */ + load(): void { + this.loading = true; + this.errorMessage = ''; + const today = new Date().toISOString().slice(0, 10); + + this.clientsService.getLapsedKyc().subscribe({ + next: (records: any[]) => { + const clients = (records || []).map((r) => this.mapLapsed(r)).filter((c) => !!c.expiryDate); + + if (clients.length === 0) { + this.rows = []; + this.loading = false; + return; + } + + forkJoin( + clients.map((c) => + this.clientsService.getKycContactLog(c.clientId).pipe( + map((logs: any[]) => (logs || []).map((l) => this.mapContact(l))), + // One customer's history failing must not blank the whole worklist — an overdue + // account the branch cannot see is worse than one shown without its outreach. + catchError(() => of([] as KycContactRow[])) + ) + ) + ).subscribe({ + next: (histories) => { + this.rows = sortWorklist(clients.map((c, i) => buildExpiryRow(c, histories[i], today))); + this.loading = false; + }, + error: () => { + this.rows = sortWorklist(clients.map((c) => buildExpiryRow(c, [], today))); + this.loading = false; + this.errorMessage = 'Contact history could not be loaded. The worklist below is still accurate.'; + } + }); + }, + error: (err: any) => { + this.rows = []; + this.loading = false; + this.errorMessage = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the lapsed KYC worklist.'; + } + }); + } + + /** Maps one ClientKycData record onto what the ladder needs. */ + private mapLapsed(r: any): { + clientId: number; + clientName: string; + accountNo: string; + branchCode: string; + riskCategory: number; + expiryDate: string; + mobile: string; + email: string; + } { + return { + clientId: r?.clientId, + clientName: r?.clientDisplayName || '—', + accountNo: r?.clientAccountNo || '', + branchCode: r?.clientOfficeName || '', + riskCategory: r?.riskCategory ?? 1, + expiryDate: toIsoDate(r?.expiryDate), + // Only the masked forms come back from the server, which is what the dialog shows and all it + // needs — the destination itself is resolved server-side when a notice is sent. + mobile: r?.clientMobileMasked || (r?.hasMobile ? 'On file' : ''), + email: r?.clientEmailMasked || (r?.hasEmail ? 'On file' : '') + }; + } + + private mapContact(log: any): KycContactRow { + const status = log?.deliveryStatus || 'NOT_APPLICABLE'; + return { + date: toIsoDate(log?.contactedOn || log?.createdOn), + channel: log?.channel || '', + channelLabel: channelLabel(log?.channel || ''), + notes: log?.notes || '', + officer: log?.officerName || log?.loggedByUsername || '—', + deliveryStatus: status, + delivered: status === 'SENT', + destinationMasked: log?.destinationMasked || '' + }; + } + + // ── Filtering ────────────────────────────────────────────────────────────── + + get visibleRows(): KycExpiryRow[] { + const needle = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.stageFilter && r.stage !== this.stageFilter) { + return false; + } + if (this.riskFilter && r.riskCategory !== this.riskFilter) { + return false; + } + if (this.notContactedOnly && r.contactCount > 0) { + return false; + } + if (needle) { + const hay = `${r.clientName} ${r.clientId} ${r.accountNo}`.toLowerCase(); + if (!hay.includes(needle)) { + return false; + } + } + return true; + }); + } + + get summary(): KycExpirySummary { + return expirySummary(this.rows); + } + + resetFilters(): void { + this.stageFilter = ''; + this.riskFilter = 0; + this.search = ''; + this.notContactedOnly = false; + } + + toggleStage(stage: KycExpiryStage): void { + this.stageFilter = this.stageFilter === stage ? '' : stage; + this.expanded = null; + } + + toggle(clientId: number): void { + this.expanded = this.expanded === clientId ? null : clientId; + } + + // ── Outreach ─────────────────────────────────────────────────────────────── + + /** + * Opens the contact dialog on a sensible channel. + * + * Email is the default only where the customer has one — offering a channel with no destination + * behind it produces a log entry that looks like an attempt but reached nobody. + */ + openContact(row: KycExpiryRow, channel: string, event: Event): void { + event.stopPropagation(); + this.contactRow = row; + this.contactChannel = channel || (row.email ? 'EMAIL' : row.mobile ? 'SMS' : 'PHONE_CALL'); + this.contactNotes = ''; + this.contactOfficer = ''; + this.contactError = ''; + this.contactOpen = true; + } + + /** Whether the chosen channel has a destination on file. */ + get contactDestination(): string { + if (!this.contactRow) { + return ''; + } + const ch = this.contactChannel; + if (ch === 'EMAIL') { + return this.contactRow.email; + } + if (ch === 'SMS' || ch === 'WHATSAPP' || ch === 'PHONE_CALL') { + return this.contactRow.mobile; + } + // A visit is made in person, so there is no destination to check. + return 'in person'; + } + + get contactDestinationMissing(): boolean { + return !this.contactDestination; + } + + get contactChannelTransmits(): boolean { + return !!CONTACT_CHANNELS.find((c) => c.key === this.contactChannel)?.transmits; + } + + saveContact(): void { + if (!this.contactRow || !this.contactNotes.trim()) { + this.contactError = 'Enter what was said or attempted — the note is the record.'; + return; + } + + this.contactSaving = true; + this.contactError = ''; + const row = this.contactRow; + + this.clientsService + .saveKycContactLog(row.clientId, { + channel: this.contactChannel, + notes: this.contactNotes.trim(), + officerName: this.contactOfficer.trim() || undefined + }) + .subscribe({ + next: (res: any) => { + this.contactSaving = false; + this.contactOpen = false; + // The log row is written whether or not the message left the gateway, so the officer is + // told which of the two happened rather than being left to assume it was delivered. + const label = channelLabel(this.contactChannel); + const status = res?.deliveryStatus; + this.toast( + status && status !== 'SENT' && status !== 'NOT_APPLICABLE' + ? `${label} recorded for ${row.clientName}, but it was not delivered — see the history.` + : `${label} recorded for ${row.clientName}` + ); + this.load(); + }, + error: (err: any) => { + this.contactSaving = false; + this.contactError = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the contact attempt.'; + } + }); + } + + // ── Acknowledgement ──────────────────────────────────────────────────────── + + /** + * Marks that the customer has been reached and understands what is required. + * + * This is a note on the outreach, not a substitute for the re-KYC: the ladder keeps running, and + * the account is only released once the KYC record itself is renewed on the KYC screen. + */ + openAck(row: KycExpiryRow, event: Event): void { + event.stopPropagation(); + this.ackRow = row; + this.ackNote = ''; + this.ackOfficer = ''; + this.ackOpen = true; + } + + saveAck(): void { + if (!this.ackRow) { + return; + } + const row = this.ackRow; + // Recorded through the same contact log so the acknowledgement sits in one history with the + // attempts that led to it, rather than in a store of its own. + this.clientsService + .saveKycContactLog(row.clientId, { + channel: 'PHONE_CALL', + notes: `[ACKNOWLEDGED] ${this.ackNote.trim() || 'Customer reached and informed of re-KYC requirement.'}`, + officerName: this.ackOfficer.trim() || undefined + }) + .subscribe({ + next: () => { + row.acknowledged = true; + row.acknowledgedOn = new Date().toISOString().slice(0, 10); + row.acknowledgedBy = this.ackOfficer.trim() || 'Current user'; + row.acknowledgedNote = this.ackNote.trim(); + this.ackOpen = false; + this.toast(`${row.clientName} marked as contacted`); + this.load(); + }, + error: () => { + this.ackOpen = false; + this.toast('Could not save the acknowledgement.'); + } + }); + } + + // ── Navigation ───────────────────────────────────────────────────────────── + + /** The KYC screen is where the record is actually renewed. */ + openKyc(row: KycExpiryRow, event: Event): void { + event.stopPropagation(); + this.router.navigate([ + '/clients', + row.clientId, + 'kyc' + ]); + } + + // ── Presentation ─────────────────────────────────────────────────────────── + + stageClass(stage: KycExpiryStage): string { + return `ke-stage ke-stage--${KYC_EXPIRY_STAGES[stage].tone}`; + } + + riskClass(category: number): string { + const key = category === 3 ? 'high' : category === 2 ? 'mid' : 'low'; + return `ke-risk ke-risk--${key}`; + } + + /** "214 days overdue" / "in 17 days", as an officer would say it. */ + overdueText(row: KycExpiryRow): string { + if (row.daysOverdue > 0) { + return `${row.daysOverdue} days overdue`; + } + if (row.daysOverdue === 0) { + return 'Due today'; + } + return `Due in ${Math.abs(row.daysOverdue)} days`; + } + + deliveryClass(status: string): string { + return status === 'SENT' ? 'ke-dlv ke-dlv--ok' : 'ke-dlv ke-dlv--bad'; + } + + deliveryText(c: KycContactRow): string { + switch (c.deliveryStatus) { + case 'SENT': + return 'Delivered'; + case 'FAILED': + return 'Failed'; + case 'SKIPPED_NO_DESTINATION': + return 'No destination on file'; + case 'PROVIDER_NOT_CONFIGURED': + return 'Gateway not configured'; + default: + return 'Recorded offline'; + } + } + + toast(msg: string): void { + this.toastMsg = msg; + this.toastVisible = true; + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); + } +} diff --git a/src/app/kyc-expiry/kyc-expiry.compute.ts b/src/app/kyc-expiry/kyc-expiry.compute.ts new file mode 100644 index 000000000..d92ec8cfc --- /dev/null +++ b/src/app/kyc-expiry/kyc-expiry.compute.ts @@ -0,0 +1,328 @@ +/** + * 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/. + */ + +/** + * What happens to an account once its periodic re-KYC falls due. + * + * RBI's Master Direction on KYC does not treat an overdue re-KYC as a reason to shut a customer + * out. The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not + * to be closed merely because periodic KYC is pending, and that restrictions come only after the + * customer has been given notice and time to respond. So the ladder below escalates slowly, and + * every stage before PARTIAL_FREEZE leaves the account fully operative. + * + * The day counts are the bank's own policy, not RBI's — the Direction requires "adequate notice" + * without fixing a number — so they live in one place here and can be moved without touching the + * logic that reads them. + */ + +/** Where an overdue customer currently sits on the restriction ladder. */ +export type KycExpiryStage = + /** Due date approaching; nothing is restricted and notices are going out. */ + | 'DUE_SOON' + /** Past the due date but inside the grace window. Still fully operative. */ + | 'GRACE' + /** Debits stopped, credits still accepted. The first actual restriction. */ + | 'PARTIAL_FREEZE' + /** Both directions restricted, barring the always-allowed credits. */ + | 'INOPERATIVE'; + +/** Policy day counts, measured forward from the KYC expiry date. */ +export const KYC_EXPIRY_POLICY = { + /** Days past expiry before debits are stopped. */ + graceDays: 90, + /** Days past expiry before the account is marked inoperative. */ + inoperativeDays: 180 +}; + +/** + * Credits that must keep landing whatever the KYC position. + * + * Government benefit transfers are the one category RBI has specifically directed banks not to + * hold up, and interest and the bank's own loan recoveries are the account working rather than + * the customer transacting on it. + */ +export const ALWAYS_ALLOWED = [ + 'DBT / government subsidy credit', + 'Interest credit', + 'Own loan EMI auto-debit', + 'Reversal of a bank-initiated entry' +]; + +export interface KycExpiryStageMeta { + stage: KycExpiryStage; + label: string; + /** Colour key the template maps to a badge class. */ + tone: 'info' | 'warn' | 'danger' | 'critical'; + /** What the customer can still do, in the officer's words. */ + allowed: string[]; + /** What has stopped. Empty while the account is still fully operative. */ + blocked: string[]; + /** One line telling the officer what this stage means. */ + summary: string; +} + +export const KYC_EXPIRY_STAGES: Record = { + DUE_SOON: { + stage: 'DUE_SOON', + label: 'Re-KYC due soon', + tone: 'info', + allowed: [ + 'All debits', + 'All credits', + 'Cheque book & channels' + ], + blocked: [], + summary: 'Nothing is restricted. Notices are going out so the customer can complete re-KYC in time.' + }, + GRACE: { + stage: 'GRACE', + label: 'Overdue — grace period', + tone: 'warn', + allowed: [ + 'All debits', + 'All credits', + 'Cheque book & channels' + ], + blocked: [], + summary: 'Past the due date but still fully operative. Restrictions only follow if the customer does not respond.' + }, + PARTIAL_FREEZE: { + stage: 'PARTIAL_FREEZE', + label: 'Debit freeze', + tone: 'danger', + allowed: [ + 'All credits', + 'DBT / subsidy', + 'Interest credit' + ], + blocked: [ + 'Cash withdrawal', + 'Transfers & NEFT / RTGS / UPI out', + 'Cheque issue & clearing debits', + 'New standing instructions' + ], + summary: 'Debits are stopped; money can still come in. The customer must visit the branch to have this lifted.' + }, + INOPERATIVE: { + stage: 'INOPERATIVE', + label: 'Inoperative', + tone: 'critical', + allowed: [ + 'DBT / subsidy', + 'Interest credit' + ], + blocked: [ + 'All debits', + 'Ordinary credits', + 'Cheque book issue', + 'All digital channels', + 'New products & renewals' + ], + summary: 'Both directions restricted. Only the credits RBI requires be let through are still accepted.' + } +}; + +/** A customer whose re-KYC is due or overdue, with the ladder worked out. */ +export interface KycExpiryRow { + clientId: number; + clientName: string; + accountNo: string; + branchCode: string; + /** 1 Low / 2 Medium / 3 High. */ + riskCategory: number; + riskLabel: string; + expiryDate: string; + /** Negative while the due date is still ahead. */ + daysOverdue: number; + stage: KycExpiryStage; + meta: KycExpiryStageMeta; + mobile: string; + email: string; + /** Outreach already recorded against this customer. */ + contacts: KycContactRow[]; + contactCount: number; + lastContactDate: string; + /** True once an officer has confirmed the customer was reached and the case can rest. */ + acknowledged: boolean; + acknowledgedOn: string; + acknowledgedBy: string; + acknowledgedNote: string; + /** The day the next restriction bites, so the officer can warn the customer precisely. */ + nextActionDate: string; + nextActionLabel: string; +} + +/** One recorded attempt to reach the customer. */ +export interface KycContactRow { + date: string; + channel: string; + channelLabel: string; + notes: string; + officer: string; + /** SENT means it actually left the gateway; anything else means the customer was not reached. */ + deliveryStatus: string; + delivered: boolean; + destinationMasked: string; +} + +/** Channels an officer can record, matching the backend's KycContactChannel. */ +export const CONTACT_CHANNELS = [ + { key: 'EMAIL', label: 'Email', icon: 'envelope', transmits: true }, + { key: 'SMS', label: 'SMS', icon: 'comment-dots', transmits: true }, + { key: 'WHATSAPP', label: 'WhatsApp', icon: 'comment-dots', transmits: true }, + { key: 'PHONE_CALL', label: 'Phone Call', icon: 'phone', transmits: false }, + { key: 'BRANCH_VISIT', label: 'Branch Visit', icon: 'building-columns', transmits: false }, + { key: 'FIELD_VISIT', label: 'Field Visit', icon: 'user-check', transmits: false } +]; + +export function channelLabel(key: string): string { + return CONTACT_CHANNELS.find((c) => c.key === key)?.label ?? key; +} + +/** Days between two ISO dates, positive when `later` is after `earlier`. */ +function daysBetween(earlier: string, later: string): number { + const a = new Date(`${earlier}T00:00:00Z`).getTime(); + const b = new Date(`${later}T00:00:00Z`).getTime(); + return Math.round((b - a) / 86400000); +} + +function addDays(date: string, days: number): string { + const d = new Date(`${date}T00:00:00Z`); + return new Date(d.getTime() + days * 86400000).toISOString().slice(0, 10); +} + +/** + * Which rung of the ladder a customer is on, from how far past the due date they are. + * + * An acknowledged case is deliberately not exempt: acknowledging records that the customer was + * reached, which is not the same as the re-KYC being done, and letting it hold off a freeze would + * turn a contact note into a way of parking an overdue account indefinitely. + */ +export function expiryStage(daysOverdue: number): KycExpiryStage { + if (daysOverdue >= KYC_EXPIRY_POLICY.inoperativeDays) { + return 'INOPERATIVE'; + } + if (daysOverdue >= KYC_EXPIRY_POLICY.graceDays) { + return 'PARTIAL_FREEZE'; + } + if (daysOverdue >= 0) { + return 'GRACE'; + } + return 'DUE_SOON'; +} + +/** The next restriction and the day it lands, worded for the customer. */ +export function nextAction(expiryDate: string, stage: KycExpiryStage): { date: string; label: string } { + switch (stage) { + case 'DUE_SOON': + return { date: expiryDate, label: 'Re-KYC falls due' }; + case 'GRACE': + return { + date: addDays(expiryDate, KYC_EXPIRY_POLICY.graceDays), + label: 'Debit freeze applies' + }; + case 'PARTIAL_FREEZE': + return { + date: addDays(expiryDate, KYC_EXPIRY_POLICY.inoperativeDays), + label: 'Account marked inoperative' + }; + default: + // Nothing further is applied automatically — closure is a manual decision with its own notice. + return { date: '', label: 'No further automatic restriction' }; + } +} + +export const RISK_LABELS: Record = { 1: 'Low Risk', 2: 'Medium Risk', 3: 'High Risk' }; + +/** Builds one worklist row from a KYC record and its contact history. */ +export function buildExpiryRow( + kyc: { + clientId: number; + clientName: string; + accountNo?: string; + branchCode?: string; + riskCategory?: number; + expiryDate: string; + mobile?: string; + email?: string; + acknowledged?: boolean; + acknowledgedOn?: string; + acknowledgedBy?: string; + acknowledgedNote?: string; + }, + contacts: KycContactRow[], + today: string +): KycExpiryRow { + const daysOverdue = daysBetween(kyc.expiryDate, today); + const stage = expiryStage(daysOverdue); + const next = nextAction(kyc.expiryDate, stage); + const risk = kyc.riskCategory ?? 1; + const sorted = [...contacts].sort((a, b) => b.date.localeCompare(a.date)); + + return { + clientId: kyc.clientId, + clientName: kyc.clientName, + accountNo: kyc.accountNo || '', + branchCode: kyc.branchCode || '', + riskCategory: risk, + riskLabel: RISK_LABELS[risk] ?? 'Low Risk', + expiryDate: kyc.expiryDate, + daysOverdue, + stage, + meta: KYC_EXPIRY_STAGES[stage], + mobile: kyc.mobile || '', + email: kyc.email || '', + contacts: sorted, + contactCount: sorted.length, + lastContactDate: sorted.length ? sorted[0].date : '', + acknowledged: !!kyc.acknowledged, + acknowledgedOn: kyc.acknowledgedOn || '', + acknowledgedBy: kyc.acknowledgedBy || '', + acknowledgedNote: kyc.acknowledgedNote || '', + nextActionDate: next.date, + nextActionLabel: next.label + }; +} + +export interface KycExpirySummary { + total: number; + grace: number; + partialFreeze: number; + inoperative: number; + /** Lapsed customers nobody has reached out to yet — the real backlog. */ + notContacted: number; + acknowledged: number; +} + +export function expirySummary(rows: KycExpiryRow[]): KycExpirySummary { + return { + total: rows.length, + grace: rows.filter((r) => r.stage === 'GRACE').length, + partialFreeze: rows.filter((r) => r.stage === 'PARTIAL_FREEZE').length, + inoperative: rows.filter((r) => r.stage === 'INOPERATIVE').length, + notContacted: rows.filter((r) => r.contactCount === 0).length, + acknowledged: rows.filter((r) => r.acknowledged).length + }; +} + +/** Sorts the worklist so the most restricted, least contacted cases come first. */ +export function sortWorklist(rows: KycExpiryRow[]): KycExpiryRow[] { + const rank: Record = { + INOPERATIVE: 0, + PARTIAL_FREEZE: 1, + GRACE: 2, + DUE_SOON: 3 + }; + return [...rows].sort( + (a, b) => + rank[a.stage] - rank[b.stage] || + Number(a.acknowledged) - Number(b.acknowledged) || + a.contactCount - b.contactCount || + b.daysOverdue - a.daysOverdue + ); +} diff --git a/src/app/letter-mgmt/certificates/certificate-pdf.ts b/src/app/letter-mgmt/certificates/certificate-pdf.ts index 91aebd362..ddf2fb004 100644 --- a/src/app/letter-mgmt/certificates/certificate-pdf.ts +++ b/src/app/letter-mgmt/certificates/certificate-pdf.ts @@ -25,10 +25,68 @@ 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'; +/** + * The issuing bank's own identity, as set on the Institution Profile (Admin > Organization) — this + * file is shared by every certificate type (loan interest, share, balance, Form 60/15G/15H, + * hypothecation, NOC) and every tenant this app runs for, so nothing here can be a fixed name. + * These defaults only cover the moment before whichever screen opened this module has fetched the + * real profile and called {@link setBankIdentity}; they are never meant to reach a printed PDF. + */ +const bankIdentity = { + name: 'Bank', + address: '', + regNo: '', + contact: '', + /** City/state/country line used by the Share Certificate's own letterhead style. */ + location: '', + /** Short label for the circular seal graphic — two lines, e.g. bank's short name + type. */ + sealPrimary: 'BANK', + sealSecondary: '' +}; + +/** Called once the caller has fetched the real Institution Profile, so every certificate built + * after this point — of any type — carries the actual bank's own details. */ +export function setBankIdentity(profile: { + bankName?: string; + statementHeaderName?: string; + shortName?: string; + bankType?: string; + addressLine1?: string; + addressLine2?: string; + city?: string; + state?: string; + country?: string; + licenseNumber?: string; + contactEmail?: string; + contactPhone?: string; +}): void { + bankIdentity.name = profile.statementHeaderName || profile.bankName || bankIdentity.name; + bankIdentity.address = [ + profile.addressLine1, + profile.addressLine2, + profile.city + ] + .filter((p) => !!p && String(p).trim()) + .join(', '); + bankIdentity.regNo = profile.licenseNumber ? `RBI Lic. No. ${profile.licenseNumber}` : ''; + bankIdentity.contact = [ + profile.contactEmail, + profile.contactPhone + ] + .filter((p) => !!p && String(p).trim()) + .join(' · '); + bankIdentity.location = [ + profile.city, + profile.state, + profile.country + ] + .filter((p) => !!p && String(p).trim()) + .join(', '); + bankIdentity.sealPrimary = (profile.shortName || profile.bankName || bankIdentity.name).toUpperCase(); + bankIdentity.sealSecondary = profile.bankType + ? profile.bankType.replace(/_/g, ' ').toUpperCase() + (/BANK$/i.test(profile.bankType) ? '' : ' BANK') + : ''; +} const C_TEXT: [number, number, number] = [ 15, @@ -141,14 +199,24 @@ function paintLetterhead( c.doc.setFont('times', 'bold'); c.doc.setFontSize(19); setText(c, c.accent); - c.doc.text(BANK_NAME, c.pageW / 2, y, { align: 'center' }); + c.doc.text(bankIdentity.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' }); + if (bankIdentity.address) { + c.doc.text(bankIdentity.address, c.pageW / 2, y, { align: 'center' }); + y += 4; + } + const regContactLine = [ + bankIdentity.regNo, + bankIdentity.contact + ] + .filter((p) => !!p) + .join(' · '); + if (regContactLine) { + c.doc.text(regContactLine, c.pageW / 2, y, { align: 'center' }); + } y += 5; setDraw(c, C_BORDER); c.doc.setLineWidth(0.3); @@ -350,11 +418,10 @@ function paintSignoff(c: PdfCtx, y: number, branch: string): number { c.doc.setFont('helvetica', 'bold'); c.doc.setFontSize(7); setText(c, c.accent); - c.doc.text('NEXORA', cx, cy - 1, { align: 'center' }); + c.doc.text(bankIdentity.sealPrimary, 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' }); + c.doc.text(bankIdentity.sealSecondary, cx, cy + 4, { align: 'center' }); return y + 28; } @@ -376,7 +443,7 @@ function paintFooter(c: PdfCtx, y: number): void { 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.`, + `Issued by ${bankIdentity.name} on ${formatLongDate(today())}. Computer generated — does not require physical signature for record purposes.`, c.pageW / 2, y, { align: 'center' } @@ -609,13 +676,15 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF { doc.setFont('times', 'bold'); doc.setFontSize(22); doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]); - doc.text('NEXORA CO-OPERATIVE BANK LTD.', pageW / 2, y, { align: 'center' }); + doc.text(bankIdentity.name.toUpperCase(), pageW / 2, y, { align: 'center' }); - y += 5; - doc.setFont('times', 'italic'); - doc.setFontSize(10.5); - doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]); - doc.text('Mumbai, Maharashtra, India', pageW / 2, y, { align: 'center' }); + if (bankIdentity.location) { + y += 5; + doc.setFont('times', 'italic'); + doc.setFontSize(10.5); + doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]); + doc.text(bankIdentity.location, pageW / 2, y, { align: 'center' }); + } y += 4; doc.setDrawColor(242, 237, 213); // light gold separator @@ -685,7 +754,7 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF { doc.setFontSize(11); doc.setTextColor(textDark[0], textDark[1], textDark[2]); - const bodyText = `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 Nexora Co-operative Bank Ltd., subject to the memorandum, articles of association, and bye-laws of the bank.`; + const bodyText = `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 ${bankIdentity.name}, subject to the memorandum, articles of association, and bye-laws of the bank.`; const bodyLines = doc.splitTextToSize(bodyText, pageW - 50); for (const line of bodyLines) { doc.text(line, pageW / 2, y, { align: 'center' }); @@ -763,7 +832,7 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF { // Row 1 drawTableCell(25, y, tableColW, tableRowH, 'Branch', true); - drawTableCell(25 + tableColW, y, tableColW, tableRowH, r.branch || 'Mumbai Main Branch', false); + drawTableCell(25 + tableColW, y, tableColW, tableRowH, r.branch || 'Head Office', false); drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Member Since', true); drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, formatLongDate(r.joiningDate), false); y += tableRowH; @@ -1168,7 +1237,7 @@ export function buildHypothecationPdf(r: HypothecationRecord): jsPDF { 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.` + `This is to certify that the asset described below stands hypothecated to ${bankIdentity.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][] = [ 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 index a0b6381fd..022a216f3 100644 --- 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 @@ -684,9 +684,9 @@
-
NEXORA
+
{{ bankSealPrimary }}
-
CO-OPERATIVE BANK
+
{{ bankSealSecondary }}
diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss index f099d25a1..dc84e930b 100644 --- a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss +++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss @@ -575,8 +575,17 @@ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -/* ───────── Print — only the page, no toolbar / dialog chrome ───────── */ +/* ───────── Print — only the page, no toolbar / dialog chrome ───────── + The document-level clip (`html, body { overflow: hidden }`) is lifted globally in main.scss's + print block; what remains here is this dialog's own clip — it opens with `maxHeight: '92vh'`, + which Angular Material carries onto the overlay pane / .mat-mdc-dialog-container as an inline + max-height plus overflow:auto. Freeing those lets the whole certificate flow onto the page in + normal static order (a fixed/absolute hack would pin it to the first page instead). */ @media print { + body > :not(.cdk-overlay-container) { + display: none !important; + } + :host { background: #fff; } @@ -585,11 +594,27 @@ display: none !important; } + .cdk-overlay-container, + .cdk-global-overlay-wrapper, + .cdk-overlay-pane, + .mat-mdc-dialog-container, + .mdc-dialog__surface { + position: static !important; + overflow: visible !important; + max-height: none !important; + height: auto !important; + max-width: none !important; + box-shadow: none !important; + padding: 0 !important; + } + .cpd-root { + position: static; + width: 100% !important; + height: auto !important; background: #fff; max-height: none; overflow: visible; - height: auto; } .cpd-page { @@ -599,6 +624,7 @@ width: 100%; box-shadow: none; border-top: 4px solid var(--cpd-blue-d); + overflow: visible; &.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 index 0484c415d..8b35a57d5 100644 --- 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 @@ -6,12 +6,13 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { 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 { OrganizationService } from 'app/organization/organization.service'; import { FyInterestBreakup, LoanInterestRecord } from '../loan-interest/loan-interest.component'; import { ShareCertificateRecord } from '../share-certificate/share-certificate.component'; @@ -27,7 +28,8 @@ import { downloadHypothecationPdf, downloadLoanInterestPdf, downloadNocPdf, - downloadShareCertificatePdf + downloadShareCertificatePdf, + setBankIdentity } from '../certificate-pdf'; export interface InterestPreviewPayload { @@ -70,14 +72,52 @@ export interface CertificatePreviewDialogData { MatTooltip ] }) -export class CertificatePreviewDialogComponent { +export class CertificatePreviewDialogComponent implements OnInit { private dialogRef = inject(MatDialogRef); + private organizationService = inject(OrganizationService); 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'; + /** The issuing bank's own details, fetched from the Institution Profile — this dialog is shared + * by every certificate type and every tenant, so nothing here is a fixed name. */ + bankName = 'Bank'; + bankAddress = ''; + bankRegNo = ''; + bankContact = ''; + bankSealPrimary = 'BANK'; + bankSealSecondary = ''; + + ngOnInit(): void { + this.organizationService.getInstitutionProfile().subscribe({ + next: (profile: any) => { + this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName; + this.bankAddress = [ + profile?.addressLine1, + profile?.addressLine2, + profile?.city + ] + .filter((p: any) => !!p && String(p).trim()) + .join(', '); + this.bankRegNo = profile?.licenseNumber ? `RBI Lic. No. ${profile.licenseNumber}` : ''; + this.bankContact = [ + profile?.contactEmail, + profile?.contactPhone + ] + .filter((p: any) => !!p && String(p).trim()) + .join(' · '); + this.bankSealPrimary = (profile?.shortName || this.bankName).toUpperCase(); + this.bankSealSecondary = profile?.bankType + ? profile.bankType.replace(/_/g, ' ').toUpperCase() + (/BANK$/i.test(profile.bankType) ? '' : ' BANK') + : ''; + + // Every certificate PDF this dialog can build (Download button below, or the list page's + // own download icon opened without previewing first) reads the same shared identity, so + // set it here once the real profile is in — never leave a PDF carrying the placeholder. + setBankIdentity(profile || {}); + }, + // A missing/incomplete profile must not stop the certificate from rendering. + error: () => {} + }); + } get isInterest(): boolean { return this.data.kind === 'interest'; diff --git a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html index bc34a7490..949239f5c 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html +++ b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html @@ -8,12 +8,12 @@ @if (loading) {
- +
Loading live declarations and accounts from Fineract…
} @else if (loadError) { -
- +
+
Could not load real data from Fineract. Check connection.
} @else { @@ -139,7 +139,9 @@
{{ row.row.record.customerName }}
-
{{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }}
+
+ {{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }} +
DOB {{ row.row.record.dob | date: 'd MMM y' }}
@@ -306,8 +308,8 @@ @if (row.row.schedule.shieldedInterest > 0) {
- ₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of - the declaration — reportable in the quarterly {{ statute.statement }} return. + ₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength + of the declaration — reportable in the quarterly {{ statute.statement }} return.
} diff --git a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts index 1793ce61c..dfac843d7 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts @@ -64,7 +64,11 @@ export interface RealForm15ghRow { /** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */ function toIso(value: unknown): string | null { if (Array.isArray(value) && value.length >= 3) { - const [y, m, d] = value as number[]; + const [ + y, + m, + d + ] = value as number[]; return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`; } if (typeof value === 'string' && value.trim()) { @@ -118,7 +122,10 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy { private clientKycCache = new Map>(); private clientAccountsCache = new Map>(); - readonly availableFys = ['FY 2026-27', 'FY 2025-26']; + readonly availableFys = [ + 'FY 2026-27', + 'FY 2025-26' + ]; ngOnInit(): void { this.loadRealData(); @@ -311,9 +318,11 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy { depositPrincipal, fy: this.selectedFy, status, - filedOn: hasDeclaration ? toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01` : undefined, + filedOn: hasDeclaration ? (toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01`) : undefined, assessed: true, - interestPostingPeriodType: detailsList.find(d => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code || 'savingsPostingInterestPeriodType.quarterly' + interestPostingPeriodType: + detailsList.find((d) => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code || + 'savingsPostingInterestPeriodType.quarterly' }; } diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts index 32ee84d6d..9cba8c989 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts @@ -226,7 +226,11 @@ export function uinTaxYearPart(fy: string): string { */ export function buildUin(sequence: number, fy: string, tan: string): string { const digits = `${Math.max(0, Math.floor(sequence))}`.slice(-9).padStart(9, '0'); - const normalisedTan = (tan || '').toUpperCase().replace(/[^A-Z0-9]/g, '').padEnd(10, 'X').slice(0, 10); + const normalisedTan = (tan || '') + .toUpperCase() + .replace(/[^A-Z0-9]/g, '') + .padEnd(10, 'X') + .slice(0, 10); return `D${digits}${uinTaxYearPart(fy)}${normalisedTan}`; } diff --git a/src/app/letter-mgmt/certificates/share-certificate/real-share-accounts-view/real-share-accounts-view.component.ts b/src/app/letter-mgmt/certificates/share-certificate/real-share-accounts-view/real-share-accounts-view.component.ts index 655d42238..1df873a16 100644 --- a/src/app/letter-mgmt/certificates/share-certificate/real-share-accounts-view/real-share-accounts-view.component.ts +++ b/src/app/letter-mgmt/certificates/share-certificate/real-share-accounts-view/real-share-accounts-view.component.ts @@ -17,8 +17,9 @@ import { catchError, map, shareReplay, switchMap } from 'rxjs/operators'; import { SharesService } from '../../../../shares/shares.service'; import { ClientsService } from '../../../../clients/clients.service'; +import { OrganizationService } from '../../../../organization/organization.service'; import { ShareCertificateDialogComponent } from '../../../../shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component'; -import { downloadShareCertificatePdf } from '../../certificate-pdf'; +import { downloadShareCertificatePdf, setBankIdentity } from '../../certificate-pdf'; import { DscSignService } from '../../../dsc-sign-dialog/dsc-sign.service'; import type { ShareClass, ShareCertificateRecord } from '../share-certificate.component'; @@ -47,6 +48,7 @@ export class RealShareAccountsViewComponent implements OnInit { private dialog = inject(MatDialog); private sharesService = inject(SharesService); private clientsService = inject(ClientsService); + private organizationService = inject(OrganizationService); private dscSign = inject(DscSignService); loading = true; @@ -70,6 +72,14 @@ export class RealShareAccountsViewComponent implements OnInit { ngOnInit(): void { this.loadRealAccounts(); + + // The Download button below builds the PDF directly (no preview dialog in between), so this + // view must set the shared bank identity itself rather than relying on some other screen + // having opened first. + this.organizationService.getInstitutionProfile().subscribe({ + next: (profile: any) => setBankIdentity(profile || {}), + error: () => {} + }); } private loadRealAccounts(): void { 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 index 47e65b44a..851899d5b 100644 --- a/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts +++ b/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts @@ -19,8 +19,9 @@ import { CertificatePreviewDialogComponent, CertificatePreviewDialogData } from '../certificate-preview-dialog/certificate-preview-dialog.component'; -import { downloadShareCertificatePdf } from '../certificate-pdf'; +import { downloadShareCertificatePdf, setBankIdentity } from '../certificate-pdf'; import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service'; +import { OrganizationService } from '../../../organization/organization.service'; import { RealShareAccountsViewComponent } from './real-share-accounts-view/real-share-accounts-view.component'; export type ShareClass = 'ORDINARY' | 'PREFERENCE'; @@ -62,6 +63,7 @@ export class ShareCertificateComponent implements OnInit { private dialog = inject(MatDialog); private snack = inject(MatSnackBar); private dscSign = inject(DscSignService); + private organizationService = inject(OrganizationService); /** Toggles between the static sample table and the live m_share_account view. */ viewMode: 'demo' | 'real' = 'demo'; @@ -80,6 +82,13 @@ export class ShareCertificateComponent implements OnInit { ngOnInit(): void { this.records = seedShareCertificates(); + + // Download here builds the PDF directly, without necessarily opening the preview dialog first, + // so this list must set the shared bank identity itself. + this.organizationService.getInstitutionProfile().subscribe({ + next: (profile: any) => setBankIdentity(profile || {}), + error: () => {} + }); } // ── Derived list ── diff --git a/src/app/letter-mgmt/letter-mgmt-routing.module.ts b/src/app/letter-mgmt/letter-mgmt-routing.module.ts index 2f9a34202..c0909eb59 100644 --- a/src/app/letter-mgmt/letter-mgmt-routing.module.ts +++ b/src/app/letter-mgmt/letter-mgmt-routing.module.ts @@ -19,6 +19,9 @@ import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component' import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component'; import { NocCertificateComponent } from './certificates/noc/noc.component'; import { DatabaseExportComponent } from './database-export/database-export.component'; +import { ShareMemberComponent } from './share-member/share-member.component'; +import { MembershipRegisterComponent } from './membership-register/membership-register.component'; +import { ShareAllotmentReportComponent } from './share-allotment-report/share-allotment-report.component'; const routes: Routes = [ Route.withShell([ @@ -37,6 +40,21 @@ const routes: Routes = [ component: ShareCertificateComponent, data: { title: 'Share Certificate', breadcrumb: 'Share Certificate' } }, + { + path: 'letter-mgmt/share-member', + component: ShareMemberComponent, + data: { title: 'Share Members', breadcrumb: 'Share Members' } + }, + { + path: 'letter-mgmt/membership-register', + component: MembershipRegisterComponent, + data: { title: 'Membership Register', breadcrumb: 'Membership Register' } + }, + { + path: 'letter-mgmt/share-allotment-report', + component: ShareAllotmentReportComponent, + data: { title: 'Share Allotment Report', breadcrumb: 'Share Allotment Report' } + }, { path: 'letter-mgmt/balance-certificate', component: BalanceCertificateComponent, diff --git a/src/app/letter-mgmt/letter-mgmt.module.ts b/src/app/letter-mgmt/letter-mgmt.module.ts index 20a063020..cab048ac0 100644 --- a/src/app/letter-mgmt/letter-mgmt.module.ts +++ b/src/app/letter-mgmt/letter-mgmt.module.ts @@ -24,6 +24,9 @@ import { HypothecationCertificateComponent } from './certificates/hypothecation/ import { NocCertificateComponent } from './certificates/noc/noc.component'; import { CertificatePreviewDialogComponent } from './certificates/certificate-preview-dialog/certificate-preview-dialog.component'; import { DscSignDialogComponent } from './dsc-sign-dialog/dsc-sign-dialog.component'; +import { ShareMemberComponent } from './share-member/share-member.component'; +import { MembershipRegisterComponent } from './membership-register/membership-register.component'; +import { ShareAllotmentReportComponent } from './share-allotment-report/share-allotment-report.component'; @NgModule({ imports: [ @@ -42,7 +45,10 @@ import { DscSignDialogComponent } from './dsc-sign-dialog/dsc-sign-dialog.compon HypothecationCertificateComponent, NocCertificateComponent, CertificatePreviewDialogComponent, - DscSignDialogComponent + DscSignDialogComponent, + ShareMemberComponent, + MembershipRegisterComponent, + ShareAllotmentReportComponent ] }) export class LetterMgmtModule {} diff --git a/src/app/letter-mgmt/membership-register/membership-register.component.html b/src/app/letter-mgmt/membership-register/membership-register.component.html new file mode 100644 index 000000000..011126148 --- /dev/null +++ b/src/app/letter-mgmt/membership-register/membership-register.component.html @@ -0,0 +1,194 @@ + + +
+
+
+ +
+

Membership Register

+
Statutory register of members — one line per member, in order of admission.
+
+
+
+ + +
+
+ + +
+
+ +
+
{{ filteredRows.length | number }}
+
Members on Register
+
+
+
+ +
+
{{ activeMembers() | number }}
+
Holding Shares
+
+
+
+ +
+
{{ totalShares() | number }}
+
Total Shares Held
+
+
+
+ +
+
₹{{ totalValue() | number: '1.0-0' }}
+
Share Capital
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ @if (loading) { +
Loading membership register…
+ } @else if (loadError) { +
+ Could not load the membership register. + +
+ } @else if (!filteredRows.length) { +
+ @if (rows.length) { + No member matches these filters. + } @else { + No members on the register yet. + } +
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.clientId) { + + + + + + + + + + + + + } + + + + + + + + + +
S.NoMember's NameMembership NoAddressDate of AdmissionNo. of Shares HeldShare ValueBranchMobile
{{ row.serial }} + {{ row.memberName }} + @if (row.accountCount > 1) { + + {{ row.accountCount }} a/c + + } + {{ row.membershipNo }}{{ row.address }}{{ row.admissionDate ? (row.admissionDate | date: 'd MMM y') : '—' }} + {{ row.sharesHeld | number }} + @if (row.pendingShares) { + + +{{ row.pendingShares | number }} + + } + ₹{{ row.shareValue | number: '1.0-0' }}{{ row.branch }}{{ row.mobileNo }} + +
Total — {{ filteredRows.length | number }} members{{ totalShares() | number }}₹{{ totalValue() | number: '1.0-0' }}
+
+ } +
+ +
+ One line per member, not per share account — a member holding several share accounts appears once with their + holdings summed. Serial numbers follow date of admission. A "+n" beside the holding is shares applied for but not + yet approved. +
+
diff --git a/src/app/letter-mgmt/membership-register/membership-register.component.scss b/src/app/letter-mgmt/membership-register/membership-register.component.scss new file mode 100644 index 000000000..139a5b0f7 --- /dev/null +++ b/src/app/letter-mgmt/membership-register/membership-register.component.scss @@ -0,0 +1,116 @@ +/** + * 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/. + */ + +.mr-row { + cursor: pointer; + transition: background 0.12s ease; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--cert-blue) 6%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--cert-blue); + outline-offset: -2px; + } + + &:hover .mr-chevron, + &:focus-visible .mr-chevron { + opacity: 1; + } +} + +.mr-chevron { + opacity: 0; + color: var(--cert-blue); + transition: opacity 0.12s ease; +} + +/* The serial is the register's own numbering, so it reads as a label rather than a figure. */ +.mr-serial { + font-weight: 700; + color: var(--cert-g500); + font-variant-numeric: tabular-nums; +} + +.mr-address { + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Shares still awaiting approval sit beside the holding rather than inside it — they are + not yet share capital, and adding them to the figure would overstate the register. */ +.mr-pending { + margin-left: 6px; + font-size: 11px; + font-weight: 700; + color: var(--cert-amber); +} + +.mr-pill--multi { + margin-left: 8px; + background: var(--cert-blue-l); + color: var(--cert-blue-d); + font-size: 10px; + padding: 2px 7px; +} + +.cert-table { + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + tfoot .mr-total > td { + background: var(--cert-g50); + font-weight: 800; + color: var(--cert-g900); + border-top: 2px solid var(--cert-g200); + padding: 12px; + white-space: nowrap; + } +} + +.mr-retry { + margin-left: 10px; +} + +/* A register is a document that gets signed and filed, so printing drops the chrome. */ +@media print { + .cert-filters, + .cert-head-r, + .cert-kpis, + .mr-chevron { + display: none !important; + } + + .cert-card { + border: none; + } + + .mr-address { + max-width: none; + white-space: normal; + } + + .cert-table { + font-size: 10px; + } + + /* Repeat the column headings on every printed page of a long register. */ + .cert-table thead { + display: table-header-group; + } + + .mr-row { + break-inside: avoid; + } +} diff --git a/src/app/letter-mgmt/membership-register/membership-register.component.ts b/src/app/letter-mgmt/membership-register/membership-register.component.ts new file mode 100644 index 000000000..8e252b3f3 --- /dev/null +++ b/src/app/letter-mgmt/membership-register/membership-register.component.ts @@ -0,0 +1,349 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatTooltip } from '@angular/material/tooltip'; +import { forkJoin, of, Observable } from 'rxjs'; +import { catchError, map, shareReplay, switchMap } from 'rxjs/operators'; + +import { SharesService } from '../../shares/shares.service'; +import { ClientsService } from '../../clients/clients.service'; + +/** + * One line of the statutory Membership Register. + * + * The register is a record of *members*, not of share accounts — a member holding three + * share accounts is one member on one line, with their holdings summed. That is the whole + * reason this cannot just be the share-account list with different column headings. + */ +export interface MembershipRegisterRow { + serial: number; + clientId: string; + memberName: string; + membershipNo: string; + address: string; + admissionDate: string; + sharesHeld: number; + /** Shares applied for but not yet approved — still funded out of Sundry. */ + pendingShares: number; + shareValue: number; + branch: string; + mobileNo: string; + /** Share accounts rolled into this line — a member may hold several. */ + accountCount: number; + /** The account opened to navigate to; the member's first/primary share account. */ + primaryAccountId: number; + /** True once the member holds at least one approved share. */ + isActive: boolean; +} + +@Component({ + selector: 'mifosx-membership-register', + templateUrl: './membership-register.component.html', + styleUrls: [ + '../certificates/certificates.shared.scss', + './membership-register.component.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class MembershipRegisterComponent implements OnInit { + private sharesService = inject(SharesService); + private clientsService = inject(ClientsService); + private router = inject(Router); + + loading = true; + loadError = false; + rows: MembershipRegisterRow[] = []; + + // ── Filters ── + search = ''; + branchFilter = ''; + statusFilter = ''; + fromDate = ''; + toDate = ''; + + private clientDataCache = new Map>(); + private clientAddressCache = new Map>(); + + ngOnInit(): void { + this.loadRegister(); + } + + loadRegister(): void { + this.loading = true; + this.loadError = false; + this.clientDataCache.clear(); + this.clientAddressCache.clear(); + + this.sharesService + .getAllShareAccounts(0, 200) + .pipe( + map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])), + switchMap((items: any[]) => { + if (!items.length) { + return of([] as any[]); + } + return forkJoin(items.map((item) => this.loadAccount(item))).pipe( + map((accounts) => accounts.filter((a) => !!a)) + ); + }), + catchError(() => { + this.loadError = true; + return of([] as any[]); + }) + ) + .subscribe((accounts) => { + this.rows = this.collapseToMembers(accounts); + this.loading = false; + }); + } + + private loadAccount(listItem: any): Observable { + const clientId = listItem?.clientId != null ? String(listItem.clientId) : null; + return forkJoin({ + sa: this.sharesService.getSharesAccountData(String(listItem.id), false), + client: clientId ? this.getClientDataCached(clientId) : of(null), + address: clientId ? this.getClientAddressCached(clientId) : of([]) + }).pipe(catchError(() => of(null))); + } + + private getClientDataCached(clientId: string): Observable { + if (!this.clientDataCache.has(clientId)) { + this.clientDataCache.set( + clientId, + this.clientsService.getClientData(clientId, true).pipe( + catchError(() => of(null)), + shareReplay(1) + ) + ); + } + return this.clientDataCache.get(clientId)!; + } + + private getClientAddressCached(clientId: string): Observable { + if (!this.clientAddressCache.has(clientId)) { + this.clientAddressCache.set( + clientId, + this.clientsService.getClientAddressData(clientId, true).pipe( + catchError(() => of([])), + shareReplay(1) + ) + ); + } + return this.clientAddressCache.get(clientId)!; + } + + /** + * Collapses share accounts into one line per member, then numbers the lines by date of + * admission — a register's serial number is the order members joined, so it has to be + * assigned after sorting, not carried over from the account list. + */ + private collapseToMembers(accounts: any[]): MembershipRegisterRow[] { + const byMember = new Map(); + + for (const entry of accounts) { + const { sa, client, address } = entry; + if (!sa) { + continue; + } + const clientId = String(sa.clientId ?? client?.id ?? ''); + if (!clientId) { + continue; + } + + const approved = sa.summary?.totalApprovedShares || 0; + const pending = sa.summary?.totalPendingForApprovalShares || 0; + const unitPrice = sa.currentMarketPrice || sa.unitPrice || 0; + + const existing = byMember.get(clientId); + if (existing) { + existing.sharesHeld += approved; + existing.pendingShares += pending; + existing.shareValue += approved * unitPrice; + existing.accountCount += 1; + existing.isActive = existing.isActive || approved > 0; + // Keep the earliest account as the navigation target, so the link is stable + // regardless of the order the API returned the accounts in. + existing.primaryAccountId = Math.min(existing.primaryAccountId, sa.id); + continue; + } + + byMember.set(clientId, { + serial: 0, + clientId, + memberName: sa.clientName || client?.displayName || '—', + membershipNo: client?.accountNo || sa.clientAccountNo || '—', + address: this.firstAddress(address), + admissionDate: + this.toIsoDate(client?.activationDate) || + this.toIsoDate(client?.timeline?.activatedOnDate) || + this.toIsoDate(sa.timeline?.activatedDate) || + '', + sharesHeld: approved, + pendingShares: pending, + shareValue: approved * unitPrice, + branch: client?.officeName || sa.officeName || '—', + mobileNo: client?.mobileNo || '—', + accountCount: 1, + primaryAccountId: sa.id, + isActive: approved > 0 + }); + } + + // Oldest admission first: member no. 1 is the society's first member. + const ordered = Array.from(byMember.values()).sort((a, b) => + (a.admissionDate || '').localeCompare(b.admissionDate || '') + ); + ordered.forEach((row, i) => (row.serial = i + 1)); + return ordered; + } + + private toIsoDate(value: any): string { + if (!value) { + return ''; + } + if (Array.isArray(value)) { + const [ + y, + m, + d + ] = value; + if (!y) { + return ''; + } + return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`; + } + return String(value); + } + + private firstAddress(address: any): string { + const list = Array.isArray(address) ? address : address?.pageItems || []; + if (!list.length) { + return '—'; + } + const a = list[0]; + const parts = [ + a.addressLine1, + a.addressLine2, + a.townVillage, + a.city, + a.stateName, + a.countryName, + a.postalCode + ] + .filter((p: any) => !!p && String(p).trim()) + .join(', '); + return parts || '—'; + } + + // ── Derived list ── + get filteredRows(): MembershipRegisterRow[] { + const q = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.branch !== this.branchFilter) return false; + if (this.statusFilter === 'active' && !r.isActive) return false; + if (this.statusFilter === 'pending' && r.isActive) return false; + // Admission-date window — the usual way a register is pulled for an AGM or an audit. + if (this.fromDate && (!r.admissionDate || r.admissionDate < this.fromDate)) return false; + if (this.toDate && (!r.admissionDate || r.admissionDate > this.toDate)) return false; + if (!q) return true; + return ( + r.memberName.toLowerCase().includes(q) || + r.membershipNo.toLowerCase().includes(q) || + r.address.toLowerCase().includes(q) || + r.mobileNo.toLowerCase().includes(q) + ); + }); + } + + get branchOptions(): string[] { + return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort(); + } + + totalShares(): number { + return this.filteredRows.reduce((s, r) => s + r.sharesHeld, 0); + } + + totalValue(): number { + return this.filteredRows.reduce((s, r) => s + r.shareValue, 0); + } + + activeMembers(): number { + return this.filteredRows.filter((r) => r.isActive).length; + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.statusFilter = ''; + this.fromDate = ''; + this.toDate = ''; + } + + openMember(row: MembershipRegisterRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.primaryAccountId, + 'general' + ]); + } + + exportCsv(): void { + const header = [ + 'S.No', + "Member's Name", + 'Membership No', + 'Address', + 'Date of Admission', + 'No. of Shares Held', + 'Share Value', + 'Branch', + 'Mobile' + ]; + const rows = this.filteredRows.map((r) => [ + r.serial, + r.memberName, + r.membershipNo, + r.address, + r.admissionDate, + r.sharesHeld, + r.shareValue, + r.branch, + r.mobileNo + ]); + const csv = [ + header, + ...rows + ] + .map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')) + .join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'membership-register.csv'; + link.click(); + URL.revokeObjectURL(url); + } + + print(): void { + window.print(); + } +} diff --git a/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.html b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.html new file mode 100644 index 000000000..a79ae80f2 --- /dev/null +++ b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.html @@ -0,0 +1,221 @@ + + +
+
+
+ +
+

Share Allotment Report

+
+ Every tranche of shares issued — allotment date, number, value and payment status. +
+
+
+
+ + +
+
+ + +
+
+ +
+
{{ filteredRows.length | number }}
+
Allotments
+
+
+
+ +
+
{{ allottedShares() | number }}
+
Shares Allotted
+
+
+
+ +
+
₹{{ allottedValue() | number: '1.0-0' }}
+
Value Allotted
+
+
+
+ +
+
{{ pendingShares() | number }}
+
Pending Approval
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ @if (loading) { +
Loading allotments…
+ } @else if (loadError) { +
+ Could not load the allotment report. + +
+ } @else if (!filteredRows.length) { +
+ @if (rows.length) { + No allotment matches these filters. + } @else { + No shares have been allotted yet. + } +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.serial) { + + + + + + + + + + + + + + + + + + } + + + + + + + + + + +
S.NoMember NameMembership NoShare A/c NoProductBranchAllotment DateNo. of SharesUnit PriceValueShare Nos.ResolutionStatusPayment
{{ row.serial }}{{ row.memberName }}{{ row.membershipNo }}{{ row.shareAccountNo }}{{ row.productName }}{{ row.branch }}{{ row.allotmentDate ? (row.allotmentDate | date: 'd MMM y') : '—' }}{{ row.numberOfShares | number }}₹{{ row.unitPrice | number: '1.0-2' }}₹{{ row.value | number: '1.0-0' }} + @if (row.shareNoFrom) { + {{ row.shareNoFrom }}–{{ row.shareNoTo }} + } @else { + — + } + + {{ row.resolutionNumber }} + @if (row.meetingDate) { + ({{ row.meetingDate | date: 'd MMM y' }}) + } + + {{ row.statusLabel }} + + {{ + row.paymentStatus + }} + + +
Total allotted — {{ filteredRows.length | number }} tranches{{ allottedShares() | number }}₹{{ allottedValue() | number: '1.0-0' }}
+
+ } +
+ +
+ One line per tranche, so a member who bought twice appears twice with each allotment date. Totals count allotted + shares only. "In Sundry" means the money has been received but the tranche is not yet approved, so it is not yet + share capital. +
+
diff --git a/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.scss b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.scss new file mode 100644 index 000000000..bc31ce884 --- /dev/null +++ b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.scss @@ -0,0 +1,125 @@ +/** + * 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/. + */ + +.sar-row { + cursor: pointer; + transition: background 0.12s ease; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--cert-blue) 6%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--cert-blue); + outline-offset: -2px; + } + + &:hover .sar-chevron, + &:focus-visible .sar-chevron { + opacity: 1; + } +} + +.sar-chevron { + opacity: 0; + color: var(--cert-blue); + transition: opacity 0.12s ease; +} + +.sar-serial { + font-weight: 700; + color: var(--cert-g500); + font-variant-numeric: tabular-nums; +} + +/* The meeting date qualifies the resolution rather than standing on its own. */ +.sar-meeting { + margin-left: 4px; + font-size: 11px; + color: var(--cert-g500); + white-space: nowrap; +} + +.cert-table { + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + tfoot .sar-total > td { + background: var(--cert-g50); + font-weight: 800; + color: var(--cert-g900); + border-top: 2px solid var(--cert-g200); + padding: 12px; + white-space: nowrap; + } +} + +/* Allotment status */ +.sar-pill--allotted { + background: var(--cert-green-l); + color: var(--cert-green); +} + +.sar-pill--pending { + background: var(--cert-amber-l); + color: var(--cert-amber); +} + +.sar-pill--rejected { + background: color-mix(in srgb, #dc2626 12%, transparent); + color: #b91c1c; +} + +/* Payment status is deliberately toned down against the allotment status beside it — + the two track the same event, and matching intensities read as two separate facts. */ +.sar-pay--allotted { + background: color-mix(in srgb, var(--cert-green) 10%, transparent); + color: var(--cert-green); +} + +.sar-pay--pending { + background: var(--cert-blue-l); + color: var(--cert-blue-d); +} + +.sar-pay--rejected { + background: var(--cert-g100); + color: var(--cert-g700); +} + +.sar-retry { + margin-left: 10px; +} + +@media print { + .cert-filters, + .cert-head-r, + .cert-kpis, + .sar-chevron { + display: none !important; + } + + .cert-card { + border: none; + } + + .cert-table { + font-size: 10px; + } + + .cert-table thead { + display: table-header-group; + } + + .sar-row { + break-inside: avoid; + } +} diff --git a/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.ts b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.ts new file mode 100644 index 000000000..12783988e --- /dev/null +++ b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.ts @@ -0,0 +1,374 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatTooltip } from '@angular/material/tooltip'; +import { forkJoin, of, Observable } from 'rxjs'; +import { catchError, map, shareReplay, switchMap } from 'rxjs/operators'; + +import { SharesService } from '../../shares/shares.service'; +import { ClientsService } from '../../clients/clients.service'; + +/** Where a tranche stands: paid and allotted, awaiting approval, or refused. */ +export type AllotmentStatus = 'ALLOTTED' | 'PENDING' | 'REJECTED'; + +/** + * One allotment — a single tranche of shares issued to a member. + * + * This is deliberately per-*tranche*, not per-account: the report exists to track the issue + * of new shares, so a member who bought 100 shares in April and 50 more in August is two + * lines with two allotment dates, which is exactly what the account-level view cannot show. + */ +export interface AllotmentRow { + serial: number; + clientId: string; + accountId: number; + memberName: string; + membershipNo: string; + shareAccountNo: string; + productName: string; + branch: string; + /** Date this tranche was purchased/allotted. */ + allotmentDate: string; + numberOfShares: number; + unitPrice: number; + /** numberOfShares x unitPrice — the value of this tranche. */ + value: number; + /** Board resolution authorising the issue, falling back to the account's own. */ + resolutionNumber: string; + meetingDate: string; + /** Running share-number range this tranche covers, per account. */ + shareNoFrom: number; + shareNoTo: number; + status: AllotmentStatus; + statusLabel: string; + /** Whether the money for this tranche has cleared out of Sundry. */ + paymentStatus: string; +} + +@Component({ + selector: 'mifosx-share-allotment-report', + templateUrl: './share-allotment-report.component.html', + styleUrls: [ + '../certificates/certificates.shared.scss', + './share-allotment-report.component.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class ShareAllotmentReportComponent implements OnInit { + private sharesService = inject(SharesService); + private clientsService = inject(ClientsService); + private router = inject(Router); + + loading = true; + loadError = false; + rows: AllotmentRow[] = []; + + // ── Filters ── + search = ''; + branchFilter = ''; + productFilter = ''; + statusFilter = ''; + fromDate = ''; + toDate = ''; + + private clientDataCache = new Map>(); + + ngOnInit(): void { + this.loadAllotments(); + } + + loadAllotments(): void { + this.loading = true; + this.loadError = false; + this.clientDataCache.clear(); + + this.sharesService + .getAllShareAccounts(0, 200) + .pipe( + map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])), + switchMap((items: any[]) => { + if (!items.length) { + return of([] as AllotmentRow[]); + } + return forkJoin(items.map((item) => this.buildAllotments(item))).pipe(map((perAccount) => perAccount.flat())); + }), + catchError(() => { + this.loadError = true; + return of([] as AllotmentRow[]); + }) + ) + .subscribe((rows) => { + // Most recent issue first — this report is read to see what was just allotted. + const ordered = rows.sort((a, b) => (b.allotmentDate || '').localeCompare(a.allotmentDate || '')); + ordered.forEach((row, i) => (row.serial = i + 1)); + this.rows = ordered; + this.loading = false; + }); + } + + private buildAllotments(listItem: any): Observable { + const clientId = listItem?.clientId != null ? String(listItem.clientId) : null; + return forkJoin({ + sa: this.sharesService.getSharesAccountData(String(listItem.id), false), + client: clientId ? this.getClientDataCached(clientId) : of(null) + }).pipe( + map(({ sa, client }: any) => this.mapAccount(sa, client)), + catchError(() => of([] as AllotmentRow[])) + ); + } + + private getClientDataCached(clientId: string): Observable { + if (!this.clientDataCache.has(clientId)) { + this.clientDataCache.set( + clientId, + this.clientsService.getClientData(clientId, true).pipe( + catchError(() => of(null)), + shareReplay(1) + ) + ); + } + return this.clientDataCache.get(clientId)!; + } + + /** + * Expands one account's `purchasedShares` into allotment lines. + * + * Charge payments are dropped — a fee is not a share movement. Redemptions are dropped too: + * this report tracks the *issue* of shares, and netting a buy-back into it would understate + * what was actually allotted in the period. + */ + private mapAccount(sa: any, client: any): AllotmentRow[] { + const clientId = String(sa.clientId ?? client?.id ?? ''); + const memberName = sa.clientName || client?.displayName || '—'; + const membershipNo = client?.accountNo || sa.clientAccountNo || '—'; + const branch = client?.officeName || sa.officeName || '—'; + + const purchases = (sa.purchasedShares || []) + .filter((txn: any) => { + const type = txn?.type?.value; + return type !== 'Charge Payment' && type !== 'Redeem'; + }) + .slice() + .sort( + (a: any, b: any) => + new Date(this.toIsoDate(a.purchasedDate)).getTime() - new Date(this.toIsoDate(b.purchasedDate)).getTime() + ); + + // Share numbers run consecutively within an account, so the range is assigned by walking + // the tranches in date order. Only approved tranches consume numbers — a pending or + // rejected request has not been allotted any. + let nextShareNumber = sa.id * 5000 + 201; + + return purchases.map((txn: any) => { + const shares = Number(txn.numberOfShares) || 0; + const unitPrice = Number(txn.purchasedPrice) || 0; + const status = this.statusOf(txn); + + let from = 0; + let to = 0; + if (status === 'ALLOTTED' && shares > 0) { + from = nextShareNumber; + to = from + shares - 1; + nextShareNumber += shares; + } + + return { + serial: 0, + clientId, + accountId: sa.id, + memberName, + membershipNo, + shareAccountNo: sa.accountNo || String(sa.id), + productName: sa.productName || '—', + branch, + allotmentDate: this.toIsoDate(txn.purchasedDate), + numberOfShares: shares, + unitPrice, + value: shares * unitPrice, + resolutionNumber: txn.resolutionNumber || sa.resolutionNumber || '—', + meetingDate: this.toIsoDate(txn.meetingDate) || this.toIsoDate(sa.resolutionDate), + shareNoFrom: from, + shareNoTo: to, + status, + statusLabel: txn?.status?.value || status, + paymentStatus: this.paymentStatusOf(status) + } as AllotmentRow; + }); + } + + private statusOf(txn: any): AllotmentStatus { + const code = txn?.status?.code || ''; + if (code === 'purchasedSharesStatusType.rejected') { + return 'REJECTED'; + } + if (code === 'purchasedSharesStatusType.applied') { + return 'PENDING'; + } + return 'ALLOTTED'; + } + + /** + * Payment status follows approval, because that is what actually moves the money: until a + * tranche is approved its funds sit in Share Sundry, and approval is the event that posts + * Dr Sundry / Cr Share Capital. + */ + private paymentStatusOf(status: AllotmentStatus): string { + switch (status) { + case 'ALLOTTED': + return 'Paid'; + case 'PENDING': + return 'In Sundry'; + default: + return 'Refunded'; + } + } + + private toIsoDate(value: any): string { + if (!value) { + return ''; + } + if (Array.isArray(value)) { + const [ + y, + m, + d + ] = value; + if (!y) { + return ''; + } + return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`; + } + return String(value); + } + + // ── Derived list ── + get filteredRows(): AllotmentRow[] { + const q = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.branch !== this.branchFilter) return false; + if (this.productFilter && r.productName !== this.productFilter) return false; + if (this.statusFilter && r.status !== this.statusFilter) return false; + if (this.fromDate && (!r.allotmentDate || r.allotmentDate < this.fromDate)) return false; + if (this.toDate && (!r.allotmentDate || r.allotmentDate > this.toDate)) return false; + if (!q) return true; + return ( + r.memberName.toLowerCase().includes(q) || + r.membershipNo.toLowerCase().includes(q) || + r.shareAccountNo.toLowerCase().includes(q) || + r.resolutionNumber.toLowerCase().includes(q) + ); + }); + } + + get branchOptions(): string[] { + return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort(); + } + + get productOptions(): string[] { + return Array.from(new Set(this.rows.map((r) => r.productName).filter((p) => p && p !== '—'))).sort(); + } + + /** Shares actually issued — pending and rejected tranches are not allotments. */ + allottedShares(): number { + return this.filteredRows.filter((r) => r.status === 'ALLOTTED').reduce((s, r) => s + r.numberOfShares, 0); + } + + allottedValue(): number { + return this.filteredRows.filter((r) => r.status === 'ALLOTTED').reduce((s, r) => s + r.value, 0); + } + + pendingShares(): number { + return this.filteredRows.filter((r) => r.status === 'PENDING').reduce((s, r) => s + r.numberOfShares, 0); + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + this.statusFilter = ''; + this.fromDate = ''; + this.toDate = ''; + } + + openAccount(row: AllotmentRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.accountId, + 'general' + ]); + } + + exportCsv(): void { + const header = [ + 'S.No', + 'Member Name', + 'Membership No', + 'Share A/c No', + 'Product', + 'Branch', + 'Allotment Date', + 'No. of Shares', + 'Unit Price', + 'Value', + 'Share No. From', + 'Share No. To', + 'Resolution No', + 'Meeting Date', + 'Status', + 'Payment Status' + ]; + const rows = this.filteredRows.map((r) => [ + r.serial, + r.memberName, + r.membershipNo, + r.shareAccountNo, + r.productName, + r.branch, + r.allotmentDate, + r.numberOfShares, + r.unitPrice, + r.value, + r.shareNoFrom || '', + r.shareNoTo || '', + r.resolutionNumber, + r.meetingDate, + r.statusLabel, + r.paymentStatus + ]); + const csv = [ + header, + ...rows + ] + .map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')) + .join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'share-allotment-report.csv'; + link.click(); + URL.revokeObjectURL(url); + } + + print(): void { + window.print(); + } +} diff --git a/src/app/letter-mgmt/share-member/share-member.component.html b/src/app/letter-mgmt/share-member/share-member.component.html new file mode 100644 index 000000000..4d86ed839 --- /dev/null +++ b/src/app/letter-mgmt/share-member/share-member.component.html @@ -0,0 +1,189 @@ + + +
+
+
+ +
+

Share Members

+
+ Every share member on the register with their holding. Select a row to open that share account. +
+
+
+
+ + +
+
+ + +
+
+ +
+
{{ totalMembers() | number }}
+
Members
+
+
+
+ +
+
{{ filteredRows.length | number }}
+
Share Accounts
+
+
+
+ +
+
{{ totalShares() | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totalValue() | number: '1.0-0' }}
+
Share Capital
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ @if (loading) { +
Loading share members…
+ } @else if (loadError) { +
+ Could not load share members. + +
+ } @else if (!filteredRows.length) { +
+ @if (rows.length) { + No share member matches these filters. + } @else { + No share members on the register yet. + } +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.accountId; let i = $index) { + + + + + + + + + + + + + + + + + + + } + +
S.NoMembership NoMember NameFolio NoShare A/c NoProductBranchAddressMobileDate of AdmissionShares HeldPendingUnit PriceShare ValueStatus
{{ i + 1 }}{{ row.membershipNo }}{{ row.memberName }}{{ row.folioNo }}{{ row.shareAccountNo }}{{ row.productName }}{{ row.branch }}{{ row.address }}{{ row.mobileNo }}{{ row.admissionDate ? (row.admissionDate | date: 'd MMM y') : '—' }}{{ row.approvedShares | number }}{{ row.pendingShares ? (row.pendingShares | number) : '—' }}₹{{ row.unitPrice | number: '1.0-2' }}₹{{ row.shareValue | number: '1.0-0' }} + {{ row.statusLabel }} + + +
+
+ } +
+ +
+ Share Value counts approved shares only — shares still pending approval are held in the Share Sundry GL and are not + yet part of share capital. +
+
diff --git a/src/app/letter-mgmt/share-member/share-member.component.scss b/src/app/letter-mgmt/share-member/share-member.component.scss new file mode 100644 index 000000000..9ff84d4e0 --- /dev/null +++ b/src/app/letter-mgmt/share-member/share-member.component.scss @@ -0,0 +1,99 @@ +/** + * 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/. + */ + +/* Every row navigates, so the whole row reads as the target rather than a lone link cell. */ +.sm-row { + cursor: pointer; + transition: background 0.12s ease; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--cert-blue) 6%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--cert-blue); + outline-offset: -2px; + } + + /* The chevron only earns its space once the row is the one being pointed at. */ + &:hover .sm-chevron, + &:focus-visible .sm-chevron { + opacity: 1; + } +} + +.sm-chevron { + opacity: 0; + color: var(--cert-blue); + transition: opacity 0.12s ease; +} + +/* Addresses are long and would otherwise set the table's width on their own; the + full text stays reachable through the row tooltip. */ +.sm-address { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cert-table { + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } +} + +/* Status tones — mapped from the account status rather than typed per row. */ +.sm-pill--active { + background: var(--cert-green-l); + color: var(--cert-green); +} + +.sm-pill--pending { + background: var(--cert-amber-l); + color: var(--cert-amber); +} + +.sm-pill--closed { + background: var(--cert-g100); + color: var(--cert-g700); +} + +.sm-pill--rejected { + background: color-mix(in srgb, #dc2626 12%, transparent); + color: #b91c1c; +} + +.sm-retry { + margin-left: 10px; +} + +/* Printing the register: drop the chrome and let the table use the full page. */ +@media print { + .cert-filters, + .cert-head-r, + .cert-kpis, + .sm-chevron { + display: none !important; + } + + .cert-card { + border: none; + } + + .sm-address { + max-width: none; + white-space: normal; + } + + .cert-table { + font-size: 10px; + } +} diff --git a/src/app/letter-mgmt/share-member/share-member.component.ts b/src/app/letter-mgmt/share-member/share-member.component.ts new file mode 100644 index 000000000..15867abca --- /dev/null +++ b/src/app/letter-mgmt/share-member/share-member.component.ts @@ -0,0 +1,359 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatTooltip } from '@angular/material/tooltip'; +import { forkJoin, of, Observable } from 'rxjs'; +import { catchError, map, shareReplay, switchMap } from 'rxjs/operators'; + +import { SharesService } from '../../shares/shares.service'; +import { ClientsService } from '../../clients/clients.service'; + +/** + * One share member as this register shows them: the member's identity on the left, + * their holding on the right. Kept flat (rather than nesting the raw payload) so the + * template can filter on any column without reaching through optional chains. + */ +export interface ShareMemberRow { + /** Share account id — the navigation target, never shown as a column. */ + accountId: number; + clientId: string; + /** Client account no, which is what the branch calls the membership number. */ + membershipNo: string; + memberName: string; + folioNo: string; + shareAccountNo: string; + productName: string; + branch: string; + address: string; + mobileNo: string; + /** Date of admission — when the member was activated, not when this account opened. */ + admissionDate: string; + approvedShares: number; + pendingShares: number; + unitPrice: number; + shareValue: number; + statusLabel: string; + /** Drives the status pill's colour class. */ + statusTone: 'active' | 'pending' | 'closed' | 'rejected'; +} + +@Component({ + selector: 'mifosx-share-member', + templateUrl: './share-member.component.html', + styleUrls: [ + '../certificates/certificates.shared.scss', + './share-member.component.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class ShareMemberComponent implements OnInit { + private sharesService = inject(SharesService); + private clientsService = inject(ClientsService); + private router = inject(Router); + + loading = true; + loadError = false; + rows: ShareMemberRow[] = []; + + // ── Filters ── + search = ''; + branchFilter = ''; + productFilter = ''; + statusFilter = ''; + + /** + * One member can hold several share accounts, so both client lookups are cached per + * client id — otherwise a member with four accounts costs four identical round trips. + */ + private clientDataCache = new Map>(); + private clientAddressCache = new Map>(); + + ngOnInit(): void { + this.loadMembers(); + } + + loadMembers(): void { + this.loading = true; + this.loadError = false; + this.clientDataCache.clear(); + this.clientAddressCache.clear(); + + this.sharesService + .getAllShareAccounts(0, 200) + .pipe( + map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])), + switchMap((items: any[]) => { + if (!items.length) { + return of([] as ShareMemberRow[]); + } + return forkJoin(items.map((item) => this.buildRow(item))).pipe( + map((rows) => rows.filter((r): r is ShareMemberRow => !!r)) + ); + }), + catchError(() => { + this.loadError = true; + return of([] as ShareMemberRow[]); + }) + ) + .subscribe((rows) => { + // Newest admissions first: a register is read from the most recent entry back. + this.rows = rows.sort((a, b) => (b.admissionDate || '').localeCompare(a.admissionDate || '')); + this.loading = false; + }); + } + + /** + * The list payload already carries clientId, so the account read and both client reads + * fire together rather than in sequence. A row whose account read fails is dropped + * instead of failing the whole register. + */ + private buildRow(listItem: any): Observable { + const clientId = listItem?.clientId != null ? String(listItem.clientId) : null; + return forkJoin({ + sa: this.sharesService.getSharesAccountData(String(listItem.id), false), + client: clientId ? this.getClientDataCached(clientId) : of(null), + address: clientId ? this.getClientAddressCached(clientId) : of([]) + }).pipe( + map(({ sa, client, address }: any) => this.mapToRow(sa, client, address)), + catchError(() => of(null)) + ); + } + + private getClientDataCached(clientId: string): Observable { + if (!this.clientDataCache.has(clientId)) { + this.clientDataCache.set( + clientId, + this.clientsService.getClientData(clientId, true).pipe( + catchError(() => of(null)), + shareReplay(1) + ) + ); + } + return this.clientDataCache.get(clientId)!; + } + + private getClientAddressCached(clientId: string): Observable { + if (!this.clientAddressCache.has(clientId)) { + this.clientAddressCache.set( + clientId, + this.clientsService.getClientAddressData(clientId, true).pipe( + catchError(() => of([])), + shareReplay(1) + ) + ); + } + return this.clientAddressCache.get(clientId)!; + } + + private mapToRow(sa: any, client: any, address: any): ShareMemberRow { + const approvedShares = sa.summary?.totalApprovedShares || 0; + const pendingShares = sa.summary?.totalPendingForApprovalShares || 0; + const unitPrice = sa.currentMarketPrice || sa.unitPrice || 0; + const clientId = String(sa.clientId ?? client?.id ?? ''); + const statusValue = sa.status?.value || ''; + + return { + accountId: sa.id, + clientId, + membershipNo: client?.accountNo || sa.clientAccountNo || '—', + memberName: sa.clientName || client?.displayName || '—', + folioNo: `F-${client?.accountNo || String(client?.id ?? sa.clientId ?? '').padStart(5, '0')}`, + shareAccountNo: sa.accountNo || String(sa.id), + productName: sa.productName || '—', + branch: client?.officeName || sa.officeName || '—', + address: this.firstAddress(address), + mobileNo: client?.mobileNo || '—', + // Admission is the member's activation with the society, which is what the + // Membership Register records — the share account may be opened much later. + admissionDate: + this.toIsoDate(client?.activationDate) || + this.toIsoDate(client?.timeline?.activatedOnDate) || + this.toIsoDate(sa.timeline?.activatedDate) || + '', + approvedShares, + pendingShares, + unitPrice, + // Only approved shares carry value: pending ones are still sitting in Sundry. + shareValue: approvedShares * unitPrice, + statusLabel: statusValue || '—', + statusTone: this.toneFor(sa.status) + }; + } + + private toneFor(status: any): ShareMemberRow['statusTone'] { + if (status?.rejected) return 'rejected'; + if (status?.closed) return 'closed'; + if (status?.active) return 'active'; + const v = String(status?.value || '').toLowerCase(); + if (v.includes('reject')) return 'rejected'; + if (v.includes('close')) return 'closed'; + if (v.includes('active')) return 'active'; + return 'pending'; + } + + /** Fineract returns dates as [y, m, d] tuples on most reads and ISO strings on some. */ + private toIsoDate(value: any): string { + if (!value) { + return ''; + } + if (Array.isArray(value)) { + const [ + y, + m, + d + ] = value; + if (!y) { + return ''; + } + return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`; + } + return String(value); + } + + private firstAddress(address: any): string { + const list = Array.isArray(address) ? address : address?.pageItems || []; + if (!list.length) { + return '—'; + } + const a = list[0]; + const parts = [ + a.addressLine1, + a.addressLine2, + a.townVillage, + a.city, + a.stateName, + a.countryName, + a.postalCode + ] + .filter((p: any) => !!p && String(p).trim()) + .join(', '); + return parts || '—'; + } + + // ── Derived list ── + get filteredRows(): ShareMemberRow[] { + const q = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.branch !== this.branchFilter) return false; + if (this.productFilter && r.productName !== this.productFilter) return false; + if (this.statusFilter && r.statusTone !== this.statusFilter) return false; + if (!q) return true; + return ( + r.memberName.toLowerCase().includes(q) || + r.membershipNo.toLowerCase().includes(q) || + r.shareAccountNo.toLowerCase().includes(q) || + r.folioNo.toLowerCase().includes(q) || + r.mobileNo.toLowerCase().includes(q) + ); + }); + } + + get branchOptions(): string[] { + return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort(); + } + + get productOptions(): string[] { + return Array.from(new Set(this.rows.map((r) => r.productName).filter((p) => p && p !== '—'))).sort(); + } + + /** Distinct members, not accounts — one member holding three accounts counts once. */ + totalMembers(): number { + return new Set(this.filteredRows.map((r) => r.clientId)).size; + } + + totalShares(): number { + return this.filteredRows.reduce((s, r) => s + r.approvedShares, 0); + } + + totalValue(): number { + return this.filteredRows.reduce((s, r) => s + r.shareValue, 0); + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + this.statusFilter = ''; + } + + /** Opens the member's share account — the same view the client's own tab links to. */ + openAccount(row: ShareMemberRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.accountId, + 'general' + ]); + } + + exportCsv(): void { + const header = [ + 'S.No', + 'Membership No', + 'Member Name', + 'Folio No', + 'Share A/c No', + 'Product', + 'Branch', + 'Address', + 'Mobile', + 'Date of Admission', + 'Shares Held', + 'Pending Shares', + 'Unit Price', + 'Share Value', + 'Status' + ]; + const rows = this.filteredRows.map((r, i) => [ + i + 1, + r.membershipNo, + r.memberName, + r.folioNo, + r.shareAccountNo, + r.productName, + r.branch, + r.address, + r.mobileNo, + r.admissionDate, + r.approvedShares, + r.pendingShares, + r.unitPrice, + r.shareValue, + r.statusLabel + ]); + const csv = [ + header, + ...rows + ] + .map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')) + .join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'share-members.csv'; + link.click(); + URL.revokeObjectURL(url); + } + + print(): void { + window.print(); + } +} diff --git a/src/app/organization/branch-profile/branch-profile.component.html b/src/app/organization/branch-profile/branch-profile.component.html new file mode 100644 index 000000000..0ad5d381b --- /dev/null +++ b/src/app/organization/branch-profile/branch-profile.component.html @@ -0,0 +1,167 @@ + + +
+
+ +

Branch Profile

+
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + +
+
+
+ +

Select Branch

+
+
+ @if (loadingOffices) { + Loading branches… + } @else { + + Branch + + @for (o of offices; track o.officeId) { + {{ officeLabel(o) }} + } + + + } +
+
+ + @if (loadingProfile) { +
Loading…
+ } + + @if (selectedOfficeId && !loadingProfile) { +
+
+
+ +

Branch Details

+
+
+ + Branch Code + + + + Branch Type + + @for (t of branchTypes; track t.value) { + {{ t.label }} + } + + + + Address + + + + Pincode + + + + Contact Phone + + + + Contact Email + + +
+
+ +
+
+ +

Branch Identifiers

+
+
+ + IFSC Code + + + + MICR Code + + +
+

+ SWIFT/BIC is set once for the whole bank, not per branch — see + Bank Profile. +

+
+ +
+
+ +

Location (optional)

+
+
+ + Latitude + + + + Longitude + + +
+
+ +
+
+ +

Working Hours

+
+
+ + Opens At + + + + Closes At + + + + Working Days + + +
+
+ + @if (lastUpdated) { +
Last updated by {{ lastUpdated.by }} · {{ lastUpdated.at | dateFormat }}
+ } + +
+ +
+
+ } +
+
diff --git a/src/app/organization/branch-profile/branch-profile.component.scss b/src/app/organization/branch-profile/branch-profile.component.scss new file mode 100644 index 000000000..200f99b04 --- /dev/null +++ b/src/app/organization/branch-profile/branch-profile.component.scss @@ -0,0 +1,175 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +$primary: #1353a0; +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-900: #202124; + +.bp-container { + max-width: 760px; + margin: 0 auto; + padding: 20px 16px; +} + +.bp-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 20px; + + &__title { + font-size: 18px; + font-weight: 600; + color: $gray-900; + margin: 0; + } +} + +.bp-back { + border-radius: 8px !important; + font-size: 12px !important; + font-weight: 600 !important; + height: 32px !important; + border-color: $border !important; + color: $gray-500 !important; + + i { + margin-right: 6px; + } + + &:hover { + color: $primary !important; + border-color: $primary !important; + } +} + +.bp-alert { + padding: 10px 14px; + margin-bottom: 14px; + border-radius: 8px; + font-size: 13px; + + &--error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + } + + &--success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #14532d; + } +} + +.bp-muted { + color: $gray-500; + font-size: 13px; +} + +.bp-loading { + padding: 24px; + text-align: center; + color: $gray-500; +} + +.bp-card { + background: #fff; + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; +} + +.bp-section { + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + i { + font-size: 14px; + color: $accent; + } + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + } +} + +.bp-body { + padding: 20px 24px; + display: flex; + flex-wrap: wrap; + gap: 0 16px; +} + +.bp-field { + width: 100%; + margin-bottom: 4px; + + &--half { + width: calc(50% - 8px); + } + + &--third { + width: calc(33.33% - 11px); + } +} + +.bp-note { + margin: 0 24px 16px; + padding: 8px 12px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + font-size: 11px; + color: #1e40af; + + a { + color: $primary; + font-weight: 600; + } +} + +.bp-lastupdated { + padding: 0 24px 10px; + font-size: 11px; + color: $gray-500; +} + +.bp-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 24px; + border-top: 1px solid $gray-200; + background: $gray-50; + + &__submit { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + height: 36px !important; + box-shadow: 0 1px 3px rgba($primary, 0.3) !important; + + i { + margin-right: 6px; + font-size: 12px; + } + } +} diff --git a/src/app/organization/branch-profile/branch-profile.component.ts b/src/app/organization/branch-profile/branch-profile.component.ts new file mode 100644 index 000000000..628d0cb6b --- /dev/null +++ b/src/app/organization/branch-profile/branch-profile.component.ts @@ -0,0 +1,153 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { catchError, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; + +const BRANCH_TYPES = [ + { value: 'HEAD_OFFICE', label: 'Head Office' }, + { value: 'BRANCH', label: 'Branch' }, + { value: 'REGIONAL_OFFICE', label: 'Regional Office' }, + { value: 'ZONAL_OFFICE', label: 'Zonal Office' } +]; + +/** + * The real-world operational fields behind a branch — code, address, contact, type and working + * hours — kept as their own screen on top of the stock Manage Offices flow, since m_office (the + * core Fineract entity) was deliberately left untouched. Pick an office, fill in what real-world + * UCB branch setup expects, save. + */ +@Component({ + selector: 'mifosx-branch-profile', + templateUrl: './branch-profile.component.html', + styleUrls: ['./branch-profile.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class BranchProfileComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private organizationService = inject(OrganizationService); + + branchTypes = BRANCH_TYPES; + + offices: any[] = []; + selectedOfficeId: number | null = null; + form: UntypedFormGroup; + + loadingOffices = true; + loadingProfile = false; + saving = false; + errorMsg = ''; + successMsg = ''; + lastUpdated: any = null; + + ngOnInit(): void { + this.form = this.formBuilder.group({ + branchCode: [''], + branchType: [null], + address: [''], + pincode: [''], + contactPhone: [''], + contactEmail: [''], + workingHoursStart: [''], + workingHoursEnd: [''], + workingDays: [''], + ifscCode: [''], + micrCode: [''], + latitude: [null], + longitude: [null] + }); + + this.organizationService + .getBranchProfiles() + .pipe( + catchError((err: any) => { + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not load branches.'; + return of([]); + }) + ) + .subscribe((res: any[]) => { + this.loadingOffices = false; + this.offices = res || []; + if (this.offices.length === 1) { + this.selectOffice(this.offices[0].officeId); + } + }); + } + + selectOffice(officeId: number): void { + this.selectedOfficeId = officeId; + this.loadingProfile = true; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .getBranchProfile(officeId) + .pipe( + catchError((err: any) => { + this.loadingProfile = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not load this branch.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loadingProfile = false; + if (!res) return; + this.form.patchValue({ + branchCode: res.branchCode ?? '', + branchType: res.branchType ?? null, + address: res.address ?? '', + pincode: res.pincode ?? '', + contactPhone: res.contactPhone ?? '', + contactEmail: res.contactEmail ?? '', + workingHoursStart: res.workingHoursStart ?? '', + workingHoursEnd: res.workingHoursEnd ?? '', + workingDays: res.workingDays ?? '', + ifscCode: res.ifscCode ?? '', + micrCode: res.micrCode ?? '', + latitude: res.latitude ?? null, + longitude: res.longitude ?? null + }); + this.lastUpdated = res.updatedByUsername ? { by: res.updatedByUsername, at: res.updatedAt } : null; + this.form.markAsPristine(); + // Reflect the just-loaded office in the list so the selector stays accurate without a reload. + const idx = this.offices.findIndex((o) => o.officeId === officeId); + if (idx >= 0) this.offices[idx] = res; + }); + } + + officeLabel(o: any): string { + return o.branchCode ? `${o.officeName} (${o.branchCode})` : o.officeName; + } + + submit(): void { + if (this.saving || !this.selectedOfficeId) return; + this.saving = true; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .updateBranchProfile(this.selectedOfficeId, this.form.value) + .pipe( + catchError((err: any) => { + this.saving = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this branch.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (!res) return; + this.successMsg = 'Branch profile saved.'; + this.form.markAsPristine(); + this.lastUpdated = res.updatedByUsername ? { by: res.updatedByUsername, at: res.updatedAt } : null; + const idx = this.offices.findIndex((o) => o.officeId === this.selectedOfficeId); + if (idx >= 0) this.offices[idx] = res; + }); + } +} diff --git a/src/app/organization/clearing-gl-config/clearing-gl-config.component.html b/src/app/organization/clearing-gl-config/clearing-gl-config.component.html new file mode 100644 index 000000000..cca3e2d3a --- /dev/null +++ b/src/app/organization/clearing-gl-config/clearing-gl-config.component.html @@ -0,0 +1,70 @@ + + +
+
+ +

Clearing GL Configuration

+
+

+ Which GL account backs each clearing role. An unmapped role posts nowhere — map it here before that clearing channel + goes live. +

+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + + @if (loading) { +
Loading…
+ } @else { +
+ + + + + + + + + + @for (row of rows; track row.configKey) { + + + + + + } + +
Clearing RoleDescriptionMapped GL Account
+ {{ row.configKey }} + {{ row.description }} + + + — Not mapped — + @for (o of glAccountOptions; track o.id) { + {{ o.glCode }} · {{ o.name }} + } + + + @if (!row.glAccountId) { + Unmapped + } +
+
+ } +
diff --git a/src/app/organization/clearing-gl-config/clearing-gl-config.component.scss b/src/app/organization/clearing-gl-config/clearing-gl-config.component.scss new file mode 100644 index 000000000..a231ce25e --- /dev/null +++ b/src/app/organization/clearing-gl-config/clearing-gl-config.component.scss @@ -0,0 +1,137 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +$primary: #1353a0; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-900: #202124; + +.cgc-container { + max-width: 900px; + margin: 0 auto; + padding: 20px 16px; +} + +.cgc-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 6px; + + &__title { + font-size: 18px; + font-weight: 600; + color: $gray-900; + margin: 0; + } +} + +.cgc-sub { + margin: 0 0 16px; + font-size: 12px; + color: $gray-500; +} + +.cgc-back { + border-radius: 8px !important; + font-size: 12px !important; + font-weight: 600 !important; + height: 32px !important; + border-color: $border !important; + color: $gray-500 !important; + + i { + margin-right: 6px; + } + + &:hover { + color: $primary !important; + border-color: $primary !important; + } +} + +.cgc-alert { + padding: 10px 14px; + margin-bottom: 14px; + border-radius: 8px; + font-size: 13px; + + &--error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + } + + &--success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #14532d; + } +} + +.cgc-loading { + padding: 24px; + text-align: center; + color: $gray-500; +} + +.cgc-card { + background: #fff; + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; +} + +.cgc-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + padding: 10px 16px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-500; + } + + td { + padding: 10px 16px; + border-bottom: 1px solid $gray-200; + vertical-align: middle; + } + + tr:last-child td { + border-bottom: none; + } +} + +.cgc-key { + font-family: monospace; + font-weight: 700; + color: $primary; +} + +.cgc-desc { + color: #475569; +} + +.cgc-select { + min-width: 260px; +} + +.cgc-unmapped { + margin-left: 8px; + font-size: 11px; + font-weight: 700; + color: #b45309; +} diff --git a/src/app/organization/clearing-gl-config/clearing-gl-config.component.ts b/src/app/organization/clearing-gl-config/clearing-gl-config.component.ts new file mode 100644 index 000000000..1bb04a305 --- /dev/null +++ b/src/app/organization/clearing-gl-config/clearing-gl-config.component.ts @@ -0,0 +1,75 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { catchError, forkJoin, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; + +/** + * Which GL account backs each clearing role (CTS inward/outward suspense, NACH suspense, RTGS + * settlement, ...). The roles themselves are fixed; this only changes which GL account each one + * points at. + */ +@Component({ + selector: 'mifosx-clearing-gl-config', + templateUrl: './clearing-gl-config.component.html', + styleUrls: ['./clearing-gl-config.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class ClearingGlConfigComponent implements OnInit { + private organizationService = inject(OrganizationService); + + rows: any[] = []; + glAccountOptions: any[] = []; + loading = true; + savingKey: string | null = null; + errorMsg = ''; + successMsg = ''; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + forkJoin({ + rows: this.organizationService.getClearingGlConfig().pipe(catchError(() => of([]))), + template: this.organizationService.getClearingGlConfigTemplate().pipe(catchError(() => of(null))) + }).subscribe(({ rows, template }: any) => { + this.loading = false; + this.rows = rows || []; + this.glAccountOptions = template?.glAccountOptions || []; + if (!rows || rows.length === 0) { + this.errorMsg = 'Could not load clearing GL configuration.'; + } + }); + } + + onSelect(row: any, glAccountId: number | null): void { + this.savingKey = row.configKey; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .updateClearingGlConfig(row.configKey, glAccountId) + .pipe( + catchError((err: any) => { + this.savingKey = null; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.'; + return of(null); + }) + ) + .subscribe((res: any[]) => { + this.savingKey = null; + if (!res) return; + this.rows = res; + this.successMsg = `${row.configKey} updated.`; + }); + } +} diff --git a/src/app/organization/institution-profile/institution-profile.component.html b/src/app/organization/institution-profile/institution-profile.component.html new file mode 100644 index 000000000..a4d4f79a2 --- /dev/null +++ b/src/app/organization/institution-profile/institution-profile.component.html @@ -0,0 +1,395 @@ + + +
+
+ +

Bank Profile

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

Basic Information

+
+
+ + Bank / Institution Name + + + + Short Name / Abbreviation + + + + Legal / Registered Name + + + + Bank Code + + + + Institution Code + + + + Institution Type + + @for (t of bankTypes; track t.value) { + {{ t.label }} + } + + + + Ownership Type + + @for (o of ownershipTypes; track o.value) { + {{ o.label }} + } + + + + Parent Institution (if any) + + + + Established Date + + + + + + Status + + @for (s of statuses; track s.value) { + {{ s.label }} + } + + + + Country + + + + Currency + + Set once for the whole system, not editable here + +
+
+ + +
+
+ +

Regulatory Information

+
+
+ + Regulatory Authority + + + + Registration Date + + + + + + Registration Number + + + + License Number + + + + License Status + + @for (l of licenseStatuses; track l.value) { + {{ l.label }} + } + + + + License Expiry Date + + + + + + PAN + + + + TAN + + + + GSTIN + + +
+
+ + +
+
+ +

Registered Office Address

+
+
+ + Address Line 1 + + + + Address Line 2 + + + + City + + + + District + + + + State + + + + PIN Code + + +
+
+ +
+
+ +

Head Office / Corporate Address

+
+
+ + Same as Registered Office Address + + @if (!form.value.headOfficeSameAsRegistered) { + + Head Office Address + + + } +
+
+ +
+
+ +

Communication Address

+
+
+ + Same as Registered Office Address + + @if (!form.value.communicationSameAsRegistered) { + + Communication Address + + + } +
+
+ + +
+
+ +

Contact Details

+
+
+ + Official Email + + + + Phone Number + + + + Customer Care Number + + + + Grievance / Support Email + + + + Website + + +
+
+ + +
+
+ +

Banking Configuration

+
+
+

+ Business date, financial year, time zone, working days and the holiday calendar are configured on their + own screens rather than duplicated here, so there is exactly one place each is set: +

+ +
+
+ + +
+
+ +

Banking Identifiers

+
+
+ + SWIFT / BIC + + +
+

+ IFSC and MICR are branch-level identifiers, not institution-level — a bank with multiple branches has a + different IFSC/MICR per branch, so those are set on each branch's own + Branch Profile instead. +

+
+ + +
+
+ +

Document Headers

+
+
+ + Statement Header Name + + + + Passbook Header Name + + + + Receipt Header Name + + + + Terms & Conditions + + +
+
+ + +
+
+ +

Authorized Representative

+
+
+ + Name + + + + Designation + + + + Contact Number + + + + Official Email + + +
+
+ + + @if (audit) { +
+ @if (audit.createdBy) { + Created by {{ audit.createdBy }} · {{ audit.createdAt | dateFormat }} + } + @if (audit.updatedBy) { + Last updated by {{ audit.updatedBy }} · {{ audit.updatedAt | dateFormat }} + } + @if (audit.activationDate) { + Activated {{ audit.activationDate }} + } + @if (audit.deactivationDate) { + Deactivated {{ audit.deactivationDate }} + } +
+ } + +
+ +
+
+
+ } +
diff --git a/src/app/organization/institution-profile/institution-profile.component.scss b/src/app/organization/institution-profile/institution-profile.component.scss new file mode 100644 index 000000000..7b30182a4 --- /dev/null +++ b/src/app/organization/institution-profile/institution-profile.component.scss @@ -0,0 +1,226 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +$primary: #1353a0; +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-700: #5f6368; +$gray-900: #202124; + +.ip-container { + max-width: 760px; + margin: 0 auto; + padding: 20px 16px; +} + +.ip-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 20px; + + &__title { + font-size: 18px; + font-weight: 600; + color: $gray-900; + margin: 0; + } +} + +.ip-back { + border-radius: 8px !important; + font-size: 12px !important; + font-weight: 600 !important; + height: 32px !important; + border-color: $border !important; + color: $gray-500 !important; + + i { + margin-right: 6px; + } + + &:hover { + color: $primary !important; + border-color: $primary !important; + } +} + +.ip-alert { + padding: 10px 14px; + margin-bottom: 14px; + border-radius: 8px; + font-size: 13px; + + &--error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + } + + &--success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #14532d; + } +} + +.ip-loading { + padding: 24px; + text-align: center; + color: $gray-500; +} + +.ip-card { + background: #fff; + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; +} + +.ip-section { + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + i { + font-size: 14px; + color: $accent; + } + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + } +} + +.ip-body { + padding: 20px 24px; + display: flex; + flex-wrap: wrap; + gap: 0 16px; +} + +.ip-field { + width: 100%; + margin-bottom: 4px; + + &--half { + width: calc(50% - 8px); + } + + &--third { + width: calc(33.33% - 11px); + } + + &--quarter { + width: calc(25% - 12px); + } +} + +.ip-note { + margin: 0 24px 16px; + padding: 8px 12px; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 6px; + font-size: 11px; + color: #92400e; + + a { + color: $primary; + font-weight: 600; + } +} + +.ip-checkbox { + display: block; + margin-bottom: 8px; + font-size: 13px; +} + +.ip-refnote { + margin: 0 0 10px; + font-size: 12px; + color: #64748b; +} + +.ip-reflinks { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ip-reflink { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: #fff; + border: 1px solid $border; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: $primary; + text-decoration: none; + + &:hover { + background: #eff6ff; + border-color: $primary; + } + + i { + font-size: 11px; + color: $accent; + } +} + +.ip-lastupdated { + padding: 0 24px 10px; + font-size: 11px; + color: $gray-500; +} + +.ip-audit { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding: 8px 24px 10px; + font-size: 11px; + color: $gray-500; +} + +.ip-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 24px; + border-top: 1px solid $gray-200; + background: $gray-50; + + &__submit { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + height: 36px !important; + box-shadow: 0 1px 3px rgba($primary, 0.3) !important; + + i { + margin-right: 6px; + font-size: 12px; + } + } +} diff --git a/src/app/organization/institution-profile/institution-profile.component.ts b/src/app/organization/institution-profile/institution-profile.component.ts new file mode 100644 index 000000000..2d04f9480 --- /dev/null +++ b/src/app/organization/institution-profile/institution-profile.component.ts @@ -0,0 +1,211 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { catchError, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { DISABLED_ACCOUNTING_FEATURES } from '../../accounting/disabled-features'; + +const BANK_TYPES = [ + { value: 'COOPERATIVE', label: 'Co-operative Bank' }, + { value: 'COMMERCIAL', label: 'Commercial Bank' }, + { value: 'RRB', label: 'Regional Rural Bank' }, + { value: 'NBFC', label: 'NBFC' }, + { value: 'OTHER', label: 'Other' } +]; + +const OWNERSHIP_TYPES = [ + { value: 'GOVERNMENT', label: 'Government' }, + { value: 'PRIVATE', label: 'Private' }, + { value: 'COOPERATIVE', label: 'Cooperative' }, + { value: 'OTHER', label: 'Other' } +]; + +const LICENSE_STATUSES = [ + { value: 'ACTIVE', label: 'Active' }, + { value: 'EXPIRED', label: 'Expired' }, + { value: 'SUSPENDED', label: 'Suspended' }, + { value: 'NOT_APPLICABLE', label: 'Not Applicable' } +]; + +const STATUSES = [ + { value: 'ACTIVE', label: 'Active' }, + { value: 'INACTIVE', label: 'Inactive' } +]; + +/** + * The bank's own "Bank Master" profile — identity/classification, regulatory details, address + * (registered, with optional head-office/communication overrides), contact, the institution-level + * banking identifier (SWIFT/BIC), document header names and terms, the authorised representative, + * and audit fields. A singleton screen: there is only ever one institution running this tenant. + * + * IFSC/MICR are deliberately NOT here — those identify a specific branch, not the institution, and + * live on Branch Profile instead. Business Date, Financial Year, Time Zone, Working Days and Holiday + * Calendar are likewise not stored here — they already exist as their own configuration, and this + * screen only links to them (see the Operational Configuration section) instead of holding a second, + * driftable copy. + */ +@Component({ + selector: 'mifosx-institution-profile', + templateUrl: './institution-profile.component.html', + styleUrls: ['./institution-profile.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class InstitutionProfileComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + + /** Accounting features switched off for this installation — see accounting/disabled-features.ts. */ + disabledFeatures = DISABLED_ACCOUNTING_FEATURES; + bankTypes = BANK_TYPES; + ownershipTypes = OWNERSHIP_TYPES; + licenseStatuses = LICENSE_STATUSES; + statuses = STATUSES; + + form: UntypedFormGroup; + loading = true; + saving = false; + errorMsg = ''; + successMsg = ''; + currencyCode = ''; + audit: any = null; + + private static readonly DATE_FIELDS = [ + 'establishedDate', + 'registrationDate', + 'licenseExpiryDate' + ]; + + ngOnInit(): void { + this.form = this.formBuilder.group({ + bankName: [''], + legalName: [''], + shortName: [''], + bankCode: [''], + institutionCode: [''], + bankType: [null], + ownershipType: [null], + parentInstitutionName: [''], + establishedDate: [null], + status: ['ACTIVE'], + country: ['India'], + + registrationNumber: [''], + licenseNumber: [''], + regulatoryAuthority: [''], + registrationDate: [null], + licenseStatus: [null], + licenseExpiryDate: [null], + pan: [''], + tan: [''], + gstin: [''], + + addressLine1: [''], + addressLine2: [''], + city: [''], + district: [''], + state: [''], + pincode: [''], + headOfficeSameAsRegistered: [true], + headOfficeAddress: [''], + communicationSameAsRegistered: [true], + communicationAddress: [''], + + contactPhone: [''], + contactEmail: [''], + customerCareNumber: [''], + grievanceEmail: [''], + website: [''], + + swiftBic: [''], + + statementHeaderName: [''], + passbookHeaderName: [''], + receiptHeaderName: [''], + termsAndConditions: [''], + + authorizedPersonName: [''], + authorizedPersonDesignation: [''], + authorizedPersonContact: [''], + authorizedPersonEmail: [''] + }); + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.organizationService + .getInstitutionProfile() + .pipe( + catchError((err: any) => { + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not load the bank profile.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loading = false; + if (!res) return; + this.applyResult(res); + this.form.markAsPristine(); + }); + } + + submit(): void { + if (this.saving) return; + this.saving = true; + this.errorMsg = ''; + this.successMsg = ''; + const raw = this.form.value; + const payload: any = { ...raw, locale: this.settingsService.language.code }; + for (const field of InstitutionProfileComponent.DATE_FIELDS) { + payload[field] = raw[field] ? this.formatDate(raw[field]) : null; + } + this.organizationService + .updateInstitutionProfile(payload) + .pipe( + catchError((err: any) => { + this.saving = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save the bank profile.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (!res) return; + this.successMsg = 'Bank profile saved.'; + this.applyResult(res); + this.form.markAsPristine(); + }); + } + + private applyResult(res: any): void { + const patch: any = { ...res }; + for (const field of InstitutionProfileComponent.DATE_FIELDS) { + patch[field] = res[field] ? new Date(res[field]) : null; + } + this.form.patchValue(patch); + this.currencyCode = res.currencyCode ?? ''; + this.audit = { + createdBy: res.createdByUsername, + createdAt: res.createdAt, + updatedBy: res.updatedByUsername, + updatedAt: res.updatedAt, + activationDate: res.activationDate, + deactivationDate: res.deactivationDate + }; + } + + private formatDate(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } +} diff --git a/src/app/organization/offices/offices.component.ts b/src/app/organization/offices/offices.component.ts index 518b5bbd9..2f71d361f 100644 --- a/src/app/organization/offices/offices.component.ts +++ b/src/app/organization/offices/offices.component.ts @@ -262,8 +262,15 @@ export class OfficesComponent implements OnInit { ref.afterClosed().subscribe((response: any) => { if (!response?.delete) return; this.organizationService.deleteOffice(row.id).subscribe({ - next: () => { - this.showSuccess('Office Removed', `"${row.name}" has been deleted.`); + next: (response: any) => { + if (response?.rollbackTransaction) { + this.showSuccess( + 'Sent for Approval', + `Deleting "${row.name}" has been submitted and is awaiting checker approval.` + ); + } else { + this.showSuccess('Office Removed', `"${row.name}" has been deleted.`); + } this.refreshOffices(); }, error: (err: any) => { diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 64fb7f7ba..4c91ad7ca 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -271,6 +271,30 @@ const routes: Routes = [ component: DdsBatchProcessingComponent, data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' } }, + { + // Bank's own profile — name, legal name, code, registration, address/contact. A + // singleton, unlike every other screen under Organization, so it has no create/list step. + path: 'institution-profile', + loadComponent: () => + import('./institution-profile/institution-profile.component').then((m) => m.InstitutionProfileComponent), + data: { title: 'Bank Profile', breadcrumb: 'Bank Profile' } + }, + { + // The real-world fields (code, address, contact, type, working hours) behind a branch — + // kept as its own screen rather than folded into Manage Offices, since the underlying + // m_office record is a stock Fineract entity this CBS deliberately left untouched. + path: 'branch-profile', + loadComponent: () => + import('./branch-profile/branch-profile.component').then((m) => m.BranchProfileComponent), + data: { title: 'Branch Profile', breadcrumb: 'Branch Profile' } + }, + { + // Which GL account backs each clearing suspense/settlement role (CTS, NACH, RTGS, ...). + path: 'clearing-gl-config', + loadComponent: () => + import('./clearing-gl-config/clearing-gl-config.component').then((m) => m.ClearingGlConfigComponent), + data: { title: 'Clearing GL Configuration', breadcrumb: 'Clearing GL Configuration' } + }, { path: 'employees', data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' }, diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index e13b1e18b..dab768f4e 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -34,6 +34,48 @@
+ + + + + + + + +
diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index 44e296c6e..91030b2d9 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -1156,4 +1156,51 @@ export class OrganizationService { deleteLoanOriginator(originatorId: number): Observable { return this.http.delete(`/loan-originators/${originatorId}`); } + + // ── Institution Profile ── + + /** @returns {Observable} The bank's own profile (name, legal name, code, contact...). */ + getInstitutionProfile(): Observable { + return this.http.get('/institutionprofile'); + } + + /** @returns {Observable} */ + updateInstitutionProfile(payload: any): Observable { + return this.http.put('/institutionprofile', payload); + } + + // ── Branch Profile ── + + /** @returns {Observable} Every office joined with its branch profile fields, if any. */ + getBranchProfiles(): Observable { + return this.http.get('/branchprofile'); + } + + /** @returns {Observable} One office's branch profile, joined with its office fields. */ + getBranchProfile(officeId: number): Observable { + const params = new HttpParams().set('officeId', String(officeId)); + return this.http.get('/branchprofile', { params }); + } + + /** @returns {Observable} */ + updateBranchProfile(officeId: number, payload: any): Observable { + return this.http.put(`/branchprofile/${officeId}`, payload); + } + + // ── Clearing GL Configuration ── + + /** @returns {Observable} Every clearing role (CTS/NACH/RTGS...) and its mapped GL account, if any. */ + getClearingGlConfig(): Observable { + return this.http.get('/clearingglconfig'); + } + + /** @returns {Observable} Selectable GL accounts for mapping a clearing role. */ + getClearingGlConfigTemplate(): Observable { + return this.http.get('/clearingglconfig/template'); + } + + /** @returns {Observable} */ + updateClearingGlConfig(configKey: string, glAccountId: number | null): Observable { + return this.http.put(`/clearingglconfig/${configKey}`, { glAccountId }); + } } diff --git a/src/app/pipes/date-format.pipe.ts b/src/app/pipes/date-format.pipe.ts index 4582f2621..c12113dff 100644 --- a/src/app/pipes/date-format.pipe.ts +++ b/src/app/pipes/date-format.pipe.ts @@ -59,6 +59,10 @@ export class DateFormatPipe implements PipeTransform, OnDestroy { if (Array.isArray(value)) { // Backend sometimes returns [yyyy, MM, dd] dateVal = moment(value.join('-'), 'YYYY-MM-DD'); + } else if (typeof value === 'number' && value < 1e12) { + // Unix timestamp in seconds (e.g. /makercheckers madeOnDate). + // moment(seconds) would treat it as milliseconds → 1970; use moment.unix() instead. + dateVal = moment.unix(value); } else { dateVal = moment(value); } diff --git a/src/app/platform-admin/menu-access/menu-access.component.html b/src/app/platform-admin/menu-access/menu-access.component.html new file mode 100644 index 000000000..9ff4d2959 --- /dev/null +++ b/src/app/platform-admin/menu-access/menu-access.component.html @@ -0,0 +1,135 @@ + + +
+
+
+
+ +
+
+

Bank Feature Access

+

+ Platform-level control — caps which top-nav sections a bank can ever show, independent of any role's own + permissions. +

+
+
+ +
+
+ +
+ + + +
+
+ + @if (tenants.length > 0) { +
+ + Connected — {{ tenants.length }} bank{{ tenants.length !== 1 ? 's' : '' }} on this platform +
+ } +
+ + @if (error) { +
+ + {{ error }} +
+ } + @if (savedMessage) { +
+ + {{ savedMessage }} +
+ } + + @if (tenants.length > 0) { +
+
+ + +
+ + @if (loadingMenus) { +
+ + Loading {{ selectedTenantName }}'s menu list… +
+ } + + @if (!loadingMenus && rows.length > 0) { +
+
+ {{ enabledCount }} + / {{ rows.length }} shown +
+
+ + +
+
+ +
+ @for (row of rows; track row.code) { + + } +
+ +
+ Changes apply the moment {{ selectedTenantName }}'s users next load the app. + +
+ } +
+ } +
+
diff --git a/src/app/platform-admin/menu-access/menu-access.component.scss b/src/app/platform-admin/menu-access/menu-access.component.scss new file mode 100644 index 000000000..1e107b19b --- /dev/null +++ b/src/app/platform-admin/menu-access/menu-access.component.scss @@ -0,0 +1,431 @@ +/** + * 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/. + */ + +// ── Design tokens ────────────────────────────────────────────────────────── +$ink: #0f172a; +$ink-soft: #475569; +$ink-faint: #94a3b8; +$border: #e2e8f0; +$bg: #eef2f7; +$card: #fff; +$accent: #4338ca; +$accent-soft: #eef2ff; +$success: #16a34a; +$success-soft: #f0fdf4; +$danger: #dc2626; +$danger-soft: #fef2f2; + +.pma-page { + min-height: 100vh; + width: 100%; + padding: 40px clamp(20px, 4vw, 64px) 80px; + background: radial-gradient(circle at 10% 0%, rgb(67 56 202 / 8%), transparent 45%), $bg; + font-family: + Inter, + system-ui, + -apple-system, + sans-serif; + box-sizing: border-box; +} + +.pma-shell { + width: 100%; +} + +// ── Header ──────────────────────────────────────────────────────────────── +.pma-head { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + + &__badge { + flex: none; + width: 46px; + height: 46px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: $ink; + color: #fff; + font-size: 18px; + box-shadow: 0 6px 16px rgb(15 23 42 / 25%); + } + + &__title { + margin: 0; + font-size: 21px; + font-weight: 800; + color: $ink; + letter-spacing: -0.01em; + } + + &__sub { + margin: 2px 0 0; + font-size: 13px; + color: $ink-soft; + line-height: 1.5; + } +} + +// ── Cards ───────────────────────────────────────────────────────────────── +.pma-card { + background: $card; + border: 1px solid $border; + border-radius: 14px; + padding: 24px; + margin-bottom: 16px; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); +} + +// ── Connect block ───────────────────────────────────────────────────────── +.pma-connect__field { + display: flex; + flex-direction: column; + gap: 6px; + + label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $ink-soft; + } +} + +.pma-key-row { + display: flex; + align-items: center; + gap: 10px; + position: relative; + max-width: 640px; + + input { + flex: 1; + height: 42px; + padding: 0 14px 0 38px; + border: 1.5px solid $border; + border-radius: 9px; + font-size: 14px; + font-family: ui-monospace, monospace; + color: $ink; + transition: border-color 0.15s; + + &:focus { + outline: none; + border-color: $accent; + } + } +} + +.pma-key-ic { + position: absolute; + left: 13px; + color: $ink-faint; + font-size: 13px; + pointer-events: none; +} + +.pma-status { + display: flex; + align-items: center; + gap: 8px; + margin-top: 14px; + padding: 9px 14px; + border-radius: 8px; + font-size: 12.5px; + font-weight: 600; + + &--ok { + background: $success-soft; + color: $success; + } +} + +// ── Alerts ──────────────────────────────────────────────────────────────── +.pma-alert { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-radius: 10px; + font-size: 13px; + font-weight: 500; + margin-bottom: 16px; + + &--error { + background: $danger-soft; + color: $danger; + border: 1px solid #fecaca; + } + + &--ok { + background: $success-soft; + color: $success; + border: 1px solid #bbf7d0; + } +} + +// ── Field / select ──────────────────────────────────────────────────────── +.pma-field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 18px; + + label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $ink-soft; + } + + select { + height: 42px; + max-width: 480px; + padding: 0 14px; + border: 1.5px solid $border; + border-radius: 9px; + font-size: 14px; + font-weight: 600; + color: $ink; + background: #fff; + cursor: pointer; + + &:focus { + outline: none; + border-color: $accent; + } + } +} + +.pma-loading { + display: flex; + align-items: center; + gap: 10px; + padding: 32px 0; + justify-content: center; + font-size: 13.5px; + color: $ink-soft; +} + +// ── Toolbar ─────────────────────────────────────────────────────────────── +.pma-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + flex-wrap: wrap; + gap: 10px; +} + +.pma-count { + display: flex; + align-items: baseline; + gap: 4px; + + &__num { + font-size: 22px; + font-weight: 800; + color: $accent; + } + + &__of { + font-size: 12.5px; + font-weight: 600; + color: $ink-faint; + } +} + +.pma-toolbar__actions { + display: flex; + gap: 8px; +} + +// ── Buttons ─────────────────────────────────────────────────────────────── +.pma-btn { + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 18px; + border-radius: 8px; + font-size: 13px; + font-weight: 700; + cursor: pointer; + border: 1.5px solid transparent; + font-family: inherit; + transition: all 0.15s; + white-space: nowrap; + + &--primary { + background: $ink; + color: #fff; + + &:hover:not(:disabled) { + background: #1e293b; + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + } + + &--ghost { + background: #fff; + border-color: $border; + color: $ink-soft; + height: 34px; + padding: 0 13px; + font-size: 12.5px; + + &:hover { + border-color: $accent; + color: $accent; + background: $accent-soft; + } + } +} + +// ── Menu tile grid ──────────────────────────────────────────────────────── +.pma-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 10px; +} + +.pma-tile { + display: flex; + align-items: center; + gap: 11px; + padding: 12px 14px; + border: 1.5px solid $border; + border-radius: 10px; + background: #fafbfc; + cursor: pointer; + transition: all 0.15s; + + &:hover { + border-color: #cbd5e1; + } + + &--on { + background: $accent-soft; + border-color: #c7d2fe; + } +} + +.pma-tile__ic { + flex: none; + width: 20px; + text-align: center; + font-size: 14px; + color: $ink-faint; + + .pma-tile--on & { + color: $accent; + } +} + +.pma-tile__label { + flex: 1; + font-size: 13.5px; + font-weight: 600; + color: $ink; + line-height: 1.25; +} + +// Custom toggle switch — checkbox visually hidden, styled track+thumb driven by :checked. +.pma-switch { + position: relative; + flex: none; + width: 36px; + height: 20px; + + input { + position: absolute; + inset: 0; + opacity: 0; + margin: 0; + cursor: pointer; + z-index: 1; + + &:checked ~ .pma-switch__track { + background: $accent; + + .pma-switch__thumb { + transform: translateX(16px); + } + } + } +} + +.pma-switch__track { + position: absolute; + inset: 0; + border-radius: 999px; + background: #cbd5e1; + transition: background 0.15s; +} + +.pma-switch__thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgb(0 0 0 / 25%); + transition: transform 0.15s; +} + +// ── Save bar ────────────────────────────────────────────────────────────── +.pma-save-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 20px; + padding-top: 18px; + border-top: 1px solid $border; + flex-wrap: wrap; +} + +.pma-save-bar__hint { + font-size: 12px; + color: $ink-faint; +} + +.pma-save { + margin-left: auto; +} + +// ── Responsive ──────────────────────────────────────────────────────────── +@media (width <= 560px) { + .pma-key-row { + flex-wrap: wrap; + } + + .pma-grid { + grid-template-columns: 1fr; + } + + .pma-save-bar { + flex-direction: column; + align-items: stretch; + } + + .pma-save { + margin-left: 0; + justify-content: center; + } +} diff --git a/src/app/platform-admin/menu-access/menu-access.component.ts b/src/app/platform-admin/menu-access/menu-access.component.ts new file mode 100644 index 000000000..c5607a687 --- /dev/null +++ b/src/app/platform-admin/menu-access/menu-access.component.ts @@ -0,0 +1,220 @@ +/** + * 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 } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { environment } from '../../../environments/environment'; + +interface MenuRow { + code: string; + label: string; + icon: string; + enabled: boolean; +} + +interface TenantOption { + identifier: string; + name: string; +} + +/** + * Standalone platform-operator screen — deliberately outside the normal login/tenant-selector flow + * every other page in this app goes through. It talks to the backend with a hand-entered API key + * (`X-Api-Key`) instead of a Fineract session, exactly the credential `TenantMenuAccessApiResource` + * requires for its write side and accepts for its reads. Reachable at #/platform-admin/menu-access + * without logging in, on purpose: the real security boundary is the key, checked on the backend, not + * this page's URL — a bank's own super user has no way to obtain that key at all, and without it + * every call here 401s regardless of who is looking at the page. + * + * The key itself is minted once via `POST /v1/apiclients` (super-user session, one-time) and pasted + * in below; this page only remembers it in this browser's localStorage for convenience across visits, + * it is never sent anywhere but the two endpoints below. + */ +@Component({ + selector: 'mifosx-platform-menu-access', + standalone: true, + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ], + templateUrl: './menu-access.component.html', + styleUrl: './menu-access.component.scss' +}) +export class PlatformMenuAccessComponent { + private static readonly KEY_STORAGE = 'cbsx_platform_api_key'; + + /** Every menu code TenantMenuAccessApiResource knows about, with the label and icon shown on this + * bank's own top nav — same icon names as shell.component.html, so a toggle here is recognisable + * as the exact item it controls. Kept in the same order as the nav's two rows. */ + private static readonly ALL_MENUS: { code: string; label: string; icon: string }[] = [ + { code: 'MENU_INSTITUTION', label: 'Client Master', icon: 'users-cog' }, + { code: 'SIDEBAR_CFCFRMS', label: 'CFCFRMS', icon: 'user-shield' }, + { code: 'MENU_TASKS', label: 'Approvals', icon: 'check' }, + { code: 'MENU_CASHIER', label: 'Cashier', icon: 'money-bill' }, + { code: 'MENU_CLERK', label: 'Clerk', icon: 'id-card' }, + { code: 'MENU_BRANCH_OPS', label: 'Branch Ops', icon: 'building' }, + { code: 'MENU_CLEARING', label: 'Clearing', icon: 'exchange-alt' }, + { code: 'MENU_DEPOSITS', label: 'Deposits', icon: 'piggy-bank' }, + { code: 'MENU_ADVANCES', label: 'Advances', icon: 'hand-holding-usd' }, + { code: 'MENU_LOAN_ORIGINATION', label: 'Loan Origination', icon: 'clipboard-check' }, + { code: 'MENU_SHARE', label: 'Share', icon: 'coins' }, + { code: 'MENU_NPA', label: 'NPA', icon: 'exclamation-triangle' }, + { code: 'MENU_GENERAL_LEDGER', label: 'General Ledger', icon: 'book-open' }, + { code: 'MENU_SUB_LEDGER', label: 'Sub Ledger', icon: 'book' }, + { code: 'MENU_ACCOUNTING', label: 'Accounting', icon: 'money-bill-alt' }, + { code: 'MENU_CKYC_MGMT', label: 'CKYC', icon: 'fingerprint' }, + { code: 'MENU_INSURANCE', label: 'Insurance', icon: 'shield-alt' }, + { code: 'MENU_DIGITAL', label: 'Digital', icon: 'mobile-alt' }, + { code: 'MENU_HRM', label: 'HRM', icon: 'money-check' }, + { code: 'MENU_ADMIN', label: 'Admin', icon: 'shield-alt' }, + { code: 'MENU_RBI', label: 'RBI', icon: 'university' }, + { code: 'MENU_INVESTMENT', label: 'Investment', icon: 'chart-line' }, + { code: 'MENU_REPORTS', label: 'Reports', icon: 'chart-bar' }, + { code: 'MENU_OTHERS', label: 'Others', icon: 'folder-open' }, + { code: 'MENU_CONFIG_WIZARD', label: 'Reconciliation', icon: 'sync-alt' }, + { code: 'MENU_CHEQUEBOOK', label: 'Cheque Book', icon: 'book' }, + { code: 'MENU_LETTER_MGMT', label: 'Documents Hub', icon: 'envelope' } + ]; + + apiKey = ''; + tenants: TenantOption[] = []; + selectedTenant = ''; + rows: MenuRow[] = []; + + loadingTenants = false; + loadingMenus = false; + saving = false; + error: string | null = null; + savedMessage: string | null = null; + + constructor() { + this.apiKey = localStorage.getItem(PlatformMenuAccessComponent.KEY_STORAGE) ?? ''; + } + + private headers(tenantForFilter?: string): Record { + return { + 'X-Api-Key': this.apiKey, + // Any real tenant satisfies the platform's tenant-resolution filter; the target tenant for + // read/write is this header's own value, so it is set to whichever tenant is actually in play. + 'Fineract-Platform-TenantId': + tenantForFilter || this.selectedTenant || this.tenants[0]?.identifier || environment.fineractPlatformTenantId + }; + } + + private apiUrl(path: string): string { + return `${environment.serverUrl}${path}`; + } + + rememberKey(): void { + localStorage.setItem(PlatformMenuAccessComponent.KEY_STORAGE, this.apiKey); + } + + async loadTenants(): Promise { + this.error = null; + this.savedMessage = null; + if (!this.apiKey.trim()) { + this.error = 'Enter the platform API key first.'; + return; + } + this.rememberKey(); + this.loadingTenants = true; + try { + const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), { + headers: this.headers(environment.fineractPlatformTenantId) + }); + if (!res.ok) { + throw new Error( + res.status === 403 || res.status === 401 ? 'Invalid or inactive API key.' : `Server error (${res.status}).` + ); + } + this.tenants = await res.json(); + if (this.tenants.length > 0 && !this.selectedTenant) { + this.selectedTenant = this.tenants[0].identifier; + await this.loadMenusForSelectedTenant(); + } + } catch (e: any) { + this.error = e?.message ?? 'Could not load the tenant list.'; + this.tenants = []; + } finally { + this.loadingTenants = false; + } + } + + async loadMenusForSelectedTenant(): Promise { + this.error = null; + this.savedMessage = null; + if (!this.selectedTenant) { + return; + } + this.loadingMenus = true; + try { + const res = await fetch(this.apiUrl('/tenant-menu-access'), { headers: this.headers() }); + if (!res.ok) { + throw new Error(`Could not read this tenant's menu list (${res.status}).`); + } + const saved: { menuCode: string; enabled: boolean }[] = await res.json(); + const enabledByCode = new Map( + saved.map((r) => [ + r.menuCode, + r.enabled + ]) + ); + // A code the tenant has never had a row saved for yet reads as enabled — matches the backend's + // own default (0541 seeds every existing tenant with everything on), so a freshly onboarded + // bank that nobody has touched here shows its real, unrestricted state, not a false "all off". + this.rows = PlatformMenuAccessComponent.ALL_MENUS.map((m) => ({ + code: m.code, + label: m.label, + icon: m.icon, + enabled: enabledByCode.has(m.code) ? !!enabledByCode.get(m.code) : true + })); + } catch (e: any) { + this.error = e?.message ?? "Could not load this tenant's menu list."; + this.rows = []; + } finally { + this.loadingMenus = false; + } + } + + async save(): Promise { + this.error = null; + this.savedMessage = null; + this.saving = true; + try { + const body = this.rows.map((r) => ({ menuCode: r.code, enabled: r.enabled })); + const res = await fetch(this.apiUrl('/tenant-menu-access'), { + method: 'PUT', + headers: { ...this.headers(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!res.ok) { + throw new Error(`Save failed (${res.status}).`); + } + this.savedMessage = `Saved for "${this.selectedTenant}".`; + } catch (e: any) { + this.error = e?.message ?? 'Could not save.'; + } finally { + this.saving = false; + } + } + + setAll(enabled: boolean): void { + this.rows = this.rows.map((r) => ({ ...r, enabled })); + } + + get enabledCount(): number { + return this.rows.filter((r) => r.enabled).length; + } + + get selectedTenantName(): string { + return this.tenants.find((t) => t.identifier === this.selectedTenant)?.name ?? this.selectedTenant; + } +} diff --git a/src/app/products/charges/create-charge/create-charge.component.ts b/src/app/products/charges/create-charge/create-charge.component.ts index 6464513de..79ee8256f 100644 --- a/src/app/products/charges/create-charge/create-charge.component.ts +++ b/src/app/products/charges/create-charge/create-charge.component.ts @@ -20,6 +20,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router'; /** Custom Services */ import { ProductsService } from '../../products.service'; import { SettingsService } from 'app/settings/settings.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { Dates } from 'app/core/utils/dates'; import { minNumberValueValidator } from 'app/shared/validators/min-number-value.validator'; import { maxNumberValueValidator } from 'app/shared/validators/max-number-value.validator'; @@ -45,6 +46,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class CreateChargeComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private route = inject(ActivatedRoute); private router = inject(Router); private dateUtils = inject(Dates); @@ -352,8 +354,16 @@ export class CreateChargeComponent implements OnInit { if (!data.maxCap) { delete data.maxCap; } - this.productsService.createCharge(data).subscribe((response: any) => { - this.router.navigate(['../'], { relativeTo: this.route }); + this.productsService.createCharge(data).subscribe({ + next: (response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Charge submitted successfully for approval.' + }); + } + this.router.navigate(['../'], { relativeTo: this.route }); + } }); } } diff --git a/src/app/products/charges/edit-charge/edit-charge.component.ts b/src/app/products/charges/edit-charge/edit-charge.component.ts index cf839ffa8..4f0b5caa9 100644 --- a/src/app/products/charges/edit-charge/edit-charge.component.ts +++ b/src/app/products/charges/edit-charge/edit-charge.component.ts @@ -14,6 +14,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; /** Custom Services */ import { ProductsService } from 'app/products/products.service'; import { SettingsService } from 'app/settings/settings.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { maxNumberValueValidator } from 'app/shared/validators/max-number-value.validator'; import { minNumberValueValidator } from 'app/shared/validators/min-number-value.validator'; import { ValidateOnFocusDirective } from '../../../directives/validate-on-focus.directive'; @@ -37,6 +38,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; }) export class EditChargeComponent implements OnInit { private productsService = inject(ProductsService); + private alertService = inject(AlertService); private formBuilder = inject(UntypedFormBuilder); private route = inject(ActivatedRoute); private router = inject(Router); @@ -222,8 +224,16 @@ export class EditChargeComponent implements OnInit { if (!charges.maxCap) { delete charges.maxCap; } - this.productsService.updateCharge(this.chargeData.id.toString(), charges).subscribe((response: any) => { - this.router.navigate(['../'], { relativeTo: this.route }); + this.productsService.updateCharge(this.chargeData.id.toString(), charges).subscribe({ + next: (response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Charge update submitted successfully for approval.' + }); + } + this.router.navigate(['../'], { relativeTo: this.route }); + } }); } } diff --git a/src/app/products/current-products/create-current-product/create-current-product.component.ts b/src/app/products/current-products/create-current-product/create-current-product.component.ts index f0b8477d5..721802f37 100644 --- a/src/app/products/current-products/create-current-product/create-current-product.component.ts +++ b/src/app/products/current-products/create-current-product/create-current-product.component.ts @@ -18,6 +18,7 @@ import { CurrentProductAccountingStepComponent } from '../current-product-steppe import { CurrentProductPreviewStepComponent } from '../current-product-stepper/current-product-preview-step/current-product-preview-step.component'; import { ProductsService } from 'app/products/products.service'; import { SettingsService } from 'app/settings/settings.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { Accounting } from 'app/core/utils/accounting'; import { MatTabsModule } from '@angular/material/tabs'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -42,6 +43,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class CreateCurrentProductComponent { private route = inject(ActivatedRoute); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private router = inject(Router); private settingsService = inject(SettingsService); private accounting = inject(Accounting); @@ -144,14 +146,24 @@ export class CreateCurrentProductComponent { submit() { const currentProduct = this.prepareCurrentProductPayload(); console.log('Current product submit payload:', currentProduct); - this.productsService.createCurrentProduct(currentProduct).subscribe((response: any) => { - this.router.navigate( - [ - '../', - response.resourceId - ], - { relativeTo: this.route } - ); + this.productsService.createCurrentProduct(currentProduct).subscribe({ + next: (response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Current product creation request submitted successfully for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + } else { + this.router.navigate( + [ + '../', + response.resourceId + ], + { relativeTo: this.route } + ); + } + } }); } } diff --git a/src/app/products/current-products/current-product-stepper/current-product-charges-step/current-product-charges-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-charges-step/current-product-charges-step.component.ts index d6cafa12d..8daa84920 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-charges-step/current-product-charges-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-charges-step/current-product-charges-step.component.ts @@ -26,6 +26,7 @@ import { MatRowDef, MatRow } from '@angular/material/table'; +import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service'; import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe'; import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -55,6 +56,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class CurrentProductChargesStepComponent implements OnInit { dialog = inject(MatDialog); private translateService = inject(TranslateService); + private facilityChargeFilter = inject(FacilityChargeFilterService); @Input() CurrentProductsTemplate: any; @Input() currencyCode: UntypedFormControl; @@ -73,7 +75,9 @@ export class CurrentProductChargesStepComponent implements OnInit { pristine = true; ngOnInit() { - this.chargeData = this.CurrentProductsTemplate.chargeOptions; + this.facilityChargeFilter + .excludeFacilityCharges(this.CurrentProductsTemplate.chargeOptions) + .subscribe((charges: any[]) => (this.chargeData = charges)); this.chargesDataSource = this.CurrentProductsTemplate.charges || []; this.pristine = true; diff --git a/src/app/products/current-products/current-product-stepper/current-product-limits-step/current-product-limits-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-limits-step/current-product-limits-step.component.html index 0a78185a6..0fba6e324 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-limits-step/current-product-limits-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-limits-step/current-product-limits-step.component.html @@ -174,21 +174,6 @@ - -
+ } + + + +
+ diff --git a/src/app/products/share-products/all-products-dividends/all-products-dividends.component.scss b/src/app/products/share-products/all-products-dividends/all-products-dividends.component.scss new file mode 100644 index 000000000..72d57d3d8 --- /dev/null +++ b/src/app/products/share-products/all-products-dividends/all-products-dividends.component.scss @@ -0,0 +1,121 @@ +/** + * 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/product-list' as pl; + +@include pl.product-list-styles; + +/* Same pink accent as Share Products — this page is part of that area, not a separate one. */ +:host { + --pl-accent: #be185d; + --pl-accent-l: #fce7f3; +} + +/* Financial-year filter chips. A dividend decision belongs to a year, so the year is the primary + way an officer narrows this register. */ +.apd-years { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.apd-years-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #64748b; + margin-right: 4px; +} + +.apd-year-chip { + border: 1px solid #e2e8f0; + background: #fff; + color: #334155; + border-radius: 999px; + padding: 5px 14px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + border-color: var(--pl-accent); + color: var(--pl-accent); + } + + &--on { + background: var(--pl-accent); + border-color: var(--pl-accent); + color: #fff; + } +} + +/* Amber rather than the default pill colour: a declaration awaiting approval has not reached any + member yet, and should not read the same as one that has. */ +.apd-status--pending { + background: #fef3c7; + color: #b45309; +} + +.apd-pending { + color: #b45309; + font-weight: 500; +} + +/* Right-aligned so it reads as the row's action rather than another data column. */ +.apd-action { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid #e2e8f0; + background: #fff; + color: var(--pl-accent); + border-radius: 6px; + padding: 5px 12px; + font-size: 13px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + border-color: var(--pl-accent); + background: var(--pl-accent-l); + } + + fa-icon { + font-size: 11px; + } +} + +.apd-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 48px 20px; + color: #64748b; + text-align: center; + + fa-icon { + font-size: 32px; + color: #cbd5e1; + } + + p { + margin: 0; + font-size: 14px; + } + + &--error fa-icon { + color: #f59e0b; + } +} diff --git a/src/app/products/share-products/all-products-dividends/all-products-dividends.component.ts b/src/app/products/share-products/all-products-dividends/all-products-dividends.component.ts new file mode 100644 index 000000000..fb612562c --- /dev/null +++ b/src/app/products/share-products/all-products-dividends/all-products-dividends.component.ts @@ -0,0 +1,218 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; +import { MatSort, MatSortHeader, MatSortModule } from '@angular/material/sort'; +import { + MatTableDataSource, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** Custom Imports */ +import { ProductsService } from '../../products.service'; +import { DateFormatPipe } from '../../../pipes/date-format.pipe'; +import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive'; + +/** + * The society's dividend register, across every share product. + * + * A co-operative's board resolves one dividend rate for a financial year — not one rate per share + * product — so this is the level the decision is actually taken and recorded at. Each product still + * carries its own declaration row underneath, because share capital sits in a separate GL per + * product and the per-member allocation runs per product; what is shared is the rate and the period. + * + * Declarations for the same financial year are grouped, so a year that touched two products reads + * as one decision with a split rather than as two unrelated entries. + */ +@Component({ + selector: 'mifosx-all-products-dividends', + templateUrl: './all-products-dividends.component.html', + styleUrls: ['./all-products-dividends.component.scss'], + imports: [ + CommonModule, + FormsModule, + RouterLink, + FaIconComponent, + // Individual table directives rather than MatTableModule, matching the Share Products list this + // page sits beside — the module form leaves the header/row defs unresolved here and the table + // renders nothing at all, not even headers. + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatSort, + MatSortHeader, + MatSortModule, + MatPaginatorModule, + MatTooltipModule, + MatProgressBarModule, + DateFormatPipe, + HasPermissionDirective + ] +}) +export class AllProductsDividendsComponent implements OnInit { + private productsService = inject(ProductsService); + private router = inject(Router); + + /** Every declaration across all products, newest period first. */ + dividends: any[] = []; + dataSource = new MatTableDataSource([]); + displayedColumns: string[] = [ + 'financialYear', + 'productName', + 'period', + 'amount', + 'memberCount', + 'status', + 'actions' + ]; + + loading = true; + loadFailed = false; + + /** Distinct financial years present, for the filter chips. */ + financialYears: string[] = []; + selectedYear = ''; + + // The paginator sits outside the table's @if so it resolves statically; the sort lives on the + // table inside it, so it only exists once there are rows to show — applyYearFilter re-attaches + // both every time the rows change, which is when the sort first becomes available. + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + ngOnInit(): void { + this.loadDividends(); + } + + loadDividends(): void { + this.loading = true; + this.loadFailed = false; + this.productsService.getAllProductsDividends().subscribe({ + next: (response: any[]) => { + this.dividends = response || []; + this.financialYears = Array.from(new Set(this.dividends.map((d) => d.financialYear))) + .sort() + .reverse(); + this.applyYearFilter(); + this.loading = false; + }, + error: () => { + // Left empty with a retry rather than showing zeroes, so an unreachable server is never + // mistaken for a year in which no dividend was declared. + this.dividends = []; + this.loadFailed = true; + this.loading = false; + } + }); + } + + /** Total declared across the rows currently in view. */ + get totalDeclared(): number { + return this.visibleRows.reduce((sum, d) => sum + Number(d.amount || 0), 0); + } + + /** Declarations still awaiting approval — these have not reached members yet. */ + get pendingCount(): number { + return this.visibleRows.filter((d) => !d.approved).length; + } + + /** Members receiving a dividend across the rows in view. */ + get memberCount(): number { + return this.visibleRows.reduce((sum, d) => sum + Number(d.memberCount || 0), 0); + } + + /** Products covered by the rows in view. */ + get productCount(): number { + return new Set(this.visibleRows.map((d) => d.productId)).size; + } + + private get visibleRows(): any[] { + return this.selectedYear ? this.dividends.filter((d) => d.financialYear === this.selectedYear) : this.dividends; + } + + selectYear(year: string): void { + this.selectedYear = this.selectedYear === year ? '' : year; + this.applyYearFilter(); + } + + /** + * Swaps the rows in place rather than rebuilding the data source, so the paginator and sort stay + * attached — a fresh MatTableDataSource loses both unless they are re-wired every time. + */ + private applyYearFilter(): void { + this.dataSource.data = this.visibleRows; + this.dataSource.paginator = this.paginator; + // Deferred a tick: the sort directive is inside the table's @if, so it does not exist until + // this change detection pass has rendered the rows we just set. + setTimeout(() => { + this.dataSource.sort = this.sort; + }); + } + + applyFilter(filterValue: string): void { + this.dataSource.filter = (filterValue || '').trim().toLowerCase(); + } + + /** + * Opens a declaration where the officer can actually act on it. + * + * One still awaiting approval goes to the Approval Centre, because approving is what the officer + * came to do and that is where the approve action lives alongside every other pending + * declaration — landing them on a read-only page they then have to navigate away from is a + * detour. The declaration is named in the query string so the Centre can open its member-by-member + * breakdown straight away rather than making them find the row again. + * + * One already approved has nothing to act on, so it goes to the per-product view to be read. + */ + viewDividend(row: any): void { + if (!row.approved) { + this.router.navigate( + [ + '/checker-inbox-and-tasks', + 'dividend-requests' + ], + { queryParams: { productId: row.productId, dividendId: row.dividendId } } + ); + return; + } + + this.router.navigate( + [ + '/products', + 'share-products', + row.productId, + 'dividends', + row.dividendId + ], + { queryParams: { status: 'Approved' } } + ); + } +} diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html new file mode 100644 index 000000000..945e5f5a8 --- /dev/null +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html @@ -0,0 +1,251 @@ + + +
+
+
+
+
+

Declare Dividend

+

One rate for the year, applied to every member’s share investment.

+
+
+
+ + @if (result) { + +
+ +
+ +
+

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

+

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

+
+
+ + + + @for (row of result.created; track row.productId) { + + + + + + } + @for (row of result.skipped; track row.productId) { + + + + + + } + +
{{ row.productName }}{{ row.amount | number: '1.2-2' }}Declared
{{ row.productName }}—{{ row.reason }}
+ +
+ +
+
+ } @else { +
+
+
+ + Financial Year + + @for (year of financialYears; track year.label) { + {{ year.label }} + } + + +
+ +
+ + Dividend Rate (% per annum) + +  %  + +

Applied to each member's own share investment, pro-rated for how long they held it.

+
+ + @if (loadingPreview) { + + } + + @if (previewError) { +
{{ previewError }}
+ } + + @if (preview && !loadingPreview) { + +
+
How the dividend is worked out
+ +
+ Share investment (weighted for holding period) + {{ preview.totalWeightedCapital | number: '1.2-2' }} +
+ +
+ Dividend rate + {{ declareForm.value.ratePercentage || 0 }}% +
+ +
+ Dividend payable + {{ preview.totalAmount | number: '1.2-2' }} +
+
+ + +
+
Funded from
+ +
+ + Bank profit for {{ preview.financialYear }} + {{ preview.profitGlCode }} + + {{ preview.fundingAvailable | number: '1.2-2' }} +
+ +
+ Less dividend payable + − {{ preview.totalAmount | number: '1.2-2' }} +
+ + @if (exceedsMaximumRate) { + +
+ {{ declareForm.value.ratePercentage }}% is above the society's maximum of + {{ preview.maximumRatePercentage }}%. Lower the rate, or raise the limit in Configurations if the board + has resolved a higher one. +
+ } @else if (isPeriodNotFinished) { + +
+ {{ preview.financialYear }} has not finished yet, so its profit is not final. A dividend can only be + declared once the financial year is over. +
+ } @else if (isUnderfunded) { +
+ Short by {{ preview.fundingShortfall | number: '1.2-2' }}. There is not enough in + {{ preview.profitGlCode }} to fund this dividend — lower the rate, or close the year's profit first. +
+ } @else { +
+ Left in profit (swept to Bank Profit on approval) + {{ preview.undistributedRemainder | number: '1.2-2' }} +
+ } +
+ +
+
+ {{ declareForm.value.ratePercentage || 0 }}% on each member’s share investment +
+ + + @for (product of eligibleProducts; track product.productId) { +
+ + {{ product.productName }} + + {{ product.memberCount }} {{ product.memberCount === 1 ? 'member' : 'members' }} + @if (product.accountCount !== product.memberCount) { + · {{ product.accountCount }} accounts + } + + + {{ product.amount | number: '1.2-2' }} +
+ } + + @if (eligibleProducts.length > 0) { +
+ + Total to declare + @if (preview.totalMemberCount) { + + + {{ preview.totalMemberCount }} + {{ preview.totalMemberCount === 1 ? 'member' : 'members' }} + @if (preview.totalAccountCount !== preview.totalMemberCount) { + · {{ preview.totalAccountCount }} accounts + } + + } + + {{ payableTotal | number: '1.2-2' }} +
+ } + + @for (product of skippedProducts; track product.productId) { +
+ {{ product.productName }} + + {{ product.alreadyDeclared ? 'Already declared for this year' : product.reason }} + +
+ } + + @if (eligibleProducts.length === 0) { +
+ No share product is eligible for this year, so there is nothing to declare. +
+ } +
+ } + + @if (submitError) { +
{{ submitError }}
+ } + +
+ + +
+
+
+ } +
diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss new file mode 100644 index 000000000..c95fb17a2 --- /dev/null +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss @@ -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/. + */ + +@use 'assets/styles/product-list' as pl; + +@include pl.product-list-styles; + +:host { + --pl-accent: #be185d; + --pl-accent-l: #fce7f3; +} + +/* A single-column form reads better centred than pinned to the left edge of a 1400px page — the + eye has nowhere else to go, and a wide empty right margin makes the form look unfinished. */ +.dd-panel { + max-width: 720px; + margin: 0 auto; + padding: 22px 24px; +} + +/* The header belongs over the form, not over the full page width, or it detaches from it. */ +.pl-header { + max-width: 720px; + margin-left: auto; + margin-right: auto; +} + +/* The panel is wrapped in a
, so centring it needs the form to be the full-width block that + does the centring — margin:auto on the panel alone has no effect inside a default-width form. */ +form { + display: block; + width: 100%; +} + +.dd-fields { + display: flex; + flex-wrap: wrap; + gap: 16px; + + mat-form-field { + flex: 1 1 240px; + } +} + +/* Sits directly under the rate field, where it explains what the number just typed applies to. */ +.dd-hint { + flex: 1 1 100%; + margin: 0 0 16px; + font-size: 13px; + color: #64748b; +} + +/* The cost of the rate, per product. The officer is committing to a rate, but what leaves the + society is an amount — so the amount is shown before the commit, not after. */ +.dd-preview { + margin-top: 8px; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 14px 16px; +} + +.dd-preview-head { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #64748b; + margin-bottom: 10px; +} + +.dd-preview-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 5px 0; + font-size: 14px; + + /* Red, and signed, so an appropriation reads as money leaving the pool rather than another + figure to add up. */ + &--deduction { + color: #b42318; + } + + /* The line the deductions arrive at — separated from them, but lighter than the final total, + since it is a step in the arithmetic rather than the answer. */ + &--subtotal { + margin-top: 6px; + padding-top: 8px; + border-top: 1px solid #cbd5e1; + font-weight: 600; + color: #0f172a; + } + + /* Context rather than part of the sum. */ + &--muted { + color: #64748b; + font-size: 13px; + } + + &--total { + margin-top: 6px; + padding-top: 10px; + border-top: 2px solid #cbd5e1; + font-weight: 700; + color: var(--pl-accent); + } + + /* Muted, not hidden: a product that is being skipped still needs to be visibly accounted for. */ + &--skipped { + color: #94a3b8; + font-size: 13px; + } +} + +.dd-amount { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Names the account the profit is actually read from, so the figure can be traced to the ledger + rather than taken on trust. */ +.dd-gl { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + background: #f1f5f9; + color: #64748b; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; +} + +/* Two stacked panels: where the money comes from, then how it is split. */ +.dd-preview + .dd-preview { + margin-top: 12px; +} + +.dd-reason { + font-size: 12.5px; + color: #94a3b8; + text-align: right; +} + +// Who a product's amount reaches, set quietly beside the product name so it reads as a +// qualifier on the amount rather than competing with it. +.dd-members { + margin-left: 8px; + font-size: 12px; + color: #94a3b8; +} + +.dd-note { + margin-top: 12px; + padding: 10px 14px; + border-radius: 6px; + font-size: 13px; + background: #f1f5f9; + color: #334155; + + &--error { + background: #fef2f2; + color: #b42318; + } +} + +.dd-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; +} + +/* Outcome view */ +.dd-result-head { + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 18px; + + h3 { + margin: 0 0 4px; + font-size: 17px; + font-weight: 700; + color: #0f172a; + } + + p { + margin: 0; + font-size: 13.5px; + color: #64748b; + } +} + +.dd-result-ic { + font-size: 26px; + color: #16a34a; + margin-top: 2px; + + // A run that left a product out is amber, not green: it succeeded, but not for everyone. + &--warn { + color: #d97706; + } +} + +.dd-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + + td { + padding: 9px 8px; + border-bottom: 1px solid #f1f5f9; + } + + td:nth-child(2) { + text-align: right; + width: 140px; + } + + td:nth-child(3) { + text-align: right; + width: 220px; + } +} + +.dd-ok { + color: #16a34a; + font-weight: 600; + font-size: 13px; +} + +.dd-skipped { + color: #94a3b8; +} diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts new file mode 100644 index 000000000..5ab5cbd9c --- /dev/null +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts @@ -0,0 +1,258 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatOptionModule } from '@angular/material/core'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** Custom Imports */ +import { ProductsService } from '../../../products.service'; +import { SettingsService } from '../../../../settings/settings.service'; + +/** + * Declares one dividend across every share product for a financial year. + * + * The board resolves a rate, not an amount — "9% for 2025-26" — and what that costs depends on how + * much capital each product's members held and for how long. So the officer enters the rate and the + * year, and the preview shows what it works out to per product before anything is committed. That + * preview is the point of the screen: a rate looks harmless until you see the amount it implies. + * + * The rate applies to members' share investment and to nothing else. Profit does not size the + * dividend — it only funds it, so the screen leads with capital x rate and reports the profit + * account only as the source the payout is drawn from. + */ +@Component({ + selector: 'mifosx-declare-dividend', + templateUrl: './declare-dividend.component.html', + styleUrls: ['./declare-dividend.component.scss'], + imports: [ + CommonModule, + ReactiveFormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatOptionModule, + MatProgressBarModule, + FaIconComponent + ] +}) +export class DeclareDividendComponent implements OnInit { + private formBuilder = inject(FormBuilder); + private productsService = inject(ProductsService); + private settingsService = inject(SettingsService); + private router = inject(Router); + + declareForm: FormGroup; + + /** The financial years a dividend may be declared for. */ + financialYears: { label: string; start: string; end: string }[] = []; + + /** What the entered rate works out to, per product. Null until a rate and year are both chosen. */ + preview: any = null; + loadingPreview = false; + previewError: string | null = null; + + submitting = false; + submitError: string | null = null; + + /** Set once the declaration has gone through, so the outcome per product can be shown. */ + result: any = null; + + /** + * Whether any product was left out of the declaration that just ran. + * + * Drives the outcome heading. A run that skipped a product still returns success — one product + * being ineligible is not a reason to lose the others — but those members receive nothing for the + * year, so the screen has to say so rather than report a plain "declared". + */ + get hasSkipped(): boolean { + return (this.result?.skipped?.length ?? 0) > 0; + } + + ngOnInit(): void { + this.declareForm = this.formBuilder.group({ + financialYear: [ + '', + Validators.required + ], + ratePercentage: [ + '', + [ + Validators.required, + Validators.min(0.01), + Validators.max(100) + ] + ] + }); + + this.buildFinancialYears(); + this.declareForm.valueChanges.subscribe(() => this.loadPreview()); + } + + /** + * The financial years a dividend can be declared for: the current one and the five before it. + * + * The year in progress is listed but cannot be declared — the server rejects it, and the screen + * says so as soon as it is picked. It stays on the list so an officer looking for it finds it with + * the reason attached, rather than wondering why the year they are in is missing. Five back covers + * a declaration delayed by an AGM that ran late. + * + * Built from the business date rather than the browser's clock, so the list matches the year the + * branch is operating in. + */ + private buildFinancialYears(): void { + const businessDate: Date = this.settingsService.businessDate ?? new Date(); + // April starts the Indian financial year, so anything before it still belongs to the prior year. + const currentStartYear = businessDate.getMonth() >= 3 ? businessDate.getFullYear() : businessDate.getFullYear() - 1; + + this.financialYears = []; + for (let startYear = currentStartYear; startYear > currentStartYear - 6; startYear--) { + this.financialYears.push({ + label: `${startYear}-${String((startYear + 1) % 100).padStart(2, '0')}`, + start: `${startYear}-04-01`, + end: `${startYear + 1}-03-31` + }); + } + } + + private selectedYear(): { label: string; start: string; end: string } | undefined { + return this.financialYears.find((y) => y.label === this.declareForm.value.financialYear); + } + + /** What this rate costs, per product, before anything is created. */ + loadPreview(): void { + const year = this.selectedYear(); + const rate = Number(this.declareForm.value.ratePercentage); + if (!year || !rate || rate <= 0) { + this.preview = null; + return; + } + + this.loadingPreview = true; + this.previewError = null; + this.productsService.previewAllProductsDividend(year.start, year.end, rate).subscribe({ + next: (response: any) => { + this.preview = response; + this.loadingPreview = false; + }, + error: (error: any) => { + // Cleared rather than left stale, so a failed refresh can never be read as the cost of the + // rate now on screen. + this.preview = null; + this.previewError = error?.error?.defaultUserMessage ?? 'Could not work out what this rate would cost.'; + this.loadingPreview = false; + } + }); + } + + /** Products that will actually receive a declaration. */ + get eligibleProducts(): any[] { + return (this.preview?.products ?? []).filter((p: any) => p.eligible && !p.alreadyDeclared); + } + + /** Products that will be skipped, with the reason shown alongside. */ + get skippedProducts(): any[] { + return (this.preview?.products ?? []).filter((p: any) => !p.eligible || p.alreadyDeclared); + } + + /** Total across only the products that will actually be declared. */ + get payableTotal(): number { + return this.eligibleProducts.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0); + } + + /** + * Whether the profit account can cover what this rate works out to. + * + * The rate decides the amount; profit only has to be able to pay it. Surfaced here so the officer + * sees the shortfall on screen rather than having the command layer reject the declaration after + * they have already committed to it — the same ceiling is enforced there either way. + */ + get isUnderfunded(): boolean { + return !!this.preview?.exceedsDistributableProfit; + } + + /** + * Whether the chosen year is still running. + * + * A dividend is a share of the year's profit, so it cannot be declared until the year is over and + * that profit is final. The server enforces this; the flag simply lets the screen say so before the + * officer commits to a rate. + */ + get isPeriodNotFinished(): boolean { + return !!this.preview?.periodNotFinished; + } + + /** + * Whether the entered rate is above the society's configured maximum. + * + * The bye-laws cap what may be paid in a year, so a rate above it needs a board resolution rather + * than a typed figure. The server enforces the same limit. + */ + get exceedsMaximumRate(): boolean { + return !!this.preview?.exceedsMaximumRate; + } + + get canSubmit(): boolean { + return ( + this.declareForm.valid && + !this.submitting && + this.eligibleProducts.length > 0 && + !this.isUnderfunded && + !this.isPeriodNotFinished && + !this.exceedsMaximumRate + ); + } + + submit(): void { + const year = this.selectedYear(); + if (!year || !this.canSubmit) { + return; + } + + this.submitting = true; + this.submitError = null; + this.productsService + .createAllProductsDividend({ + dividendPeriodStartDate: year.start, + dividendPeriodEndDate: year.end, + ratePercentage: String(this.declareForm.value.ratePercentage) + }) + .subscribe({ + next: (response: any) => { + this.result = response; + this.submitting = false; + }, + error: (error: any) => { + this.submitError = error?.error?.defaultUserMessage ?? 'The dividend could not be declared.'; + this.submitting = false; + } + }); + } + + done(): void { + this.router.navigate([ + '/products', + 'share-products', + 'dividends' + ]); + } + + cancel(): void { + this.done(); + } +} diff --git a/src/app/products/share-products/create-dividend/create-dividend.component.html b/src/app/products/share-products/create-dividend/create-dividend.component.html index 44d276f07..a8f6b06d3 100644 --- a/src/app/products/share-products/create-dividend/create-dividend.component.html +++ b/src/app/products/share-products/create-dividend/create-dividend.component.html @@ -11,44 +11,23 @@
- - {{ 'labels.inputs.Dividend Period Start Date' | translate }} - + + {{ 'labels.inputs.Financial Year' | translate }} + - - - @if (createDividendForm.controls.dividendPeriodStartDate.hasError('required')) { - - {{ 'labels.inputs.Starting Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } - - - - {{ 'labels.inputs.Dividend Period End Date' | translate }} - - - - @if (createDividendForm.controls.dividendPeriodEndDate.hasError('required')) { - - {{ 'labels.inputs.End Date' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - } + > + @for (fy of financialYears; track fy.label) { + FY {{ fy.label }} ({{ fy.startLabel }} – {{ fy.endLabel }}) + } + @if (overlapError) { @@ -63,15 +42,70 @@

{{ 'labels.inputs.Could not fetch bank profit for this period' | translate }}.

- } @else if (netProfit !== null) { -

- {{ 'labels.inputs.Bank Profit for this Period' | translate }}: - {{ netProfit | number: '1.2-2' }} -

+ } @else if (profitBreakdown !== null) { + +
+
+ @if (profitBreakdown.yearClosed) { + {{ 'labels.inputs.Net Profit' | translate }} — FY {{ profitBreakdown.financialYear }} ({{ + 'labels.inputs.closed books' | translate + }}) + } @else { + {{ 'labels.inputs.Net Profit for this Period' | translate }} + } + {{ profitBreakdown.netProfit | number: '1.2-2' }} +
+ +
+ @if (profitBreakdown.yearClosed) { + {{ 'labels.inputs.From the Bank Profit GL after year-end closing' | translate }} + } @else if (profitBreakdown.coversWholeFinancialYear) { + {{ 'labels.inputs.FY' | translate }} {{ profitBreakdown.financialYear }} + {{ + 'labels.inputs.has not been closed yet — this is the open profit and loss, not an audited figure' + | translate + }} + } @else { + {{ + 'labels.inputs.Interim period — from the open profit and loss, not an audited figure' | translate + }} + } + +
+ @if (profitBreakdown.alreadyDeclared > 0) { +
+ − {{ 'labels.inputs.Already declared for other products' | translate }} + {{ profitBreakdown.alreadyDeclared | number: '1.2-2' }} +
+ @for (declaration of profitBreakdown.existingDeclarations; track declaration.declarationId) { +
+ {{ declaration.productName }} ({{ declaration.periodStartDate }} – + {{ declaration.periodEndDate }}) + {{ declaration.amount | number: '1.2-2' }} +
+ } + } +
+ {{ 'labels.inputs.Available for this declaration' | translate }} + {{ profitBreakdown.availableForDeclaration | number: '1.2-2' }} +
+
} - {{ 'labels.inputs.Dividend Percentage' | translate }} (%) + + {{ 'labels.inputs.Dividend Rate' | translate }} (% {{ 'labels.inputs.per annum' | translate }}) + @if (createDividendForm.controls.dividendPercentage.hasError('required')) { @@ -81,6 +115,46 @@ } +

+ {{ + 'labels.inputs.Each member receives this rate on their own paid-up capital, pro-rated for how long they held it' + | translate + }}. +

+ + @if (rateCeilingError) { +

{{ rateCeilingError }}

+ } + + @if (loadingCapital) { +

{{ 'labels.inputs.Calculating eligible share capital' | translate }}...

+ } @else if (capitalLookupError) { +

+ {{ 'labels.inputs.Could not calculate eligible share capital for this period' | translate }}. +

+ } @else if (capitalPreview !== null) { +
+
+ {{ 'labels.inputs.Eligible share capital (weighted)' | translate }} + {{ capitalPreview.weightedCapital | number: '1.2-2' }} +
+
+ {{ 'labels.inputs.Eligible accounts' | translate }} + {{ capitalPreview.eligibleAccounts }} +
+ @if (capitalPreview.eligibleAccounts === 0 && capitalPreview.minimumActivePeriodDays > 0) { +
+ {{ 'labels.inputs.No member has held shares for the minimum active period of' | translate }} + {{ capitalPreview.minimumActivePeriodDays }} + {{ 'labels.inputs.days' | translate }} + +
+ } +
+ } + {{ 'labels.inputs.Dividend Amount' | translate }} ({{ @@ -95,6 +169,10 @@ } + + @if (ceilingError) { +

{{ ceilingError }}

+ }
@@ -102,7 +180,7 @@ - diff --git a/src/app/products/share-products/create-dividend/create-dividend.component.scss b/src/app/products/share-products/create-dividend/create-dividend.component.scss index 3a6edab91..c0e273142 100644 --- a/src/app/products/share-products/create-dividend/create-dividend.component.scss +++ b/src/app/products/share-products/create-dividend/create-dividend.component.scss @@ -10,6 +10,60 @@ max-width: 37rem; } +/* The appropriation waterfall: net profit reduced step by step to the ceiling this declaration + must stay within. Laid out as label/amount pairs so the arithmetic reads down the column. */ +.dividend-waterfall { + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid #d8dee4; + border-radius: 6px; + font-size: 13px; + + &__row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 3px 0; + + > span:last-child { + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + &--deduction { + color: #b42318; + } + + &--detail { + padding-left: 14px; + color: #667085; + font-size: 12px; + } + + &--subtotal { + margin-top: 4px; + padding-top: 6px; + border-top: 1px solid #d8dee4; + font-weight: 500; + } + + &--total { + margin-top: 4px; + padding-top: 6px; + border-top: 2px solid #98a2b3; + font-weight: 600; + color: #1a7f37; + } + } +} + +/* Sits under the rate field, saying what the number just typed applies to. */ +.dividend-mode__hint { + margin: 0 0 12px; + font-size: 12px; + color: #667085; +} + .dividend-profit-note { margin: 0 0 12px; padding: 8px 12px; diff --git a/src/app/products/share-products/create-dividend/create-dividend.component.ts b/src/app/products/share-products/create-dividend/create-dividend.component.ts index 59cb7dbaf..38b4bbdd5 100644 --- a/src/app/products/share-products/create-dividend/create-dividend.component.ts +++ b/src/app/products/share-products/create-dividend/create-dividend.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { DecimalPipe } from '@angular/common'; import { Dates } from 'app/core/utils/dates'; /** Custom Services. */ @@ -26,7 +27,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; templateUrl: './create-dividend.component.html', styleUrls: ['./create-dividend.component.scss'], imports: [ - ...STANDALONE_SHARED_IMPORTS + ...STANDALONE_SHARED_IMPORTS, + DecimalPipe ] }) export class CreateDividendComponent implements OnInit { @@ -47,13 +49,75 @@ export class CreateDividendComponent implements OnInit { /** Maximum Date allowed. */ maxDate = new Date(); - /** Real bank profit for the selected period (fetched from posted GL journal entries). */ - netProfit: number | null = null; + /** + * The profit available to fund a dividend for the selected period, from the backend: the profit + * standing in the GL, less dividends already declared for other share products over an + * overlapping period. The statutory reserve and education fund are not part of this — they are + * appropriated on the profit-distribution screen before a dividend is ever declared. + */ + profitBreakdown: { + financialYear: string; + coversWholeFinancialYear: boolean; + /** True once the year's P&L has been closed into the Bank Profit GL — the audited figure. */ + yearClosed: boolean; + profitSource: 'BANK_PROFIT_GL' | 'OPEN_PROFIT_AND_LOSS'; + netProfit: number; + distributableProfit: number; + alreadyDeclared: number; + availableForDeclaration: number; + maximumRatePercentage: number; + existingDeclarations: { + productName: string; + amount: number; + periodStartDate: string; + periodEndDate: string; + }[]; + } | null = null; /** True while the profit figure for the selected period is being fetched. */ loadingProfit = false; /** Set if the profit lookup fails, so the officer knows the amount below isn't auto-computed. */ profitLookupError = false; + /** + * Weighted paid-up capital for the period — the only thing the dividend is calculated from. + * `requiredAmount` is what the entered rate actually costs to honour. + */ + capitalPreview: { + weightedCapital: number; + requiredAmount: number | null; + eligibleAccounts: number; + minimumActivePeriodDays: number; + } | null = null; + loadingCapital = false; + capitalLookupError = false; + + /** + * A dividend is a rate on each member's own share investment, and nothing else. + * + * This is what a rate means in an AGM resolution: each member receives the declared rate on their + * own paid-up capital, pro-rated for how long they held it. Sizing the pool as a share of profit + * instead was dropped — the resulting per-member rate depends on how many other members hold + * shares, so it is not the rate members are promised. Profit no longer sizes the dividend; it + * only funds it, which is what {@link ceilingError} checks. + */ + readonly dividendMode = 'RATE_ON_CAPITAL'; + + /** + * Financial years a dividend can be declared for, newest first. + * + * A dividend belongs to a financial year, and everything downstream depends on that: the profit it is measured + * against is the year's closed profit, and both the rounding residue and the undistributed remainder are filed + * under the year the period ends in. Choosing the year rather than two loose dates makes a part-year period — + * which none of that arithmetic would hold for — impossible to enter. + */ + financialYears: { label: string; startLabel: string; endLabel: string; start: Date; end: Date }[] = []; + selectedFinancialYear: string | null = null; + + /** Set when the computed amount exceeds what may still be distributed for this period. */ + ceilingError: string | null = null; + /** Set when the entered rate exceeds the ceiling in the governing Co-operative Societies Act. */ + rateCeilingError: string | null = null; + /** Every dividend already declared for this product, used to block overlapping periods. */ private existingDividendPeriods: { start: Date; end: Date }[] = []; /** Set when the currently selected period overlaps an existing declaration for this product. */ @@ -77,17 +141,57 @@ export class CreateDividendComponent implements OnInit { ngOnInit() { this.maxDate = this.settingsService.businessDate; this.setDividendForm(); + this.buildFinancialYears(); this.loadExistingDividendPeriods(); - const onPeriodChange = () => { + // Both dates now move together as one financial year is chosen, so watching the end date alone + // is enough — subscribing to both would fire the profit and capital lookups twice per change. + this.createDividendForm.get('dividendPeriodEndDate').valueChanges.subscribe(() => { this.fetchProfitForSelectedPeriod(); this.checkOverlap(); - }; - this.createDividendForm.get('dividendPeriodStartDate').valueChanges.subscribe(onPeriodChange); - this.createDividendForm.get('dividendPeriodEndDate').valueChanges.subscribe(onPeriodChange); + }); this.createDividendForm.get('dividendPercentage').valueChanges.subscribe(() => this.recomputeDividendAmount()); } + /** + * The financial years a dividend can be declared for: the current one and the five before it. + * + * The current year is included because a society may declare an interim dividend part-way through, and five back + * covers a declaration delayed by an AGM that ran late. Built from the business date rather than the browser's + * clock, so the list matches the year the branch is actually operating in. + */ + private buildFinancialYears(): void { + const businessDate: Date = this.settingsService.businessDate ?? new Date(); + // April starts the Indian financial year, so anything before it still belongs to the prior year. + const currentStartYear = businessDate.getMonth() >= 3 ? businessDate.getFullYear() : businessDate.getFullYear() - 1; + + this.financialYears = []; + for (let startYear = currentStartYear; startYear > currentStartYear - 6; startYear--) { + const start = new Date(startYear, 3, 1); + const end = new Date(startYear + 1, 2, 31); + this.financialYears.push({ + label: `${startYear}-${String((startYear + 1) % 100).padStart(2, '0')}`, + startLabel: this.toIsoDate(start), + endLabel: this.toIsoDate(end), + start, + end + }); + } + } + + /** Sets both period dates from the chosen year, which is what drives the profit lookup. */ + onFinancialYearChange(label: string): void { + const fy = this.financialYears.find((y) => y.label === label); + if (!fy) { + return; + } + this.selectedFinancialYear = label; + this.createDividendForm.patchValue({ + dividendPeriodStartDate: fy.start, + dividendPeriodEndDate: fy.end + }); + } + /** Parses a Fineract date field, which may arrive as `[yyyy, MM, dd]` or an ISO string. */ private parseFineractDate(value: any): Date | null { if (!value) { @@ -172,8 +276,9 @@ export class CreateDividendComponent implements OnInit { } /** - * Looks up the bank's real net profit for the selected dividend period, once both dates are - * set, so the officer can base the dividend on actual profit instead of guessing an amount. + * Looks up how much profit is actually available to distribute for the selected period, once both + * dates are set — net profit from posted GL entries, less the statutory appropriations that + * legally precede a dividend, less what other products have already been declared. */ fetchProfitForSelectedPeriod(): void { const start: Date = this.createDividendForm.get('dividendPeriodStartDate').value; @@ -183,36 +288,123 @@ export class CreateDividendComponent implements OnInit { } this.loadingProfit = true; this.profitLookupError = false; - this.accountingService.getProfitAndLoss(this.toIsoDate(start), this.toIsoDate(end)).subscribe({ - next: (response: any) => { - this.netProfit = Number(response.netProfit) || 0; - this.loadingProfit = false; - this.recomputeDividendAmount(); - }, - error: () => { - this.netProfit = null; - this.loadingProfit = false; - this.profitLookupError = true; - } - }); + this.accountingService + .getDistributableProfit(this.toIsoDate(start), this.toIsoDate(end), this.shareProductData.id) + .subscribe({ + next: (response: any) => { + this.profitBreakdown = { + financialYear: response.financialYear, + coversWholeFinancialYear: !!response.coversWholeFinancialYear, + yearClosed: !!response.yearClosed, + profitSource: response.profitSource, + netProfit: Number(response.netProfit) || 0, + distributableProfit: Number(response.distributableProfit) || 0, + alreadyDeclared: Number(response.alreadyDeclared) || 0, + availableForDeclaration: Number(response.availableForDeclaration) || 0, + maximumRatePercentage: Number(response.maximumRatePercentage) || 0, + existingDeclarations: response.existingDeclarations || [] + }; + this.loadingProfit = false; + this.recomputeDividendAmount(); + }, + error: () => { + this.profitBreakdown = null; + this.loadingProfit = false; + this.profitLookupError = true; + } + }); + this.fetchCapitalPreview(); } - /** Dividend Amount = Net Profit x Dividend Percentage / 100 — no manual entry needed. */ + /** + * Weighted paid-up capital for the period. Fetched independently of the profit lookup because it + * answers a different question — what the members actually hold, rather than what the society + * earned — and RATE_ON_CAPITAL needs both. + */ + fetchCapitalPreview(): void { + const start: Date = this.createDividendForm.get('dividendPeriodStartDate').value; + const end: Date = this.createDividendForm.get('dividendPeriodEndDate').value; + if (!(start instanceof Date) || !(end instanceof Date)) { + return; + } + this.loadingCapital = true; + this.capitalLookupError = false; + this.productService + .getDividendCapitalPreview(this.shareProductData.id, this.toIsoDate(start), this.toIsoDate(end)) + .subscribe({ + next: (response: any) => { + this.capitalPreview = { + weightedCapital: Number(response.weightedCapital) || 0, + requiredAmount: null, + eligibleAccounts: Number(response.eligibleAccounts) || 0, + minimumActivePeriodDays: Number(response.minimumActivePeriodDays) || 0 + }; + this.loadingCapital = false; + this.recomputeDividendAmount(); + }, + error: () => { + this.capitalPreview = null; + this.loadingCapital = false; + this.capitalLookupError = true; + } + }); + } + + /** + * Computes the dividend amount — weighted paid-up capital x rate — then checks it against both + * ceilings: the rate cap in the governing Act, and the profit still available to fund it. + * + * Every member receives the declared rate on their own capital, pro-rated by holding period, so + * the amount follows entirely from what members invested. Profit does not enter the arithmetic; + * it only has to be large enough to pay the result. + */ recomputeDividendAmount(): void { const percentage = Number(this.createDividendForm.get('dividendPercentage').value); - if (this.netProfit === null || !percentage || percentage <= 0) { + this.ceilingError = null; + this.rateCeilingError = null; + + if (!percentage || percentage <= 0) { + this.createDividendForm.get('dividendAmount').setValue(''); + if (this.capitalPreview) { + this.capitalPreview.requiredAmount = null; + } + return; + } + + // The rate cap governs the rate promised on capital, which is exactly what is entered here. + const maximumRate = this.profitBreakdown?.maximumRatePercentage ?? 0; + if (maximumRate > 0 && percentage > maximumRate) { + this.rateCeilingError = `The maximum dividend rate permitted is ${maximumRate}%. Reduce the rate to declare this dividend.`; + } + + if (this.capitalPreview === null) { this.createDividendForm.get('dividendAmount').setValue(''); return; } - const amount = (this.netProfit * percentage) / 100; - this.createDividendForm.get('dividendAmount').setValue(Math.round(amount * 100) / 100); + const amount = (this.capitalPreview.weightedCapital * percentage) / 100; + const rounded = Math.round(amount * 100) / 100; + this.capitalPreview.requiredAmount = rounded; + this.createDividendForm.get('dividendAmount').setValue(rounded); + + // Not a limit on the calculation — a limit on what the profit account can fund. The amount is + // already fixed by capital; this only says whether there is enough profit to pay it. + if (this.profitBreakdown && rounded > this.profitBreakdown.availableForDeclaration) { + this.ceilingError = + `This dividend of ${rounded.toFixed(2)} exceeds the ${this.profitBreakdown.availableForDeclaration.toFixed(2)} ` + + `still available to fund it for this period. Reduce the rate, or declare for a period with more profit.`; + } + } + + /** True when any ceiling or overlap rule blocks submission. */ + get hasBlockingError(): boolean { + return !!this.overlapError || !!this.ceilingError || !!this.rateCeilingError; } /** * Submits Create Dividend form. */ submit() { - if (this.overlapError) { + if (this.hasBlockingError) { return; } const createDividendFormData = this.createDividendForm.value; diff --git a/src/app/products/share-products/create-share-product/create-share-product.component.html b/src/app/products/share-products/create-share-product/create-share-product.component.html index 89efc79c2..3d5ee4a18 100644 --- a/src/app/products/share-products/create-share-product/create-share-product.component.html +++ b/src/app/products/share-products/create-share-product/create-share-product.component.html @@ -110,8 +110,7 @@ [shareProductsTemplate]="shareProductsTemplate" [accountingRuleData]="accountingRuleData" [shareProduct]="shareProduct" - [taskPermission]="'CREATE_SHAREPRODUCT'" - (submitEvent)="submit()" + [showActions]="false" > diff --git a/src/app/products/share-products/create-share-product/create-share-product.component.ts b/src/app/products/share-products/create-share-product/create-share-product.component.ts index 4b1a19ed2..721afbb0f 100644 --- a/src/app/products/share-products/create-share-product/create-share-product.component.ts +++ b/src/app/products/share-products/create-share-product/create-share-product.component.ts @@ -22,6 +22,7 @@ import { ShareProductAccountingStepComponent } from '../share-product-stepper/sh /** Custom Services */ import { ProductsService } from 'app/products/products.service'; import { SettingsService } from 'app/settings/settings.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { Accounting } from 'app/core/utils/accounting'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { ShareProductPreviewStepComponent } from '../share-product-stepper/share-product-preview-step/share-product-preview-step.component'; @@ -49,6 +50,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class CreateShareProductComponent { private route = inject(ActivatedRoute); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private router = inject(Router); private settingsService = inject(SettingsService); private accounting = inject(Accounting); @@ -143,14 +145,24 @@ export class CreateShareProductComponent { chargesSelected: this.shareProduct.chargesSelected.map((charge: any) => ({ id: charge.id })), locale: this.settingsService.language.code // locale required for digitsAfterDecimal }; - this.productsService.createShareProduct(shareProduct).subscribe((response: any) => { - this.router.navigate( - [ - '../', - response.resourceId - ], - { relativeTo: this.route } - ); + this.productsService.createShareProduct(shareProduct).subscribe({ + next: (response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Share product creation request submitted successfully for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + } else { + this.router.navigate( + [ + '../', + response.resourceId + ], + { relativeTo: this.route } + ); + } + } }); } } diff --git a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts index 14693833d..4cb5e33d2 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts +++ b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts @@ -70,16 +70,9 @@ export class ShareProductAccountingStepComponent implements OnInit { shareProductAccountingForm: UntypedFormGroup; accountingSectionConfigs: ShareAccountingSectionConfig[] = []; - assetAccountData: GLAccount[] = []; liabilityAccountData: GLAccount[] = []; incomeAccountData: GLAccount[] = []; equityAccountData: GLAccount[] = []; - /** "Share Reference" is where a redemption's credit side lands pending settlement — some banks - * model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default), - * others as a payable-like Liability suspense account (e.g. an existing "Sundry" account - * already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts - * either instead of Asset-only. */ - referenceAccountData: GLAccount[] = []; constructor() { this.createShareProductAccountingForm(); @@ -87,18 +80,15 @@ export class ShareProductAccountingStepComponent implements OnInit { ngOnInit() { const opts = this.shareProductsTemplate.accountingMappingOptions || {}; - this.assetAccountData = opts.assetAccountOptions || []; this.liabilityAccountData = opts.liabilityAccountOptions || []; this.incomeAccountData = opts.incomeAccountOptions || []; this.equityAccountData = opts.equityAccountOptions || []; - this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData); this.buildAccountingSectionConfigs(); if (this.shareProductsTemplate.accountingMappings) { const m = this.shareProductsTemplate.accountingMappings; this.shareProductAccountingForm.patchValue({ - shareReferenceId: m.shareReferenceId?.id || '', shareSuspenseId: m.shareSuspenseId?.id || '', incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '', shareEquityId: m.shareEquityId?.id || '' @@ -109,10 +99,6 @@ export class ShareProductAccountingStepComponent implements OnInit { createShareProductAccountingForm() { this.shareProductAccountingForm = this.formBuilder.group({ accountingRule: [2], - shareReferenceId: [ - '', - Validators.required - ], shareSuspenseId: [ '', Validators.required @@ -125,7 +111,6 @@ export class ShareProductAccountingStepComponent implements OnInit { '', Validators.required ], - assetsOtherGlMappings: this.formBuilder.array([]), liabilitiesOtherGlMappings: this.formBuilder.array([]), incomeOtherGlMappings: this.formBuilder.array([]), equityOtherGlMappings: this.formBuilder.array([]) @@ -134,24 +119,6 @@ export class ShareProductAccountingStepComponent implements OnInit { buildAccountingSectionConfigs() { this.accountingSectionConfigs = [ - { - key: 'assets', - title: 'Assets', - pillLabel: 'Asset', - pillClass: 'green', - rows: [ - { - key: 'shareReferenceId', - label: 'Share Reference (Cash Vault or Suspense)', - inputLabel: 'Share reference', - controlName: 'shareReferenceId', - options: this.referenceAccountData, - required: true - } - ], - otherMappingsControlName: 'assetsOtherGlMappings', - otherAccountOptions: this.assetAccountData - }, { key: 'liabilities', title: 'Liabilities', @@ -239,7 +206,7 @@ export class ShareProductAccountingStepComponent implements OnInit { } get shareProductAccounting() { - const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } = + const { liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } = this.shareProductAccountingForm.value; return rest; } diff --git a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html index 91c9f9ec0..4f1064ae8 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html +++ b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html @@ -213,14 +213,6 @@ @if (shareProduct.accountingRule === 2) { -
{{ 'labels.heading.Assets' | translate }}
-
- {{ 'labels.inputs.Share reference' | translate }}: - {{ - shareProduct.shareReferenceId - | find: shareProductsTemplate.accountingMappingOptions.assetAccountOptions : 'id' : 'name' - }} -
{{ 'labels.heading.Liabilities' | translate }}
{{ 'labels.inputs.Share suspense control' | translate }}: @@ -250,11 +242,13 @@
-
- - -
+@if (showActions) { +
+ + +
+} diff --git a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts index 1c1fc2c11..c725721bb 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts +++ b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts @@ -52,6 +52,10 @@ export class ShareProductPreviewStepComponent { @Input() accountingRuleData: any; @Input() shareProduct: any; @Input() taskPermission: string; + /** Set to false when the host page already renders its own Cancel/Submit bar for this step + * (the Create flow's shared bottom action bar) — Edit's stepper has no such bar of its own, so + * this defaults to true and keeps rendering the button there. */ + @Input() showActions = true; @Output() submitEvent = new EventEmitter(); marketPriceDisplayedColumns: string[] = [ diff --git a/src/app/products/share-products/share-products.component.html b/src/app/products/share-products/share-products.component.html index db28741c4..7ae388c09 100644 --- a/src/app/products/share-products/share-products.component.html +++ b/src/app/products/share-products/share-products.component.html @@ -21,6 +21,10 @@ {{ totalCount }} {{ totalCount === 1 ? 'product' : 'products' }} + + @if (embedded) { + + } @else { + + } } + + @if (embedded) { @@ -214,16 +208,6 @@ @if (shareProduct.accountingRule.id === 2) {
-

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

-
- - -

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ * The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules + * leaves the case open on the sixth rather than closing it. + */ + reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void { + const codes = Array.from(this.selectedRuleCodes); + if (!codes.length) { + return; + } + this.dialog + .open(AmlReviewAlertDialogComponent, { + width: '560px', + maxWidth: '95vw', + autoFocus: false, + disableClose: true, + data: { alert, disposition, ruleCodes: codes } + }) + .afterClosed() + .subscribe((result: string | undefined) => { + if (!result) { + return; + } + this.alertService.alert({ + type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated', + message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${ + disposition === 'WHITELIST' ? 'cleared' : 'escalated' + }.` + }); + this.selectedRuleCodes.clear(); + // The alert's status may have changed with the last rule decided, so re-read rather than patching locally. + this.fetch(); + }); + } + + reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void { + const batch = this.selectedAlerts(); + if (!batch.length) { + return; + } + this.dialog + .open(AmlBulkReviewDialogComponent, { + width: '620px', + maxWidth: '95vw', + autoFocus: false, + disableClose: true, + data: { alerts: batch, disposition } + }) + .afterClosed() + .subscribe((done: boolean | undefined) => { + if (!done) { + return; + } + this.selectedIds.clear(); + this.fetch(); + }); + } } diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts index c5bc66317..beddd4527 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts @@ -31,6 +31,11 @@ import { AmlService } from '../../../aml/aml.service'; export interface AmlReviewDialogData { alert: any; disposition: 'WHITELIST' | 'ESCALATE'; + /** + * The rules being decided, when the officer picked some inside the expanded pane rather than acting on the whole + * transaction. Absent means the decision covers the alert and every rule under it. + */ + ruleCodes?: string[]; } /** @@ -162,6 +167,13 @@ export class AmlReviewAlertDialogComponent { } get title(): string { + // Named differently when only some of the transaction's rules are being decided, so the officer is not told they + // are clearing a transaction when five of its six rules stay open. + const n = this.data.ruleCodes?.length ?? 0; + if (n > 0) { + const subject = n === 1 ? 'Rule' : `${n} Rules`; + return this.isWhitelist ? `Whitelist ${subject}` : `Mark ${subject} as Suspicious`; + } return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious'; } @@ -181,7 +193,17 @@ export class AmlReviewAlertDialogComponent { this.submitting = true; this.errorMessage = ''; - this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({ + // Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence + // requirements are the same either way, which is why this dialog serves both. + const request = this.data.ruleCodes?.length + ? this.amlService.reviewAlertRules( + this.data.alert.id, + this.data.ruleCodes, + this.data.disposition, + this.composedNotes + ) + : this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes); + request.subscribe({ next: () => { this.submitting = false; this.dialogRef.close(this.data.disposition); diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html index f614c533c..8d8edac1e 100644 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html @@ -51,15 +51,6 @@ Could not load the review queue.

- -
- science - - Showing sample data — the AML engine returned no recurring alerts for this threshold and window. - These rows are for demonstration only and are not reportable. - -
- diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss index 0f64353be..9d62ff9ed 100644 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss @@ -79,27 +79,6 @@ font-size: 13px; } -.ra-demo { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 10px 12px; - margin-bottom: 12px; - border: 1px solid #fde68a; - border-radius: 6px; - background: #fffbeb; - color: #92400e; - font-size: 12.5px; - line-height: 1.5; - - mat-icon { - flex: 0 0 auto; - font-size: 18px; - width: 18px; - height: 18px; - } -} - .ra-table { width: 100%; border-collapse: separate; diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts index e69fcd8d5..bf2198e8c 100644 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts @@ -25,7 +25,6 @@ import { AmlService } from '../../../aml/aml.service'; import { AlertService } from '../../../core/alert/alert.service'; import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive'; import { AmlRecurringReviewDialogComponent } from './aml-recurring-review-dialog.component'; -import { amlRecurringAlertsDemoData } from './aml-recurring-alerts.demo-data'; /** * Customers whose transactions keep tripping the AML engine, queued for an employee to review why. @@ -95,8 +94,6 @@ export class AmlRecurringAlertsComponent implements OnInit { loading = false; loadFailed = false; - /** Set when the rows on screen are the sample set rather than engine output. */ - usingDemoData = false; /** Which customer's alert detail is expanded inline. */ expandedClientId: number | null = null; @@ -111,20 +108,20 @@ export class AmlRecurringAlertsComponent implements OnInit { this.amlService.getRecurringAlerts(this.minAlerts, this.windowDays, this.onlyNeedingReview).subscribe({ next: (data) => { this.loading = false; - const rows = data || []; - this.usingDemoData = rows.length === 0; - this.rows.data = this.mapRows(this.usingDemoData ? this.demoRows() : rows); + this.rows.data = this.mapRows(data || []); }, error: () => { this.loading = false; this.loadFailed = true; - this.usingDemoData = true; - this.rows.data = this.mapRows(this.demoRows()); + // An AML review queue shows what the engine found or nothing at all. Standing in sample + // customers when the call fails invents suspicious activity against names that are not + // the bank's, which is worse than an empty table saying the load failed. + this.rows.data = []; } }); } - /** Parses the date fields the template renders. Shared by engine rows and the sample set. */ + /** Parses the date fields the template renders. */ private mapRows(rows: any[]): any[] { return rows.map((r: any) => ({ ...r, @@ -135,36 +132,6 @@ export class AmlRecurringAlertsComponent implements OnInit { })); } - /** - * The sample set narrowed by the on-screen controls, so the threshold and window selectors behave - * the way they would against the engine instead of returning the same three rows regardless. - */ - private demoRows(): any[] { - const cutoff = Date.now() - this.windowDays * 24 * 60 * 60 * 1000; - return amlRecurringAlertsDemoData() - .map((row: any) => { - const alerts = (row.alerts || []).filter((a: any) => new Date(a.raisedOn).getTime() >= cutoff); - const ruleCodes = Array.from(new Set(alerts.map((a: any) => a.ruleCode))); - return { - ...row, - alerts, - alertCount: alerts.length, - openCount: alerts.filter((a: any) => a.status === 'OPEN').length, - escalatedCount: alerts.filter((a: any) => a.status === 'ESCALATED').length, - // Severity sits on the alert server-side; the sample rows carry a row total, so it is - // capped at what survived the window rather than recomputed. - highRiskCount: Math.min(row.highRiskCount, alerts.length), - distinctRules: ruleCodes.length, - ruleCodes, - totalAmount: alerts.reduce((sum: number, a: any) => sum + (Number(a.amount) || 0), 0), - firstAlertOn: alerts.length ? alerts[0].raisedOn : null, - lastAlertOn: alerts.length ? alerts[alerts.length - 1].raisedOn : null - }; - }) - .filter((row: any) => row.alertCount >= this.minAlerts) - .filter((row: any) => !this.onlyNeedingReview || row.needsReview); - } - toggleExpanded(row: any): void { this.expandedClientId = this.expandedClientId === row.clientId ? null : row.clientId; } diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts deleted file mode 100644 index ffab67a8e..000000000 --- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -/** - * Sample recurring-alert rows for the AML review queue, used only when the engine returns nothing - * for the selected window so the screen can be demonstrated and reviewed. - * - * Shaped exactly like the `/aml/recurring-alerts` response — dates as ISO strings, alerts nested — - * so the rows travel through the component's normal mapping rather than a second code path. - * - * Dates are generated relative to today rather than hard-coded, otherwise every row would fall - * outside the "last 90 days" window as soon as the file aged. - */ - -/** ISO timestamp `days` before now, at the given hour and minute. */ -function daysAgo(days: number, hour = 11, minute = 20): string { - const d = new Date(); - d.setDate(d.getDate() - days); - d.setHours(hour, minute, 0, 0); - return d.toISOString(); -} - -/** - * Three customers covering the three review states the table renders: never reviewed, reviewed but - * flagged again since, and reviewed with an outcome recorded. - */ -export function amlRecurringAlertsDemoData(): any[] { - return [ - // Never reviewed — the row the queue exists to surface. - { - clientId: 10078, - clientDisplayName: 'Ravi Kumar Sharma', - accountNo: '000000010078', - clientRiskCategory: 3, - alertCount: 5, - highRiskCount: 3, - openCount: 4, - escalatedCount: 1, - distinctRules: 3, - ruleCodes: [ - 'CASH_TXN_10L', - 'STRUCTURING', - 'RAPID_MOVEMENT' - ], - totalAmount: 4785000, - firstAlertOn: daysAgo(62, 10, 5), - lastAlertOn: daysAgo(3, 15, 42), - needsReview: true, - lastReviewedOn: null, - alertCountAtLastReview: null, - lastReviewOutcome: null, - lastReviewedByUsername: null, - lastReviewNotes: null, - alerts: [ - { - alertId: 90121, - raisedOn: daysAgo(62, 10, 5), - transactionType: 'Savings Deposit', - transactionId: 552301, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1200000, - status: 'OPEN', - reviewNotes: null - }, - { - alertId: 90144, - raisedOn: daysAgo(48, 12, 18), - transactionType: 'Savings Deposit', - transactionId: 553980, - ruleCode: 'STRUCTURING', - ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours', - amount: 890000, - status: 'OPEN', - reviewNotes: null - }, - { - alertId: 90190, - raisedOn: daysAgo(29, 9, 55), - transactionType: 'Savings Withdrawal', - transactionId: 556114, - ruleCode: 'RAPID_MOVEMENT', - ruleDescription: 'Funds withdrawn within 24 hours of a large credit', - amount: 1150000, - status: 'ESCALATED', - reviewNotes: 'Escalated to Principal Officer — source of funds not established.' - }, - { - alertId: 90233, - raisedOn: daysAgo(14, 16, 30), - transactionType: 'Savings Deposit', - transactionId: 558702, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1045000, - status: 'OPEN', - reviewNotes: null - }, - { - alertId: 90275, - raisedOn: daysAgo(3, 15, 42), - transactionType: 'Savings Withdrawal', - transactionId: 560488, - ruleCode: 'STRUCTURING', - ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours', - amount: 500000, - status: 'OPEN', - reviewNotes: null - } - ] - }, - - // Reviewed at 2 alerts, flagged twice since — returns to the queue as "New activity". - { - clientId: 10079, - clientDisplayName: 'Priya Patel', - accountNo: '000000010079', - clientRiskCategory: 2, - alertCount: 4, - highRiskCount: 1, - openCount: 2, - escalatedCount: 0, - distinctRules: 2, - ruleCodes: [ - 'HIGH_VALUE_TRANSFER', - 'ROUND_TRIPPING' - ], - totalAmount: 2130000, - firstAlertOn: daysAgo(71, 14, 10), - lastAlertOn: daysAgo(6, 11, 5), - needsReview: true, - lastReviewedOn: daysAgo(34, 17, 0), - alertCountAtLastReview: 2, - lastReviewOutcome: 'MONITOR', - lastReviewedByUsername: 'anjali.mehta', - lastReviewNotes: 'Textile trading receipts, invoices seen. Keep under watch for one more quarter.', - alerts: [ - { - alertId: 90102, - raisedOn: daysAgo(71, 14, 10), - transactionType: 'Account Transfer', - transactionId: 551002, - ruleCode: 'HIGH_VALUE_TRANSFER', - ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account', - amount: 620000, - status: 'WHITELISTED', - reviewNotes: 'Supplier payment, invoice verified.' - }, - { - alertId: 90158, - raisedOn: daysAgo(41, 10, 45), - transactionType: 'Account Transfer', - transactionId: 554330, - ruleCode: 'ROUND_TRIPPING', - ruleDescription: 'Funds returned to the originating account within seven days', - amount: 450000, - status: 'WHITELISTED', - reviewNotes: 'Advance refunded after order cancellation.' - }, - { - alertId: 90241, - raisedOn: daysAgo(19, 13, 25), - transactionType: 'Account Transfer', - transactionId: 559011, - ruleCode: 'HIGH_VALUE_TRANSFER', - ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account', - amount: 540000, - status: 'OPEN', - reviewNotes: null - }, - { - alertId: 90268, - raisedOn: daysAgo(6, 11, 5), - transactionType: 'Account Transfer', - transactionId: 560190, - ruleCode: 'ROUND_TRIPPING', - ruleDescription: 'Funds returned to the originating account within seven days', - amount: 520000, - status: 'OPEN', - reviewNotes: null - } - ] - }, - - // Reviewed with an outcome and no activity since — shows the settled state. - { - clientId: 10080, - clientDisplayName: 'Arjun Deshmukh', - accountNo: '000000010080', - clientRiskCategory: 1, - alertCount: 3, - highRiskCount: 0, - openCount: 0, - escalatedCount: 0, - distinctRules: 1, - ruleCodes: ['CASH_TXN_10L'], - totalAmount: 3420000, - firstAlertOn: daysAgo(80, 9, 30), - lastAlertOn: daysAgo(23, 12, 15), - needsReview: false, - lastReviewedOn: daysAgo(9, 15, 20), - alertCountAtLastReview: 3, - lastReviewOutcome: 'CLEARED', - lastReviewedByUsername: 'vikram.singh', - lastReviewNotes: 'Sugarcane crushing-season proceeds. Mill settlement statements filed with the branch.', - alerts: [ - { - alertId: 90088, - raisedOn: daysAgo(80, 9, 30), - transactionType: 'Savings Deposit', - transactionId: 550210, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1180000, - status: 'WHITELISTED', - reviewNotes: 'Mill settlement for the 2025-26 crushing season.' - }, - { - alertId: 90165, - raisedOn: daysAgo(52, 11, 0), - transactionType: 'Savings Deposit', - transactionId: 554901, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1090000, - status: 'WHITELISTED', - reviewNotes: 'Second instalment, statement on record.' - }, - { - alertId: 90212, - raisedOn: daysAgo(23, 12, 15), - transactionType: 'Savings Deposit', - transactionId: 557640, - ruleCode: 'CASH_TXN_10L', - ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction', - amount: 1150000, - status: 'WHITELISTED', - reviewNotes: 'Final instalment for the season.' - } - ] - } - ]; -} diff --git a/src/app/rbi-circulars/openproject.service.ts b/src/app/rbi-circulars/openproject.service.ts index c1c6ed092..00ea499a3 100644 --- a/src/app/rbi-circulars/openproject.service.ts +++ b/src/app/rbi-circulars/openproject.service.ts @@ -9,7 +9,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { environment } from 'environments/environment'; +import { environment } from '../../environments/environment'; export interface OpenProjectWorkPackage { id: number; diff --git a/src/app/rbi-circulars/rbi-circulars-routing.module.ts b/src/app/rbi-circulars/rbi-circulars-routing.module.ts index d0feae670..045ce0f06 100644 --- a/src/app/rbi-circulars/rbi-circulars-routing.module.ts +++ b/src/app/rbi-circulars/rbi-circulars-routing.module.ts @@ -36,6 +36,14 @@ const routes: Routes = [ loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent), data: { title: 'OSS 2 — Return on Earnings', breadcrumb: 'OSS 2 (RoE)' } }, + { + path: 'rbi/share-capital', + // Lazy for the same reason as the OSS returns — the ExcelJS workbook builder should not + // weigh down the eager bundle for a page most users never open. + loadComponent: () => + import('./share-capital-return/share-capital-return.component').then((m) => m.ShareCapitalReturnComponent), + data: { title: 'Members & Share Capital Return', breadcrumb: 'Members & Share Capital' } + }, { path: 'rbi/oss3', loadComponent: () => import('./oss3-raq/oss3-raq.component').then((m) => m.Oss3RaqComponent), diff --git a/src/app/rbi-circulars/rbi-circulars.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts index 20a610c14..33446834c 100644 --- a/src/app/rbi-circulars/rbi-circulars.service.ts +++ b/src/app/rbi-circulars/rbi-circulars.service.ts @@ -44,4 +44,20 @@ export class RbiCircularsService { triggerFetch(): Observable { return this.http.post('/rbi-feeds/fetch', {}); } + + /** + * Members and share capital per share type for one financial year, with the year before alongside. + * + * @param financialYear the year the FY opens in — 2025 means FY 2025-26. + */ + getShareCapitalReturn(financialYear: number): Observable { + return this.http.get('/shareproducts/capital-return', { + params: new HttpParams().set('financialYear', String(financialYear)) + }); + } + + /** Financial years that actually have share activity, newest first, for the year picker. */ + getShareCapitalReturnYears(): Observable { + return this.http.get('/shareproducts/capital-return/financial-years'); + } } diff --git a/src/app/rbi-circulars/share-capital-return/share-capital-return.component.html b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.html new file mode 100644 index 000000000..43110052e --- /dev/null +++ b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.html @@ -0,0 +1,202 @@ + + +
+
+
+

Return on Members & Share Capital

+ +

+ Membership and share capital as reported to the RBI, per share type — how many members hold each type, how much + capital they have subscribed, and how much of the authorised issue remains unsold, with the previous financial + year alongside for comparison. +

+
+
+ + + +
+
+ + @if (loadError) { +
{{ loadError }}
+ } + + @if (loading) { +
Loading the share register…
+ } @else if (report) { +
+ Reporting period {{ report.periodStartDate }} to {{ report.periodEndDate }} · + compared with FY {{ report.previousFinancialYear }} +
+ + +
+
+ Total Members + {{ totals.membersClosing | number }} + + {{ report.previousFinancialYear }}: {{ totals.membersClosingPrev | number }} + @if (yoyChange(totals.membersClosing, totals.membersClosingPrev) !== null) { + + {{ totals.membersClosing >= totals.membersClosingPrev ? '▲' : '▼' }} + {{ yoyChange(totals.membersClosing, totals.membersClosingPrev) | number: '1.1-1' }}% + + } + +
+
+ Total Share Capital + {{ totals.capitalClosing | number: '1.2-2' }} + + {{ report.previousFinancialYear }}: {{ totals.capitalClosingPrev | number: '1.2-2' }} + @if (yoyChange(totals.capitalClosing, totals.capitalClosingPrev) !== null) { + + {{ totals.capitalClosing >= totals.capitalClosingPrev ? '▲' : '▼' }} + {{ yoyChange(totals.capitalClosing, totals.capitalClosingPrev) | number: '1.1-1' }}% + + } + +
+
+ Shares Allotted + {{ totals.allottedShares | number }} + of {{ totals.authorisedShares | number }} authorised +
+
+ Unissued (Remaining) + {{ totals.unissuedShares | number }} + still available to allot +
+
+ + @for (t of report.shareTypes; track t.productId) { +
+
+ {{ t.shareType }} + + Face value {{ t.unitPrice | number: '1.2-2' }} {{ t.currency }} · Authorised + {{ t.authorisedShares | number }} shares + +
+ + +
+
+
+
+ + {{ t.allottedShares | number }} allotted · + {{ t.unissuedShares | number }} remaining + @if (t.utilisationPercentage != null) { + ({{ t.utilisationPercentage | number: '1.1-1' }}% issued) + } + +
+ +
Customer
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParticularsFY {{ report.financialYear }}FY {{ report.previousFinancialYear }}
Members
Opening{{ t.currentYear.membersOpening | number }}{{ t.previousYear.membersOpening | number }}
New admissions during the year{{ t.currentYear.membersAdmitted | number }}{{ t.previousYear.membersAdmitted | number }}
Ceased during the year{{ t.currentYear.membersCeased | number }}{{ t.previousYear.membersCeased | number }}
Closing members{{ t.currentYear.membersClosing | number }}{{ t.previousYear.membersClosing | number }}
Share Capital
Opening{{ t.currentYear.capitalOpening | number: '1.2-2' }}{{ t.previousYear.capitalOpening | number: '1.2-2' }}
Issued during the year{{ t.currentYear.capitalIssued | number: '1.2-2' }}{{ t.previousYear.capitalIssued | number: '1.2-2' }}
Redeemed / withdrawn{{ t.currentYear.capitalRedeemed | number: '1.2-2' }}{{ t.previousYear.capitalRedeemed | number: '1.2-2' }}
Closing share capital{{ t.currentYear.capitalClosing | number: '1.2-2' }}{{ t.previousYear.capitalClosing | number: '1.2-2' }}
Shares issued during the year{{ t.currentYear.sharesIssued | number }}{{ t.previousYear.sharesIssued | number }}
+ + } + + @if (!report.shareTypes?.length) { +
No share types are configured, so there is nothing to report.
+ } + } + diff --git a/src/app/rbi-circulars/share-capital-return/share-capital-return.component.scss b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.scss new file mode 100644 index 000000000..37c46bb6d --- /dev/null +++ b/src/app/rbi-circulars/share-capital-return/share-capital-return.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/. + */ + +.scr-root { + padding: 24px; + max-width: 1200px; + margin: 0 auto; +} + +.scr-header { + display: flex; + align-items: flex-start; + gap: 20px; + flex-wrap: wrap; + margin-bottom: 20px; + + &__text { + flex: 1 1 420px; + + h2 { + margin: 0 0 6px; + font-size: 22px; + font-weight: 600; + color: #1e293b; + } + + p { + margin: 0; + max-width: 720px; + font-size: 13px; + line-height: 1.55; + color: #64748b; + } + } + + &__actions { + display: flex; + align-items: flex-end; + gap: 10px; + flex-wrap: wrap; + } +} + +.scr-year { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + font-weight: 600; + color: #475569; + + select { + padding: 7px 10px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 13px; + color: #0f172a; + background: #fff; + } +} + +.scr-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 14px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + font-size: 13px; + font-weight: 600; + color: #334155; + cursor: pointer; + + &:hover:not(:disabled) { + background: #f8fafc; + } + + &:disabled { + opacity: 0.55; + cursor: default; + } + + &--primary { + border-color: #1e7f4f; + background: #1e7f4f; + color: #fff; + + &:hover:not(:disabled) { + background: #19693f; + } + } +} + +.scr-period { + margin-bottom: 16px; + font-size: 12.5px; + color: #475569; +} + +.scr-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px; + margin-bottom: 22px; +} + +.scr-stat { + display: flex; + flex-direction: column; + gap: 3px; + padding: 13px 15px; + border: 1px solid #e2e8f0; + border-left: 3px solid #4f46e5; + border-radius: 10px; + background: #fff; + + /* Unissued stock is headroom, not performance — muted so it does not read as a result. */ + &--muted { + border-left-color: #94a3b8; + } + + &__label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #64748b; + } + + &__value { + font-size: 22px; + font-weight: 700; + color: #0f172a; + font-variant-numeric: tabular-nums; + } + + &__sub { + font-size: 11.5px; + color: #64748b; + font-variant-numeric: tabular-nums; + } +} + +.scr-delta { + margin-left: 5px; + font-weight: 700; + + &--up { + color: #15803d; + } + + &--down { + color: #b91c1c; + } +} + +.scr-card { + margin-bottom: 18px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; + overflow: hidden; + + &__head { + display: flex; + align-items: baseline; + gap: 12px; + flex-wrap: wrap; + padding: 13px 16px; + border-bottom: 1px solid #eef2f6; + background: #f8fafc; + } + + &__title { + font-size: 14.5px; + font-weight: 700; + color: #0f172a; + } + + &__meta { + font-size: 12px; + color: #64748b; + } +} + +/* Allotted against authorised, as a bar: the remaining headroom is the figure a supervisor checks a + fresh issue against, and a bar shows it without the reader subtracting two numbers. */ +.scr-util { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 12px 16px; + + &__bar { + flex: 1 1 220px; + height: 8px; + border-radius: 9999px; + background: #eef2f6; + overflow: hidden; + } + + &__fill { + height: 100%; + border-radius: 9999px; + background: #4f46e5; + } + + &__text { + font-size: 12.5px; + color: #475569; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } +} + +.scr-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + padding: 9px 16px; + text-align: left; + font-size: 10.5px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + border-bottom: 1px solid #eef2f6; + } + + td { + padding: 8px 16px; + color: #334155; + border-bottom: 1px solid #f4f7fa; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } +} + +/* Members and Share Capital are two separate statements inside one table — labelled so a reader + never has to guess which block a row belongs to. */ +.scr-group td { + padding-top: 13px; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #4f46e5; + background: #fbfcfe; +} + +.scr-total td { + font-weight: 700; + color: #0f172a; + background: #fbfcfe; +} + +.scr-note { + padding: 12px 16px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + font-size: 13px; + color: #475569; + + &--error { + border-color: #fecaca; + background: #fef2f2; + color: #b91c1c; + } +} diff --git a/src/app/rbi-circulars/share-capital-return/share-capital-return.component.ts b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.ts new file mode 100644 index 000000000..ef3ef95fa --- /dev/null +++ b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.ts @@ -0,0 +1,237 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { RbiCircularsService } from '../rbi-circulars.service'; +import { downloadWorkbook } from '../oss-workbook'; + +/** + * Return on Members & Share Capital — the membership and share-capital position reported to the + * RBI, broken down by share type, with the previous year alongside. + * + * A supervisor reading this asks three things of every share type the society offers: how many + * members hold it, how much capital they have put in, and how much of the authorised issue is still + * unsold. Movement matters as much as position — a society whose membership fell while its capital + * rose has done something quite different from one where both grew — so each figure is shown as + * opening, movement, and closing, against the same three for the year before. + * + * Every figure comes from the share register itself rather than a stored summary, so the return + * cannot drift from the accounts it describes. + */ +@Component({ + selector: 'mifosx-share-capital-return', + standalone: true, + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ], + templateUrl: './share-capital-return.component.html', + styleUrls: ['./share-capital-return.component.scss'] +}) +export class ShareCapitalReturnComponent implements OnInit { + private service = inject(RbiCircularsService); + + years: { startYear: number; label: string }[] = []; + selectedYear: number | null = null; + + report: any = null; + loading = false; + loadError: string | null = null; + exporting = false; + + ngOnInit(): void { + this.service + .getShareCapitalReturnYears() + .pipe(catchError(() => of([]))) + .subscribe((res: any[]) => { + this.years = res ?? []; + // Opens on the most recent year rather than an empty screen: that is the return being + // prepared almost every time this page is reached. + this.selectedYear = this.years.length ? this.years[0].startYear : null; + this.load(); + }); + } + + load(): void { + if (this.selectedYear == null) { + return; + } + this.loading = true; + this.loadError = null; + this.service + .getShareCapitalReturn(this.selectedYear) + .pipe( + catchError((err: any) => { + this.loadError = err?.error?.defaultUserMessage ?? 'Could not load the share capital position.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.report = res; + this.loading = false; + }); + } + + onYearChange(startYear: any): void { + this.selectedYear = Number(startYear); + this.load(); + } + + /** Net movement in members over the year — admitted less ceased. */ + netMembers(position: any): number { + return (Number(position?.membersAdmitted) || 0) - (Number(position?.membersCeased) || 0); + } + + /** Net movement in capital — issued less redeemed. */ + netCapital(position: any): number { + return (Number(position?.capitalIssued) || 0) - (Number(position?.capitalRedeemed) || 0); + } + + /** + * Change against the prior year, as a percentage. + * + * Null rather than zero when the prior year was nil: a society going from no members to fifty has + * not grown by 0%, and showing a number there would be worse than showing nothing. + */ + yoyChange(current: any, previous: any): number | null { + const prev = Number(previous) || 0; + const curr = Number(current) || 0; + if (prev === 0) { + return null; + } + return ((curr - prev) / prev) * 100; + } + + /** Society-wide totals, so the return has a bottom line and not only per-type rows. */ + get totals(): any { + const types = this.report?.shareTypes ?? []; + const sum = (pick: (t: any) => number) => types.reduce((s: number, t: any) => s + (pick(t) || 0), 0); + return { + membersClosing: sum((t) => Number(t.currentYear?.membersClosing)), + membersClosingPrev: sum((t) => Number(t.previousYear?.membersClosing)), + capitalClosing: sum((t) => Number(t.currentYear?.capitalClosing)), + capitalClosingPrev: sum((t) => Number(t.previousYear?.capitalClosing)), + authorisedShares: sum((t) => Number(t.authorisedShares)), + allottedShares: sum((t) => Number(t.allottedShares)), + unissuedShares: sum((t) => Number(t.unissuedShares)) + }; + } + + /** + * The return as an Excel workbook, laid out the way it is read on screen. + * + * One row per share type with both years side by side, rather than a sheet per year: the whole + * point of the return is the comparison, and splitting it would leave the reader to do the + * matching by hand. + */ + async exportToExcel(): Promise { + if (!this.report) { + return; + } + this.exporting = true; + try { + const ExcelJS = await import('exceljs'); + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('Members & Share Capital'); + const fy = this.report.financialYear; + const prev = this.report.previousFinancialYear; + + ws.mergeCells('A1:L1'); + ws.getCell('A1').value = 'RETURN ON MEMBERS AND SHARE CAPITAL'; + ws.getCell('A1').font = { bold: true, size: 14 }; + ws.getCell('A1').alignment = { horizontal: 'center' }; + + ws.mergeCells('A2:L2'); + ws.getCell('A2').value = + `Financial Year ${fy} (compared with ${prev}) · Period ${this.report.periodStartDate} to ${this.report.periodEndDate}`; + ws.getCell('A2').alignment = { horizontal: 'center' }; + + const headers = [ + 'Share Type', + 'Authorised Shares', + 'Allotted Shares', + 'Unissued (Remaining)', + `Members Opening (${fy})`, + 'New Admissions', + 'Ceased', + `Members Closing (${fy})`, + `Members Closing (${prev})`, + `Capital Opening (${fy})`, + 'Capital Issued', + `Capital Closing (${fy})`, + `Capital Closing (${prev})` + ]; + const headerRow = ws.getRow(4); + headers.forEach((h, i) => { + headerRow.getCell(i + 1).value = h; + headerRow.getCell(i + 1).font = { bold: true }; + headerRow.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } }; + headerRow.getCell(i + 1).alignment = { wrapText: true, vertical: 'middle' }; + }); + + (this.report.shareTypes ?? []).forEach((t: any, i: number) => { + const row = ws.getRow(5 + i); + const values = [ + t.shareType, + Number(t.authorisedShares) || 0, + Number(t.allottedShares) || 0, + Number(t.unissuedShares) || 0, + Number(t.currentYear?.membersOpening) || 0, + Number(t.currentYear?.membersAdmitted) || 0, + Number(t.currentYear?.membersCeased) || 0, + Number(t.currentYear?.membersClosing) || 0, + Number(t.previousYear?.membersClosing) || 0, + Number(t.currentYear?.capitalOpening) || 0, + Number(t.currentYear?.capitalIssued) || 0, + Number(t.currentYear?.capitalClosing) || 0, + Number(t.previousYear?.capitalClosing) || 0 + ]; + values.forEach((v, c) => { + row.getCell(c + 1).value = v; + if (c >= 9) { + row.getCell(c + 1).numFmt = '#,##0.00'; + } + }); + }); + + const totalRow = ws.getRow(5 + (this.report.shareTypes?.length ?? 0)); + const t = this.totals; + totalRow.getCell(1).value = 'TOTAL'; + totalRow.getCell(2).value = t.authorisedShares; + totalRow.getCell(3).value = t.allottedShares; + totalRow.getCell(4).value = t.unissuedShares; + totalRow.getCell(8).value = t.membersClosing; + totalRow.getCell(9).value = t.membersClosingPrev; + totalRow.getCell(12).value = t.capitalClosing; + totalRow.getCell(13).value = t.capitalClosingPrev; + [ + 12, + 13 + ].forEach((c) => (totalRow.getCell(c).numFmt = '#,##0.00')); + for (let c = 1; c <= 13; c++) { + totalRow.getCell(c).font = { bold: true }; + } + + ws.getColumn(1).width = 26; + for (let c = 2; c <= 13; c++) { + ws.getColumn(c).width = 16; + } + + await downloadWorkbook(wb as any, `Return on Members and Share Capital - FY ${fy}.xlsx`); + } finally { + this.exporting = false; + } + } +} diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index c19074d2a..4a6fe1a06 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -64,6 +64,23 @@ const routes: Routes = [ loadComponent: () => import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent) }, + { + path: 'kyc-expiry', + data: { title: 'Re-KYC Expiry & Outreach', breadcrumb: 'Re-KYC Expiry & Outreach' }, + loadComponent: () => import('../kyc-expiry/kyc-expiry.component').then((m) => m.KycExpiryComponent) + }, + { + path: 'savings-interest', + data: { title: 'Savings Interest Posted', breadcrumb: 'Savings Interest Posted' }, + loadComponent: () => + import('../interest-report/interest-report.component').then((m) => m.InterestReportComponent) + }, + { + path: 'dormancy', + data: { title: 'Inactive, Dormant & DEAF', breadcrumb: 'Inactive, Dormant & DEAF' }, + loadComponent: () => + import('../dormancy-report/dormancy-report.component').then((m) => m.DormancyReportComponent) + }, { path: 'tds-interest', data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' }, @@ -76,6 +93,11 @@ const routes: Routes = [ loadComponent: () => import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent) }, + { + path: 'bor', + data: { title: 'BOR Register — Share Capital', breadcrumb: 'BOR Register' }, + loadComponent: () => import('../board-resolution/bor-report.component').then((m) => m.BorReportComponent) + }, { path: ':filter', data: { routeParamBreadcrumb: 'filter' }, diff --git a/src/app/risk-review/risk-change-approvals.component.ts b/src/app/risk-review/risk-change-approvals.component.ts new file mode 100644 index 000000000..677928e01 --- /dev/null +++ b/src/app/risk-review/risk-change-approvals.component.ts @@ -0,0 +1,401 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Inject, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; + +import { AmlService } from 'app/aml/aml.service'; + +const CATEGORY_LABEL: Record = { 1: 'Low', 2: 'Medium', 3: 'High' }; + +/** + * The approval queue for risk-category changes raised from the Risk Review Report. + * + *

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

+
+
+

Risk Category Approvals

+

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

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

Reject Risk Category Change

+ +

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

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

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

Change Risk Category

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

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

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

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

+ +

{{ errorMessage }}

+
+ + + + + `, + styles: [ + ` + .rc-summary { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 14px; + background: #f8fafc; + } + .rc-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 3px 0; + } + .rc-label { + color: #64748b; + font-size: 12.5px; + } + .rc-value { + color: #1e293b; + font-size: 13.5px; + } + .rc-reason { + font-size: 12.5px; + line-height: 1.5; + color: #334155; + margin: 0 0 14px; + } + .rc-form { + display: flex; + flex-direction: column; + } + .rc-full { + width: 100%; + } + .rc-effect { + font-size: 12.5px; + line-height: 1.55; + color: #1e3a8a; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + padding: 10px 12px; + margin: 4px 0 0; + } + .rc-error { + margin: 12px 0 0; + padding: 10px 12px; + border: 1px solid #fecaca; + border-radius: 6px; + background: #fef2f2; + color: #b91c1c; + font-size: 12.5px; + } + ` + ] +}) +export class RiskChangeDialogComponent { + private fb = inject(FormBuilder); + private amlService = inject(AmlService); + private snackBar = inject(MatSnackBar); + dialogRef = inject(MatDialogRef); + + form: FormGroup; + submitting = false; + errorMessage = ''; + + constructor(@Inject(MAT_DIALOG_DATA) public data: RiskChangeDialogData) { + this.form = this.fb.group({ + // Defaulted to what the report recommends, but left editable: the reviewer has seen the + // customer and the computation has not. + requestedCategory: [ + data.recommendedCategory ?? null, + Validators.required + ], + reason: [ + '', + Validators.required + ] + }); + } + + labelOf(category: number | null): string { + return category === 1 ? 'Low' : category === 2 ? 'Medium' : category === 3 ? 'High' : 'Unrated'; + } + + submit(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + this.submitting = true; + this.errorMessage = ''; + const v = this.form.value; + this.amlService.requestRiskChange(this.data.clientId, v.requestedCategory, (v.reason || '').trim()).subscribe({ + next: () => { + this.submitting = false; + this.snackBar.open( + `Risk change to ${this.labelOf(v.requestedCategory)} sent for approval — ${this.data.customerName}`, + 'Close', + { duration: 5000 } + ); + this.dialogRef.close(true); + }, + error: (err: any) => { + this.submitting = false; + this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send the request.'; + } + }); + } +} diff --git a/src/app/risk-review/risk-review-data.ts b/src/app/risk-review/risk-review-data.ts index 594b9b9fd..fea25ea07 100644 --- a/src/app/risk-review/risk-review-data.ts +++ b/src/app/risk-review/risk-review-data.ts @@ -7,6 +7,7 @@ */ import { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data'; +import { toIsoDate } from '../core/utils/api-date'; /** * Data layer for the Risk Review Report. @@ -17,11 +18,10 @@ import { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data'; * return — it is the internal worklist that feeds the risk re-rating, the * re-KYC and, where warranted, the STR. * - * Rows come from the live rule engine (GET /aml/alerts, mapped by - * `mapLiveAlert`). Where no live alert exists the screen falls back to the - * deterministic sample set below so the report can still be demonstrated; the - * component labels that state explicitly so sample rows are never mistaken for - * live compliance data. + * Rows come from the live rule engine and nowhere else (GET /aml/alerts, mapped + * by `mapLiveAlert`). A period in which the engine raised nothing shows as + * empty: a compliance report must never pad itself with invented customers, + * because every count and total on the screen would then be unactionable. */ // ── Risk rating ────────────────────────────────────────────────────────────── @@ -89,8 +89,7 @@ export const REVIEW_STATUS_META: Record = { export const REVIEW_STATUSES = Object.keys(REVIEW_STATUS_META) as ReviewStatus[]; /** - * Maps the alert engine's own lifecycle onto the review vocabulary above, so a - * live alert and a sample row read the same way in the table. + * Maps the alert engine's own lifecycle onto the review vocabulary above. */ export function reviewStatusFromAlert(status: string, disposition: string | null): ReviewStatus { if (disposition === 'ESCALATE') { @@ -344,7 +343,7 @@ export interface RiskAlertRow { remarks: string; /** The entries the rule actually fired on. */ txns: RiskTxn[]; - /** False for the sample fallback set, so the screen can say so. */ + /** Always true — every row now comes from the live rule engine. */ live: boolean; } @@ -373,13 +372,29 @@ export const MATERIALITY = { * Rules whose seriousness does not depend on the amount. A sanctions match or a * deliberate structuring pattern is grave at any value, so activity being small * is not a reason to bring the rating down. + * + * Matched as prefixes, not as whole codes. The rule set names these by variant — + * STRUCTURING_BELOW_50K, STRUCTURING_BELOW_10L, PEP_ACCOUNT_MONITORING — so an + * exact-match Set never matched any of them and the protection was dead: a + * customer caught structuring could be recommended for downgrade purely because + * the amounts involved were small, which is the one case structuring exists to + * catch. */ -export const NON_MITIGABLE_RULES = new Set([ - 'SANCTIONS_HIT', +export const NON_MITIGABLE_RULE_PREFIXES = [ + 'SANCTIONS', 'PEP', 'STRUCTURING', - 'HIGH_RISK_COUNTRY' -]); + 'STRUCTURED', + 'HIGH_RISK_COUNTRY', + 'ADVERSE_MEDIA', + 'WILFUL_DEFAULTER' +]; + +/** Whether a rule code names activity that stays grave however small the amounts are. */ +export function isNonMitigableRule(ruleCode: string): boolean { + const code = (ruleCode || '').toUpperCase(); + return NON_MITIGABLE_RULE_PREFIXES.some((prefix) => code.startsWith(prefix)); +} /** Shape of one element of GET /aml/alerts. */ export interface LiveAmlAlert { @@ -392,17 +407,23 @@ export interface LiveAmlAlert { ruleDescription: string; amount: number; currencyCode: string; - raisedOn: string; + /** Fineract sends LocalDateTime as [y,m,d,h,mi,s], not a string. */ + raisedOn: number[] | string; status: string; reviewedByUsername: string | null; - reviewedOn: string | null; + reviewedOn: number[] | string | null; reviewNotes: string | null; disposition: string | null; } -/** Trims a LocalDateTime such as 2026-07-14T11:04:22 down to its date part. */ -function isoDayOf(value: string | null | undefined): string { - return (value || '').slice(0, 10); +/** + * Fineract serialises LocalDateTime as an array — raisedOn arrives as + * [2026,9,11,0,0,0], not a string. Slicing that gave back an array rather than + * a date, so every live row carried an unusable alertDate and the period filter + * discarded it: the report read empty while the engine held alerts. + */ +function isoDayOf(value: unknown): string { + return toIsoDate(value); } /** @@ -451,1021 +472,3 @@ export function mapLiveAlert(alert: LiveAmlAlert): RiskAlertRow { live: true }; } - -// ── Deterministic sample set ───────────────────────────────────────────────── - -/** Fixed-seed linear congruential generator — identical figures on every load. */ -function lcg(seed: number): () => number { - let s = seed >>> 0; - return () => { - s = (Math.imul(s, 1664525) + 1013904223) >>> 0; - return s / 4294967296; - }; -} - -function pick(rand: () => number, list: T[]): T { - return list[Math.floor(rand() * list.length)]; -} - -function isoDate(d: Date): string { - const y = d.getUTCFullYear(); - const m = `${d.getUTCMonth() + 1}`.padStart(2, '0'); - const day = `${d.getUTCDate()}`.padStart(2, '0'); - return `${y}-${m}-${day}`; -} - -const SAMPLE_NAMES = [ - 'Raj Kumar Sharma', - 'Meenakshi Iyer', - 'Abdul Rahman Shaikh', - 'Sunita Deshpande', - 'Harpreet Singh Bedi', - 'Farah Qureshi', - 'Vikram Rathod', - 'Lakshmi Narayanan', - 'Imran Sayyed', - 'Anjali Kulkarni', - 'Devendra Patil', - 'Rukhsana Begum', - 'Sanjay Gupta', - 'Priyanka Joshi', - 'Mohammed Arif Khan', - 'Nandini Rao', - 'Gopal Krishna Verma', - 'Shabana Ansari', - 'Ramesh Chandra Pandey', - 'Kavita Bhosale', - 'Yusuf Merchant', - 'Deepak Talwar', - 'Sneha Chatterjee', - 'Balwinder Kaur', - 'Aditya Mehrotra', - 'Zainab Lokhandwala', - 'Prakash Salunkhe', - 'Nusrat Jahan Sheikh', - 'Mahesh Waghmare', - 'Ritu Aggarwal', - 'Sadiq Hussain Momin', - 'Vaishali Gaikwad', - 'Tarun Bhatia', - 'Fatima Bano Ansari', - 'Suresh Jadhav', - 'Neelam Chauhan', - 'Iqbal Ahmed Pathan', - 'Girish Kamat', - 'Poonam Wadhwa', - 'Ashok Kumar Tiwari' -]; - -/** Occupation-ish trading names, used for the entity customers in the set. */ -const SAMPLE_ENTITIES = [ - 'Sai Enterprises', - 'Konkan Traders', - 'Nashik Agro Exports', - 'Vasai Metal Works', - 'Sunrise Jewellers', - 'Bhagyalakshmi Cotton Mills', - 'Om Sai Scrap & Metals', - 'Deccan Poultry Farms', - 'Mira Road Builders LLP', - 'Panvel Cold Storage Pvt Ltd', - 'Sharda Bullion House', - 'Western Cargo Movers' -]; - -/** Counterparty pools, so each rule's entries read like that rule. */ -const COUNTERPARTIES: Record = { - CASH: [ - 'Cash counter — Teller 03', - 'Cash counter — Teller 01', - 'Cash counter — Teller 07', - 'Cash recycler — Lobby' - ], - DOMESTIC: [ - 'RAMESH TRADING CO — HDFC0000123', - 'S K ENTERPRISES — ICIC0001847', - 'M/s VARDHMAN AGENCIES — SBIN0004512', - 'A R LOGISTICS — UTIB0000276', - 'GLOBAL IMPEX — KKBK0000958', - 'P N DISTRIBUTORS — BARB0PUNENA', - 'SHREE SALES CORP — PUNB0233800' - ], - RETAIL: [ - 'rahul.mehta@okaxis', - 'sk.traders@ybl', - 'anita9821@paytm', - 'jsons.retail@ibl', - 'quickmart.pos@okhdfcbank' - ], - CROSS_BORDER: [ - 'AL NAHDA GEN TRADING LLC — Dubai, AE', - 'PACIFIC RIM EXPORTS PTE — Singapore, SG', - 'ORIENT STAR TRADING — Hong Kong, HK', - 'DELTA COMMODITIES LTD — Lagos, NG', - 'HORIZON GULF FZE — Sharjah, AE' - ] -}; - -/** Statement narrations, keyed by rule so the entry explains the alert. */ -const NARRATIONS: Record = { - LARGE_CASH_TXN: [ - 'CASH DEP — self', - 'CASH DEP — sale proceeds', - 'CASH WDL — self', - 'CASH WDL — business payment' - ], - STRUCTURING: [ - 'CASH DEP — self', - 'CASH DEP — by third party', - 'CASH DEP — counter' - ], - HIGH_RISK_COUNTRY: [ - 'INW REM — advance against export', - 'OUT REM — import advance', - 'INW REM — consultancy fees' - ], - DORMANT_ACTIVATION: [ - 'NEFT IN — reactivation credit', - 'RTGS OUT — transfer to third party', - 'TRF — to linked account' - ], - UNUSUAL_PATTERN: [ - 'NEFT IN — business receipt', - 'IMPS IN — unknown remitter', - 'CASH DEP — self' - ], - VOLUME_SPIKE: [ - 'UPI CR — collection', - 'IMPS CR — collection', - 'NEFT IN — receipt' - ], - THIRD_PARTY: [ - 'IMPS CR — third party', - 'UPI CR — third party', - 'TRF CR — third party' - ], - RAPID_MOVEMENT: [ - 'RTGS IN — large credit', - 'RTGS OUT — same-day outflow', - 'NEFT OUT — onward transfer' - ], - CROSS_BORDER: [ - 'INW REM — family maintenance', - 'OUT REM — LRS remittance', - 'INW REM — export proceeds' - ], - PEP: [ - 'NEFT IN — receipt', - 'RTGS IN — receipt', - 'CASH DEP — self' - ], - SANCTIONS_HIT: [ - 'OUT REM — beneficiary screening hit', - 'RTGS OUT — counterparty screening hit' - ], - CASH_INTENSIVE: [ - 'CASH DEP — daily collection', - 'CASH WDL — wages', - 'CASH DEP — counter sales' - ] -}; - -function counterpartyFor(rand: () => number, channel: string): string { - if (channel.startsWith('CASH')) { - return pick(rand, COUNTERPARTIES['CASH']); - } - if (channel.includes('REMITTANCE')) { - return pick(rand, COUNTERPARTIES['CROSS_BORDER']); - } - if (channel === 'UPI' || channel === 'IMPS') { - return pick(rand, COUNTERPARTIES['RETAIL']); - } - return pick(rand, COUNTERPARTIES['DOMESTIC']); -} - -const RULE_CHANNELS: Record = { - LARGE_CASH_TXN: [ - 'CASH_DEPOSIT', - 'CASH_WITHDRAWAL' - ], - STRUCTURING: ['CASH_DEPOSIT'], - HIGH_RISK_COUNTRY: [ - 'INWARD_REMITTANCE', - 'OUTWARD_REMITTANCE' - ], - DORMANT_ACTIVATION: [ - 'NEFT', - 'RTGS', - 'TRANSFER' - ], - UNUSUAL_PATTERN: [ - 'NEFT', - 'IMPS', - 'CASH_DEPOSIT' - ], - VOLUME_SPIKE: [ - 'UPI', - 'IMPS', - 'NEFT' - ], - THIRD_PARTY: [ - 'IMPS', - 'UPI', - 'TRANSFER' - ], - RAPID_MOVEMENT: [ - 'RTGS', - 'NEFT' - ], - CROSS_BORDER: [ - 'INWARD_REMITTANCE', - 'OUTWARD_REMITTANCE' - ], - PEP: [ - 'NEFT', - 'RTGS', - 'CASH_DEPOSIT' - ], - SANCTIONS_HIT: [ - 'OUTWARD_REMITTANCE', - 'RTGS' - ], - CASH_INTENSIVE: [ - 'CASH_DEPOSIT', - 'CASH_WITHDRAWAL' - ] -}; - -const SAMPLE_REMARKS: Record = { - PENDING: [''], - IN_REVIEW: [ - 'Source of funds documents called for from the branch.', - 'Awaiting the customer’s explanation of the counterparty relationship.' - ], - RISK_RETAINED: [ - 'Sale proceeds of agricultural land; sale deed produced and verified. Rating unchanged.', - 'Regular business receipts consistent with the declared turnover. No further action.', - 'One-off receipt on maturity of a term deposit held with us. Satisfied.' - ], - RISK_UPGRADED: [ - 'Cash turnover ran to several times the declared income. Rating moved to High; review in six months.', - 'Counterparties could not be explained. Moved to High risk and placed under monthly monitoring.' - ], - KYC_UPDATE: [ - 'Occupation and income on record are stale. Re-KYC called for before further large credits.', - 'Address proof over three years old; customer asked to complete periodic updation.' - ], - ESCALATED_STR: [ - 'Deposits kept just under ₹10 lakh across five days. Referred to the Principal Officer.', - 'Account operated as a pass-through with no retained balance. Referred for STR consideration.' - ], - STR_FILED: [ - 'STR filed with FIU-IND within the seven working day limit. Account under continued watch.', - 'STR filed; relationship placed under senior management review.' - ] -}; - -const SAMPLE_REVIEWERS = [ - 'amlofficer', - 'compliance.mgr', - 'po.mumbai', - '' -]; - -/** How many entries the engine strung together for this rule. */ -function legCountFor(rand: () => number, ruleCode: string): number { - switch (ruleCode) { - case 'STRUCTURING': - return 4 + Math.floor(rand() * 9); - case 'VOLUME_SPIKE': - case 'THIRD_PARTY': - return 3 + Math.floor(rand() * 6); - case 'RAPID_MOVEMENT': - // A large credit and the outflow that empties it the same day. - return 2; - case 'CASH_INTENSIVE': - return 2 + Math.floor(rand() * 4); - default: - return 1; - } -} - -/** Amount for a single entry, in the band that makes the rule fire. */ -function legAmount(rand: () => number, ruleCode: string): number { - switch (ruleCode) { - case 'LARGE_CASH_TXN': - return Math.round((10 + rand() * 22) * 10000) * 10; - case 'STRUCTURING': - // Each leg deliberately parked just under the ₹10 lakh CTR limit. - return Math.round((8.6 + rand() * 1.3) * 10000) * 10; - case 'SANCTIONS_HIT': - case 'HIGH_RISK_COUNTRY': - case 'CROSS_BORDER': - return Math.round((4 + rand() * 40) * 10000) * 10; - case 'RAPID_MOVEMENT': - return Math.round((15 + rand() * 60) * 10000) * 10; - case 'VOLUME_SPIKE': - case 'THIRD_PARTY': - return Math.round((0.6 + rand() * 4) * 10000) * 10; - case 'CASH_INTENSIVE': - return Math.round((2 + rand() * 7) * 10000) * 10; - default: - return Math.round((1 + rand() * 18) * 10000) * 10; - } -} - -/** - * Builds the entries behind one alert, working backwards from the alert date. - * - * The alert amount is the sum of these legs rather than an independent figure, - * so the transaction view and the alert view reconcile to the rupee. - */ -function buildTxns(rand: () => number, ruleCode: string, channel: TxnChannel, alertDay: Date, seq: number): RiskTxn[] { - const legs = legCountFor(rand, ruleCode); - const narrations = NARRATIONS[ruleCode] || ['TRF — entry']; - // Pattern rules accumulate over a window; a single-entry rule fires on the day. - const windowDays = legs === 1 ? 0 : ruleCode === 'RAPID_MOVEMENT' ? 0 : 4 + Math.floor(rand() * 20); - - let balance = Math.round((2 + rand() * 25) * 100000); - const out: RiskTxn[] = []; - - for (let i = 0; i < legs; i++) { - // Legs run oldest-first up to the alert date. - const back = legs === 1 ? 0 : Math.round(((legs - 1 - i) / Math.max(legs - 1, 1)) * windowDays); - const date = isoDate(new Date(alertDay.getTime() - back * 86400000)); - - let amount = legAmount(rand, ruleCode); - let drCr: 'DR' | 'CR'; - let legChannel: TxnChannel = channel; - - if (ruleCode === 'RAPID_MOVEMENT') { - // Credit first, then the same money leaving less a token residue. - drCr = i === 0 ? 'CR' : 'DR'; - if (i === 1) { - amount = Math.round(out[0].amount * (0.93 + rand() * 0.06)); - legChannel = rand() < 0.5 ? 'RTGS' : 'NEFT'; - } - } else if (channel === 'CASH_WITHDRAWAL' || channel === 'OUTWARD_REMITTANCE') { - drCr = 'DR'; - } else if (ruleCode === 'CASH_INTENSIVE') { - drCr = rand() < 0.7 ? 'CR' : 'DR'; - legChannel = drCr === 'CR' ? 'CASH_DEPOSIT' : 'CASH_WITHDRAWAL'; - } else { - drCr = 'CR'; - } - - balance = drCr === 'CR' ? balance + amount : Math.max(balance - amount, 1200); - - out.push({ - txnId: `T${String(seq).padStart(5, '0')}${String(i + 1).padStart(2, '0')}`, - date, - channel: legChannel, - drCr, - amount, - narration: pick(rand, narrations), - counterparty: counterpartyFor(rand, legChannel), - balanceAfter: balance, - ctrReportable: legChannel.startsWith('CASH') && amount >= CTR_THRESHOLD - }); - } - - return out; -} - -// ── Hand-written showcase cases ────────────────────────────────────────────── - -/** A leg of a showcase case, before the running balance is worked out. */ -interface ShowcaseLeg { - date: string; - channel: TxnChannel; - drCr: 'DR' | 'CR'; - amount: number; - narration: string; - counterparty: string; -} - -interface ShowcaseCase { - clientId: number; - name: string; - branchCode: string; - accountNo: string; - currentRisk: RiskLevel; - ruleCode: string; - alertDate: string; - status: ReviewStatus; - reviewedBy: string; - remarks: string; - openingBalance: number; - legs: ShowcaseLeg[]; -} - -/** Repeats a leg n times on consecutive days — used for the structuring series. */ -function repeatLegs(n: number, startDate: string, leg: Omit): ShowcaseLeg[] { - const start = new Date(`${startDate}T00:00:00Z`); - return Array.from({ length: n }, (_, i) => ({ - ...leg, - date: isoDate(new Date(start.getTime() + i * 86400000)) - })); -} - -/** - * Cases written out by hand rather than generated, so the report opens on a set - * of recognisable, textbook examples — one per headline rule, with the figures - * and the reviewer's reasoning spelled out. These sit at the top of the sample - * set; the generated rows below fill out the volume. - */ -const SHOWCASE_CASES: ShowcaseCase[] = [ - { - clientId: 80001, - name: 'Raj Kumar Sharma', - branchCode: '0002', - accountNo: '000200041501', - currentRisk: 'MEDIUM', - ruleCode: 'LARGE_CASH_TXN', - alertDate: '2026-07-28', - status: 'PENDING', - reviewedBy: '', - remarks: '', - openingBalance: 284500, - legs: [ - { - date: '2026-07-28', - channel: 'CASH_DEPOSIT', - drCr: 'CR', - amount: 1500000, - narration: 'CASH DEP — self', - counterparty: 'Cash counter — Teller 03' - } - ] - }, - { - clientId: 80002, - name: 'Meenakshi Iyer', - branchCode: '0003', - accountNo: '000300041538', - currentRisk: 'LOW', - ruleCode: 'STRUCTURING', - alertDate: '2026-07-24', - status: 'ESCALATED_STR', - reviewedBy: 'amlofficer', - remarks: - 'Fifteen deposits of ₹90,000 on consecutive days — none individually reportable, ₹13,50,000 in aggregate. ' + - 'Customer could not explain the source. Referred to the Principal Officer as threshold avoidance.', - openingBalance: 61200, - legs: repeatLegs(15, '2026-07-10', { - channel: 'CASH_DEPOSIT', - drCr: 'CR', - amount: 90000, - narration: 'CASH DEP — self', - counterparty: 'Cash counter — Teller 01' - }) - }, - { - clientId: 80003, - name: 'Sunrise Jewellers', - branchCode: '0004', - accountNo: '000400041575', - currentRisk: 'MEDIUM', - ruleCode: 'HIGH_RISK_COUNTRY', - alertDate: '2026-07-19', - status: 'IN_REVIEW', - reviewedBy: 'compliance.mgr', - remarks: 'Import documents and the FEMA purpose code called for from the branch. Beneficiary being screened.', - openingBalance: 1840000, - legs: [ - { - date: '2026-07-19', - channel: 'INWARD_REMITTANCE', - drCr: 'CR', - amount: 4200000, - narration: 'INW REM — advance against export', - counterparty: 'AL NAHDA GEN TRADING LLC — Dubai, AE' - } - ] - }, - { - clientId: 80004, - name: 'Gopal Krishna Verma', - branchCode: '0005', - accountNo: '000500041612', - currentRisk: 'LOW', - ruleCode: 'DORMANT_ACTIVATION', - alertDate: '2026-07-16', - status: 'RISK_UPGRADED', - reviewedBy: 'amlofficer', - remarks: - 'Account dormant since Sep 2023, reactivated and ₹28,00,000 moved out over four days. ' + - 'Customer re-verified in person; rating moved Low → High and placed under monthly monitoring.', - openingBalance: 4820, - legs: [ - { - date: '2026-07-13', - channel: 'NEFT', - drCr: 'CR', - amount: 2800000, - narration: 'NEFT IN — reactivation credit', - counterparty: 'GLOBAL IMPEX — KKBK0000958' - }, - { - date: '2026-07-14', - channel: 'RTGS', - drCr: 'DR', - amount: 1200000, - narration: 'RTGS OUT — transfer to third party', - counterparty: 'A R LOGISTICS — UTIB0000276' - }, - { - date: '2026-07-15', - channel: 'RTGS', - drCr: 'DR', - amount: 900000, - narration: 'RTGS OUT — transfer to third party', - counterparty: 'S K ENTERPRISES — ICIC0001847' - }, - { - date: '2026-07-16', - channel: 'RTGS', - drCr: 'DR', - amount: 690000, - narration: 'RTGS OUT — transfer to third party', - counterparty: 'SHREE SALES CORP — PUNB0233800' - } - ] - }, - { - clientId: 80005, - name: 'Vikram Rathod', - branchCode: '0002', - accountNo: '000200041649', - currentRisk: 'LOW', - ruleCode: 'UNUSUAL_PATTERN', - alertDate: '2026-07-22', - status: 'KYC_UPDATE', - reviewedBy: 'compliance.mgr', - remarks: - 'Declared income at onboarding ₹4,80,000 p.a.; credits of ₹67,00,000 in the quarter. ' + - 'Occupation on record is stale — re-KYC called for before any further large credit is released.', - openingBalance: 132000, - legs: [ - { - date: '2026-07-06', - channel: 'NEFT', - drCr: 'CR', - amount: 2450000, - narration: 'NEFT IN — business receipt', - counterparty: 'RAMESH TRADING CO — HDFC0000123' - }, - { - date: '2026-07-14', - channel: 'NEFT', - drCr: 'CR', - amount: 1980000, - narration: 'NEFT IN — business receipt', - counterparty: 'M/s VARDHMAN AGENCIES — SBIN0004512' - }, - { - date: '2026-07-22', - channel: 'CASH_DEPOSIT', - drCr: 'CR', - amount: 2270000, - narration: 'CASH DEP — self', - counterparty: 'Cash counter — Teller 07' - } - ] - }, - { - clientId: 80006, - name: 'Om Sai Scrap & Metals', - branchCode: '0006', - accountNo: '000600041686', - currentRisk: 'MEDIUM', - ruleCode: 'RAPID_MOVEMENT', - alertDate: '2026-07-09', - status: 'STR_FILED', - reviewedBy: 'po.mumbai', - remarks: - '₹55,00,000 received and ₹53,80,000 moved on the same day, leaving ₹1,20,000. No trading activity to support it. ' + - 'STR filed with FIU-IND on 15-Jul-2026 within the seven working day limit.', - openingBalance: 98000, - legs: [ - { - date: '2026-07-09', - channel: 'RTGS', - drCr: 'CR', - amount: 5500000, - narration: 'RTGS IN — large credit', - counterparty: 'ORIENT STAR TRADING — Hong Kong, HK' - }, - { - date: '2026-07-09', - channel: 'RTGS', - drCr: 'DR', - amount: 5380000, - narration: 'RTGS OUT — same-day outflow', - counterparty: 'P N DISTRIBUTORS — BARB0PUNENA' - } - ] - }, - { - clientId: 80007, - name: 'Anjali Kulkarni', - branchCode: '0003', - accountNo: '000300041723', - currentRisk: 'LOW', - ruleCode: 'THIRD_PARTY', - alertDate: '2026-07-30', - status: 'PENDING', - reviewedBy: '', - remarks: '', - openingBalance: 47300, - legs: [ - { - date: '2026-07-25', - channel: 'IMPS', - drCr: 'CR', - amount: 148000, - narration: 'IMPS CR — third party', - counterparty: 'rahul.mehta@okaxis' - }, - { - date: '2026-07-26', - channel: 'UPI', - drCr: 'CR', - amount: 96500, - narration: 'UPI CR — third party', - counterparty: 'sk.traders@ybl' - }, - { - date: '2026-07-27', - channel: 'IMPS', - drCr: 'CR', - amount: 172000, - narration: 'IMPS CR — third party', - counterparty: 'anita9821@paytm' - }, - { - date: '2026-07-28', - channel: 'UPI', - drCr: 'CR', - amount: 88000, - narration: 'UPI CR — third party', - counterparty: 'jsons.retail@ibl' - }, - { - date: '2026-07-29', - channel: 'IMPS', - drCr: 'CR', - amount: 205000, - narration: 'IMPS CR — third party', - counterparty: 'quickmart.pos@okhdfcbank' - }, - { - date: '2026-07-30', - channel: 'UPI', - drCr: 'CR', - amount: 134500, - narration: 'UPI CR — third party', - counterparty: 'sk.traders@ybl' - } - ] - }, - { - clientId: 80008, - name: 'Deepak Talwar', - branchCode: '0004', - accountNo: '000400041760', - currentRisk: 'HIGH', - ruleCode: 'SANCTIONS_HIT', - alertDate: '2026-07-31', - status: 'ESCALATED_STR', - reviewedBy: 'po.mumbai', - remarks: - 'Beneficiary returned a match on the designated-entity list. Remittance held and not released. ' + - 'Match reported and the relationship referred for senior management review.', - openingBalance: 3120000, - legs: [ - { - date: '2026-07-31', - channel: 'OUTWARD_REMITTANCE', - drCr: 'DR', - amount: 1800000, - narration: 'OUT REM — beneficiary screening hit', - counterparty: 'DELTA COMMODITIES LTD — Lagos, NG' - } - ] - }, - { - clientId: 80009, - name: 'Nashik Agro Exports', - branchCode: '0005', - accountNo: '000500041797', - currentRisk: 'MEDIUM', - ruleCode: 'CASH_INTENSIVE', - alertDate: '2026-07-27', - status: 'RISK_RETAINED', - reviewedBy: 'amlofficer', - remarks: - 'Cash collections match the mandi receipts and the declared seasonal turnover for a produce exporter. ' + - 'Documents verified at the branch; rating unchanged, review again after the next harvest cycle.', - openingBalance: 760000, - legs: [ - { - date: '2026-07-21', - channel: 'CASH_DEPOSIT', - drCr: 'CR', - amount: 640000, - narration: 'CASH DEP — daily collection', - counterparty: 'Cash counter — Teller 03' - }, - { - date: '2026-07-23', - channel: 'CASH_DEPOSIT', - drCr: 'CR', - amount: 585000, - narration: 'CASH DEP — daily collection', - counterparty: 'Cash recycler — Lobby' - }, - { - date: '2026-07-25', - channel: 'CASH_WITHDRAWAL', - drCr: 'DR', - amount: 410000, - narration: 'CASH WDL — wages', - counterparty: 'Cash counter — Teller 01' - }, - { - date: '2026-07-27', - channel: 'CASH_DEPOSIT', - drCr: 'CR', - amount: 712000, - narration: 'CASH DEP — counter sales', - counterparty: 'Cash counter — Teller 07' - } - ] - }, - { - // Rated High, but the period's activity is trivial — the review should bring - // the rating down rather than leave it where an old alert put it. - clientId: 80011, - name: 'Balwinder Kaur', - branchCode: '0002', - accountNo: '000200041871', - currentRisk: 'HIGH', - ruleCode: 'THIRD_PARTY', - alertDate: '2026-07-26', - status: 'RISK_RETAINED', - reviewedBy: 'amlofficer', - remarks: - 'Rated High in 2024 after a one-off remittance since fully explained. Since then the account has only ' + - 'carried small UPI receipts from her tenants. Nothing in the period supports a High rating.', - openingBalance: 41800, - legs: [ - { - date: '2026-07-08', - channel: 'UPI', - drCr: 'CR', - amount: 18500, - narration: 'UPI CR — third party', - counterparty: 'rahul.mehta@okaxis' - }, - { - date: '2026-07-14', - channel: 'UPI', - drCr: 'CR', - amount: 22000, - narration: 'UPI CR — third party', - counterparty: 'anita9821@paytm' - }, - { - date: '2026-07-20', - channel: 'IMPS', - drCr: 'CR', - amount: 27500, - narration: 'IMPS CR — third party', - counterparty: 'jsons.retail@ibl' - }, - { - date: '2026-07-26', - channel: 'UPI', - drCr: 'CR', - amount: 19800, - narration: 'UPI CR — third party', - counterparty: 'sk.traders@ybl' - } - ] - }, - { - // Rated High, activity moderate rather than trivial — comes down one step. - clientId: 80012, - name: 'Sanjay Gupta', - branchCode: '0003', - accountNo: '000300041908', - currentRisk: 'HIGH', - ruleCode: 'VOLUME_SPIKE', - alertDate: '2026-07-23', - status: 'IN_REVIEW', - reviewedBy: 'compliance.mgr', - remarks: - 'Rated High during a business expansion two years ago. Turnover has settled to a steady ₹6–7 lakh a month, ' + - 'in line with the declared trade. Enhanced monitoring no longer proportionate.', - openingBalance: 218000, - legs: [ - { - date: '2026-07-09', - channel: 'NEFT', - drCr: 'CR', - amount: 245000, - narration: 'NEFT IN — receipt', - counterparty: 'RAMESH TRADING CO — HDFC0000123' - }, - { - date: '2026-07-16', - channel: 'NEFT', - drCr: 'CR', - amount: 198000, - narration: 'NEFT IN — receipt', - counterparty: 'SHREE SALES CORP — PUNB0233800' - }, - { - date: '2026-07-23', - channel: 'IMPS', - drCr: 'CR', - amount: 227000, - narration: 'IMPS CR — collection', - counterparty: 'quickmart.pos@okhdfcbank' - } - ] - }, - { - clientId: 80010, - name: 'Farah Qureshi', - branchCode: '0006', - accountNo: '000600041834', - currentRisk: 'LOW', - ruleCode: 'VOLUME_SPIKE', - alertDate: '2026-07-29', - status: 'IN_REVIEW', - reviewedBy: 'amlofficer', - remarks: 'Monthly volume ran to eleven times the twelve-month average. Branch asked for the business reason.', - openingBalance: 88400, - legs: [ - { - date: '2026-07-18', - channel: 'UPI', - drCr: 'CR', - amount: 264000, - narration: 'UPI CR — collection', - counterparty: 'quickmart.pos@okhdfcbank' - }, - { - date: '2026-07-22', - channel: 'IMPS', - drCr: 'CR', - amount: 318000, - narration: 'IMPS CR — collection', - counterparty: 'jsons.retail@ibl' - }, - { - date: '2026-07-26', - channel: 'NEFT', - drCr: 'CR', - amount: 407000, - narration: 'NEFT IN — receipt', - counterparty: 'SHREE SALES CORP — PUNB0233800' - }, - { - date: '2026-07-29', - channel: 'UPI', - drCr: 'CR', - amount: 292000, - narration: 'UPI CR — collection', - counterparty: 'sk.traders@ybl' - } - ] - } -]; - -function buildShowcaseAlerts(): RiskAlertRow[] { - return SHOWCASE_CASES.map((c, i) => { - let balance = c.openingBalance; - const txns: RiskTxn[] = c.legs.map((leg, j) => { - balance = leg.drCr === 'CR' ? balance + leg.amount : Math.max(balance - leg.amount, 0); - return { - txnId: `T9${String(i + 1).padStart(2, '0')}${String(j + 1).padStart(2, '0')}`, - date: leg.date, - channel: leg.channel, - drCr: leg.drCr, - amount: leg.amount, - narration: leg.narration, - counterparty: leg.counterparty, - balanceAfter: balance, - ctrReportable: leg.channel.startsWith('CASH') && leg.amount >= CTR_THRESHOLD - }; - }); - - const rule = amlRuleMeta(c.ruleCode); - return { - alertId: `RR9${String(i + 1).padStart(4, '0')}`, - clientId: c.clientId, - customerName: c.name, - accountNo: c.accountNo, - branchCode: c.branchCode, - currentRisk: c.currentRisk, - ruleCode: c.ruleCode, - ruleDescription: rule.description, - channel: c.legs[0].channel, - amount: txns.reduce((s, t) => s + t.amount, 0), - currencyCode: 'INR', - txnCount: txns.length, - alertDate: c.alertDate, - status: c.status, - reviewedBy: c.reviewedBy, - remarks: c.remarks, - txns, - live: false - }; - }); -} - -function buildSampleAlerts(): RiskAlertRow[] { - const rand = lcg(20260801); - const branches = GL_BRANCHES.filter((b) => b.code !== 'HO'); - const start = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`); - const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`); - const spanDays = Math.round((end.getTime() - start.getTime()) / 86400000); - - const customers = [ - ...SAMPLE_NAMES, - ...SAMPLE_ENTITIES - ].map((name, i) => ({ - clientId: 90001 + i, - name, - branchCode: branches[i % branches.length].code, - accountNo: `${branches[i % branches.length].code}${String(41500 + i * 37).padStart(8, '0')}`, - // Rating on the KYC record today — most customers sit at Low or Medium - // before the review; that is the point of running the report. - currentRisk: (rand() < 0.55 ? 'LOW' : rand() < 0.78 ? 'MEDIUM' : 'HIGH') as RiskLevel - })); - - // The hand-written cases lead; the generated ones fill out the volume behind them. - const rows: RiskAlertRow[] = buildShowcaseAlerts(); - let seq = 1; - - for (const cust of customers) { - const alertCount = 1 + Math.floor(rand() * 3); - for (let n = 0; n < alertCount; n++) { - const rule = pick(rand, AML_RULE_CATALOGUE); - const channel = pick(rand, RULE_CHANNELS[rule.code] ?? (['TRANSFER'] as TxnChannel[])); - const day = new Date(start.getTime() + Math.floor(rand() * spanDays) * 86400000); - - // Older alerts have mostly been dealt with; recent ones are still open. - const ageDays = Math.round((end.getTime() - day.getTime()) / 86400000); - let status: ReviewStatus; - if (ageDays < 12) { - status = rand() < 0.6 ? 'PENDING' : 'IN_REVIEW'; - } else if (rule.impliedRisk === 'HIGH') { - const r = rand(); - status = r < 0.28 ? 'ESCALATED_STR' : r < 0.46 ? 'STR_FILED' : r < 0.72 ? 'RISK_UPGRADED' : 'RISK_RETAINED'; - } else { - const r = rand(); - status = r < 0.42 ? 'RISK_RETAINED' : r < 0.64 ? 'KYC_UPDATE' : r < 0.82 ? 'RISK_UPGRADED' : 'IN_REVIEW'; - } - - const txns = buildTxns(rand, rule.code, channel, day, seq); - - rows.push({ - alertId: `RR${String(seq++).padStart(5, '0')}`, - clientId: cust.clientId, - customerName: cust.name, - accountNo: cust.accountNo, - branchCode: cust.branchCode, - currentRisk: cust.currentRisk, - ruleCode: rule.code, - ruleDescription: rule.description, - channel, - // Derived from the entries so the two views reconcile. - amount: txns.reduce((s, t) => s + t.amount, 0), - currencyCode: 'INR', - txnCount: txns.length, - alertDate: isoDate(day), - status, - reviewedBy: REVIEW_STATUS_META[status].open ? '' : pick(rand, SAMPLE_REVIEWERS) || 'amlofficer', - remarks: pick(rand, SAMPLE_REMARKS[status]), - txns, - live: false - }); - } - } - - return rows.sort((a, b) => b.alertDate.localeCompare(a.alertDate)); -} - -let sampleCache: RiskAlertRow[] | null = null; - -/** The fallback set, built once and reused. */ -export function sampleRiskAlerts(): RiskAlertRow[] { - if (!sampleCache) { - sampleCache = buildSampleAlerts(); - } - return sampleCache; -} diff --git a/src/app/risk-review/risk-review-report.component.html b/src/app/risk-review/risk-review-report.component.html index 159139988..9bef269f2 100644 --- a/src/app/risk-review/risk-review-report.component.html +++ b/src/app/risk-review/risk-review-report.component.html @@ -27,18 +27,6 @@ - @if (sampleCount) { -
- - - {{ sampleCount }} demonstration rows are included alongside - {{ hasLive ? 'the live alerts' : 'the live feed, which has raised nothing' }}. Sample rows carry a - Sample tag in the table and must not be acted on as compliance data. - - -
- } -
@@ -162,33 +150,62 @@ } @else {
-
+ +
-
+ +
-
+
-
+ +
+
@@ -228,148 +245,205 @@ @if (view === 'customer') { -
- - - - - - - - - - - - - - - - - - @for (c of customerRows; track c.clientId) { - - - - - - - - - - - - - + + @for (g of customerGroups; track g.key) { +
+ - @if (expanded === c.clientId) { -
- + } - - - } - } @empty { - - - - } - -
Customer IDCustomer NameAccount NumberBranchCurrent RiskAlertsAML Rules TriggeredAmount under reviewLast AlertPosition
- - {{ c.clientId }} - {{ c.customerName }} - @if (c.openCount > 0) { - {{ c.openCount }} open - } - {{ c.accountNo || '—' }}{{ c.branchCode || '—' }} - {{ riskText(c.currentRisk) }} - {{ c.alertCount }} - @for (label of c.ruleLabels; track label) { - {{ label }} - } - {{ c.totalAmount | number: '1.0-0' }}{{ c.lastAlertDate }} - {{ verdictText(c) }} - @if (c.directionReason) { -
{{ c.directionReason }}
- } -
- - @for (a of c.alerts; track a.alertId) { -
-
- {{ a.rule.label }} - {{ a.rule.category }} - {{ a.alertId }} · raised {{ a.alertDate }} - - ₹{{ a.amount | number: '1.0-0' }} - {{ statusMeta[a.status].label }} -
-
- - Why it triggered: {{ a.rule.description }} — {{ txnDetail(a) }} -
+ @if (!collapsedGroups[g.key]) { +
+ + + + + + + + + + + + + + + + + + + @for (c of g.rows; track c.clientId) { + + + + + + + + + + + + + + + + -
Customer IDCustomer NameAccount NumberBranchRating on fileReview saysAlertsAML Rules TriggeredAmount under reviewLast AlertAction
+ + {{ c.clientId }} + {{ c.customerName }} + @if (c.openCount > 0) { + {{ c.openCount }} open + } + {{ c.accountNo || '—' }}{{ c.branchCode || '—' }} + {{ riskText(c.currentRisk) }} + + + + + {{ directionText(c) }} + + @if (c.directionReason) { +
{{ c.directionReason }}
+ } +
{{ c.alertCount }} + @for (label of c.ruleLabels; track label) { + {{ label }} + } + {{ c.totalAmount | number: '1.0-0' }}{{ c.lastAlertDate }} + + @if (canChangeRisk(c)) { + + } @else { + + + } +
- - - - - - - - - - - - - - - @for (t of a.txns; track t.txnId) { - - - - - - + +
Txn DateTxn IDChannelDr/CrAmountBalance AfterNarrationCounterpartyAML Rule Triggered
{{ t.date }}{{ t.txnId }} - {{ channelLabel(t.channel) }} - {{ t.drCr }} - {{ t.amount | number: '1.0-0' }} - @if (t.ctrReportable) { - CTR + @if (expanded === c.clientId) { +
+ + @for (a of c.alerts; track a.alertId) { +
+
+ {{ a.rule.label }} + {{ a.rule.category }} + {{ a.alertId }} · raised {{ a.alertDate }} + + ₹{{ a.amount | number: '1.0-0' }} + {{ statusMeta[a.status].label }} +
+
+ + Why it triggered: {{ a.rule.description }} — + {{ txnDetail(a) }} +
+ + + + + + + + + + + + + + + + + @for (t of a.txns; track t.txnId) { + + + + + + + + + + + } - - - - - - - } - -
Txn DateTxn IDChannelDr/CrAmountBalance AfterNarrationCounterpartyAML Rule Triggered
{{ t.date }}{{ t.txnId }} + {{ channelLabel(t.channel) }} + {{ t.drCr }} + {{ t.amount | number: '1.0-0' }} + @if (t.ctrReportable) { + CTR + } + + {{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }} + {{ t.narration || '—' }}{{ t.counterparty || '—' }} + {{ a.rule.label }} + @if (a.txns.length > 1) { + leg {{ $index + 1 }} of {{ a.txns.length }} + } +
{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}{{ t.narration || '—' }}{{ t.counterparty || '—' }} - {{ a.rule.label }} - @if (a.txns.length > 1) { - leg {{ $index + 1 }} of {{ a.txns.length }} - } -
+
-
-
- {{ a.rule.suggestedAction }} -
-
- Reviewer: {{ a.reviewedBy || 'not yet assigned' }} — - {{ a.remarks || 'no remarks recorded' }} -
-
-
+
+
+ {{ a.rule.suggestedAction }} +
+
+ Reviewer: {{ a.reviewedBy || 'not yet assigned' }} — + {{ a.remarks || 'no remarks recorded' }} +
+
+
+ } +
- No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}. -
-
+ } + + +
+ } +
+ } @empty { +
No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
+ } } @@ -400,9 +474,6 @@ {{ a.clientId }} {{ a.customerName }} - @if (!a.live) { - Sample - } {{ a.accountNo || '—' }} {{ a.branchCode || '—' }} diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss index aacb799ff..db236d881 100644 --- a/src/app/risk-review/risk-review-report.component.scss +++ b/src/app/risk-review/risk-review-report.component.scss @@ -139,12 +139,6 @@ span { flex: 1; } - - &--sample { - background: var(--r-amber-l); - border: 1px solid #fcd34d; - color: #78350f; - } } /* FILTERS */ @@ -292,12 +286,61 @@ margin-bottom: 14px; } +/* The verdict, on the row. Colour carries the same meaning as the section it sits under, so a + reviewer scanning the middle of a long table does not have to look back up to the heading. */ +.rr-move { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 700; + white-space: nowrap; + + &--up { + background: #fef3c7; + color: #92400e; + } + + &--down { + background: #dbeafe; + color: #1e40af; + } + + &--same { + background: #f1f5f9; + color: var(--r-g500); + } +} + +/* A button, so the tile is reachable by keyboard and reads as the control it is. The type + defaults that come with that — centred text, the button font, the UA border — are undone + here so it still looks like the panel it replaced. */ .rr-kpi { + display: block; + width: 100%; background: #fff; border: 1px solid var(--r-g200); border-left: 4px solid var(--r-navy); border-radius: 11px; padding: 12px 16px; + text-align: left; + font: inherit; + color: inherit; + cursor: pointer; + transition: + box-shadow 0.15s ease, + border-color 0.15s ease; + + &:hover { + box-shadow: 0 1px 6px rgb(15 23 42 / 12%); + } + + &:focus-visible { + outline: 2px solid var(--r-blue); + outline-offset: 2px; + } &--blue { border-left-color: var(--r-blue); @@ -313,6 +356,19 @@ &--dim { border-left-color: var(--r-g300); + + /* Nothing to narrow to, so the tile says so rather than accepting a click that does nothing. */ + cursor: default; + + &:hover { + box-shadow: none; + } + } + + /* The filter this tile applies is currently on. */ + &--on { + border-color: var(--r-navy); + box-shadow: inset 0 0 0 1px var(--r-navy); } } @@ -403,6 +459,84 @@ font-weight: 600; } +/* VERDICT GROUPS — the section heading carries the verdict, so rows need not repeat it. */ +.rr-group { + margin-bottom: 14px; +} + +.rr-group-head { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + background: #fff; + border: 1px solid var(--r-g200); + border-left: 4px solid var(--r-g300); + border-radius: 10px; + cursor: pointer; + text-align: left; + font: inherit; +} + +.rr-group-t { + font-size: 14px; + font-weight: 700; +} + +/* The count sits next to the title so a reviewer sees the size of each pile at a glance. */ +.rr-group-n { + min-width: 24px; + padding: 1px 8px; + border-radius: 999px; + background: var(--r-g100, #f1f5f9); + font-size: 12px; + font-weight: 700; + text-align: center; +} + +.rr-group-h { + flex: 1; + font-size: 12px; + color: var(--r-g500); +} + +/* One colour per verdict, matched to the arrows already used in the rows. */ +.rr-group--str .rr-group-head { + border-left-color: #dc2626; +} + +.rr-group--str .rr-group-n { + background: #fee2e2; + color: #991b1b; +} + +.rr-group--up .rr-group-head { + border-left-color: #ea580c; +} + +.rr-group--up .rr-group-n { + background: #ffedd5; + color: #9a3412; +} + +.rr-group--down .rr-group-head { + border-left-color: #16a34a; +} + +.rr-group--down .rr-group-n { + background: #dcfce7; + color: #166534; +} + +.rr-group--hold .rr-group-head { + border-left-color: var(--r-g300); +} + +.rr-group .rr-table-wrap { + margin-top: 6px; +} + /* TABLE */ .rr-table-wrap { background: #fff; @@ -430,9 +564,12 @@ } td { - padding: 8px 10px; + padding: 11px 10px; border-bottom: 1px solid var(--r-g100); - vertical-align: top; + + /* Middle rather than top: one cell in the row carries a two-line reason, and top alignment + left every other value floating against the row above it. */ + vertical-align: middle; } .num { @@ -466,6 +603,9 @@ td { padding: 12px 16px 16px; + + /* The expanded block is a panel, not a value, so it starts at the top of its cell. */ + vertical-align: top; } } @@ -717,6 +857,20 @@ } /* What the report says should happen to the rating */ + +/* The verdict and the sentence behind it. Wide enough that the reason wraps to two readable + lines instead of a narrow ragged column. */ +.rr-move-cell { + min-width: 250px; + max-width: 330px; +} + +/* Just the button, so the column is only as wide as the control it holds. */ +.rr-action-cell { + width: 1%; + white-space: nowrap; +} + .rr-verdict-cell { min-width: 210px; max-width: 300px; @@ -745,12 +899,89 @@ } .rr-verdict-why { - font-size: 10.5px; + font-size: 11px; + line-height: 1.45; color: var(--r-g500); - margin-top: 2px; + margin-top: 4px; white-space: normal; } +/* Where the rating should land, read straight off the Current Risk column beside it. */ +.rr-move-cell { + min-width: 150px; + max-width: 190px; +} + +.rr-move { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + white-space: nowrap; + + &--up { + background: var(--r-red-l); + color: var(--r-red); + } + + /* Coming down is a relief, not an alarm — same green the verdict uses. */ + &--down { + background: var(--r-green-l); + color: var(--r-green); + } + + &--hold { + background: var(--r-g100); + color: var(--r-g500); + } + + /* An unrated customer reads neither as settled nor as a movement: there is no rating to move. Amber says the row + is outstanding without claiming the customer is a concern — the gap is the bank's, not theirs. */ + &--unrated { + background: var(--r-amber-l, #fef3c7); + color: var(--r-amber, #b45309); + } +} + +.rr-move-why { + font-size: 10px; + color: var(--r-g500); + margin-top: 3px; + white-space: normal; +} + +/* Drill from the verdict into the KYC screen, where the rating is actually moved. */ +.rr-kyc-link { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 6px; + padding: 3px 9px; + border: 1px solid var(--r-g300); + border-radius: 5px; + background: #fff; + font: inherit; + font-size: 10.5px; + font-weight: 600; + color: var(--r-g500); + white-space: nowrap; + cursor: pointer; + + &:hover { + border-color: var(--r-blue); + color: var(--r-blue); + } + + /* A rating that has to move gets the emphasis; a settled row stays quiet. */ + &--act { + border-color: var(--r-blue); + color: var(--r-blue); + } +} + .rr-tag { display: inline-block; margin-left: 6px; diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts index 58ef27234..ee9d5f2b9 100644 --- a/src/app/risk-review/risk-review-report.component.ts +++ b/src/app/risk-review/risk-review-report.component.ts @@ -9,6 +9,8 @@ /** Angular Imports */ import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; import { forkJoin, of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; @@ -38,12 +40,13 @@ import { channelLabel, mapLiveAlert, riskFromCategory, - sampleRiskAlerts + riskRank } from './risk-review-data'; import { DateRange, RatingDirection, RiskAlertView, + BehaviourWindowByClient, RiskCustomerRow, RiskReviewFilter, RiskReviewModel, @@ -53,10 +56,20 @@ import { monthRange, weekRange } from './risk-review.compute'; +import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component'; type PeriodMode = 'day' | 'week' | 'month' | 'range'; type ViewMode = 'customer' | 'alert' | 'txn'; +/** One block of the customer view: the customers that share a single verdict. */ +interface RiskCustomerGroup { + key: string; + title: string; + hint: string; + tone: 'str' | 'up' | 'down' | 'hold'; + rows: RiskCustomerRow[]; +} + /** Starting value for the memo, used before the first filter pass runs. */ const EMPTY_SUMMARY: RiskReviewSummary = { customers: 0, @@ -82,9 +95,8 @@ const EMPTY_SUMMARY: RiskReviewSummary = { * rating, move it, refresh KYC, or escalate to the Principal Officer for an * STR — which is the vocabulary in `REVIEW_STATUS_META`. * - * Rows come from the live rule engine. If the engine has raised nothing (or is - * unreachable) the report falls back to a deterministic sample set and says so - * on screen and in the workbook, so sample rows are never taken for live data. + * Rows come from the live rule engine only. If the engine has raised nothing + * (or is unreachable) the report shows as empty rather than padding itself. */ @Component({ selector: 'mifosx-risk-review-report', @@ -100,6 +112,8 @@ export class RiskReviewReportComponent implements OnInit { private aml = inject(AmlService); private clients = inject(ClientsService); private scope = inject(BranchScopeService); + private router = inject(Router); + private dialog = inject(MatDialog); readonly period = GL_PERIOD; readonly branches = GL_BRANCHES; @@ -122,22 +136,13 @@ export class RiskReviewReportComponent implements OnInit { private liveAlerts: RiskAlertRow[] = []; loading = true; - /** - * Live alerts and the demonstration set, in one worklist. - * - * Every row keeps its `live` flag and sample rows are tagged in the table, so - * a reviewer can still tell them apart at a glance. - */ + /** The worklist — live alerts from the rule engine. */ private alerts: RiskAlertRow[] = []; get hasLive(): boolean { return this.liveAlerts.length > 0; } - get sampleCount(): number { - return this.alerts.filter((a) => !a.live).length; - } - // ── Filter state ─────────────────────────────────────────────────────────── /** @@ -181,29 +186,49 @@ export class RiskReviewReportComponent implements OnInit { // ── Loading ──────────────────────────────────────────────────────────────── + /** + * The nightly job's verdict per customer, keyed on client id. + * + * Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to + * its own per-alert reckoning rather than showing nothing. + */ + private behaviourWindows: BehaviourWindowByClient = {}; + load(): void { this.loading = true; - this.aml - .getAlerts() - .pipe(catchError(() => of([] as LiveAmlAlert[]))) - .subscribe((live) => { - const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert)); - this.liveAlerts = rows; - this.compose(); - if (!rows.length) { - this.loading = false; - return; + // Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a + // recommendation the screen then silently replaced. + forkJoin({ + alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))), + windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[]))) + }).subscribe(({ alerts: live, windows }) => { + this.behaviourWindows = {}; + for (const w of windows || []) { + if (w?.clientId != null) { + this.behaviourWindows[w.clientId] = w; } - this.enrichCurrentRisk(rows); - }); + } + const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert)); + this.liveAlerts = rows; + this.compose(); + if (!rows.length) { + this.loading = false; + return; + } + this.enrichCurrentRisk(rows); + }); } - /** Live alerts first, then the demonstration set behind them. */ + /** + * Only what the AML engine actually raised. + * + * The demonstration set that used to be appended here is gone: a compliance report that mixes + * invented customers into live alerts is worse than an empty one, because every count, total and + * rule tally on the screen is then a number nobody can act on. An empty period now reads as an + * empty period. + */ private compose(): void { - this.alerts = [ - ...this.liveAlerts, - ...sampleRiskAlerts() - ]; + this.alerts = [...this.liveAlerts]; this.memoKey = ''; } @@ -220,21 +245,40 @@ export class RiskReviewReportComponent implements OnInit { return; } + // The alert carries the client but not their account number or branch, so both columns read + // as an em dash on every live row. Both sit on the client record the KYC call already needs, + // so they are fetched alongside it rather than left blank on a compliance report. forkJoin( clientIds.map((id) => - this.clients.getClientKyc(String(id), true).pipe( - map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })), - catchError(() => of({ id, risk: null as RiskLevel | null })) + forkJoin({ + kyc: this.clients.getClientKyc(String(id), true).pipe(catchError(() => of(null))), + client: this.clients.getClientData(String(id), true).pipe(catchError(() => of(null))) + }).pipe( + map((r: any) => ({ + id, + risk: riskFromCategory(r.kyc?.riskCategory), + accountNo: r.client?.accountNo || '', + branch: r.client?.officeName || '' + })), + catchError(() => of({ id, risk: null as RiskLevel | null, accountNo: '', branch: '' })) ) ) ).subscribe((results) => { const byClient = new Map( results.map((r) => [ r.id, - r.risk + r ]) ); - this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null })); + this.liveAlerts = rows.map((r) => { + const info = byClient.get(r.clientId); + return { + ...r, + currentRisk: info?.risk ?? null, + accountNo: r.accountNo || info?.accountNo || '', + branchCode: r.branchCode || info?.branch || '' + }; + }); this.compose(); this.loading = false; }); @@ -318,9 +362,11 @@ export class RiskReviewReportComponent implements OnInit { private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY }; private get model(): RiskReviewModel { - const key = JSON.stringify(this.filter) + `|${this.alerts.length}`; + // The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same + // load as the alerts. Keying on the alerts alone would serve the memo computed before they landed. + const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`; if (key !== this.memoKey) { - this.memo = buildRiskReview(this.alerts, this.filter); + this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows); this.memoKey = key; } return this.memo; @@ -334,6 +380,188 @@ export class RiskReviewReportComponent implements OnInit { return this.model.customers; } + /** + * The customers split by what the reviewer actually has to do about them. + * + * The report's whole question is "does the rating on file still hold?", and there are only four + * answers. Grouping by the answer puts that question in the section heading, where it is read + * once, instead of repeating it on every row across a Move To and a Position column that said the + * same thing in different words. + * + * Ordered by what a reviewer must deal with first: an STR sits with the Principal Officer against + * a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is + * only a correction, and matching ratings need nothing at all. + */ + /** + * A report row for a customer the behaviour review named but no alert brought onto the report. + * + *

+ * The alert-derived fields are empty because there are none in the window, and that is the point being shown: the + * case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on + * behaviourWindow, so the row can still explain itself. + */ + private rowFromWindow(w: any): RiskCustomerRow { + const RANK: Record = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' }; + const current = RANK[w.riskCategoryAtReview] ?? null; + const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW'; + return { + clientId: w.clientId, + customerName: w.clientName || `Client ${w.clientId}`, + accountNo: w.accountNo || '', + branchCode: w.officeName || '', + currentRisk: current, + alerts: [], + alertCount: 0, + totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0), + largestAmount: Number(w.largestTxn || 0), + proposedRisk: recommended, + recommendedRisk: recommended, + direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same', + directionReason: w.suggestionReason || '', + ruleLabels: [], + firstAlertDate: '', + lastAlertDate: '', + openCount: 0, + escalated: false, + needsUpgrade: w.suggestedDirection === 'UP', + needsDowngrade: w.suggestedDirection === 'DOWN', + behaviourWindow: w + }; + } + + get customerGroups(): RiskCustomerGroup[] { + const rows = this.customerRows; + // An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting + // on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change + // somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under + // the Principal Officer with nothing to do. + const escalated = rows.filter((c) => c.escalated && c.direction === 'same'); + const rest = rows.filter((c) => !(c.escalated && c.direction === 'same')); + + // Customers the nightly review says to move who raised no alert inside the filter window. + // + // The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the + // wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the + // customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they + // would sit in the behaviour table with a verdict nobody ever sees. + const onReport = new Set(rows.map((r) => r.clientId)); + const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows) + .filter((w: any) => w?.clientId != null && !onReport.has(w.clientId)) + .filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN') + .map((w: any) => this.rowFromWindow(w)); + + const groups: RiskCustomerGroup[] = [ + { + key: 'escalated', + title: 'With the Principal Officer', + hint: 'An STR has been filed. The rating is held where it is until the outcome — nothing to apply here.', + tone: 'str', + rows: escalated + }, + { + // Raising and lowering are one section, not two. The heading answers "is there anything to do about this + // customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason. + // Splitting them put the same answer in two places and left an officer reading two lists to find one day's + // work. Raises sort first within the section, because an under-rated customer is the more urgent gap. + key: 'change', + title: 'Change needed', + hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.', + tone: 'up', + rows: [ + ...rest.filter((c) => c.currentRisk && c.direction === 'up'), + ...offReport.filter((c) => c.direction === 'up'), + ...rest.filter((c) => c.currentRisk && c.direction === 'down'), + ...offReport.filter((c) => c.direction === 'down') + ] + }, + { + // Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose + // money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it + // is in keeping - the work is to give them one, and grouping them with the customers nothing need be done + // about buried the one action the report was asking for. + key: 'unrated', + title: 'Update risk', + hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.', + tone: 'up', + rows: rest.filter((c) => !c.currentRisk) + }, + { + // Everything an alert put on the report that the policy will not move yet. "No change needed" read as though + // these customers were fine, which is not what it means: a customer with four open alerts sits here, and the + // reason is that the period does not yet carry enough to act on - not that there is nothing to look at. + // "Watching" says what the section is for, and the reason on each row says which condition fell short. + key: 'same', + title: 'Watching — not enough to act on', + hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.', + tone: 'hold', + rows: rest.filter((c) => c.currentRisk && c.direction === 'same') + } + ]; + + return groups.filter((g) => g.rows.length > 0); + } + + /** + * Collapsed sections, by group key. Every group starts open. + * + * 'No change needed' used to start closed on the reasoning that there was nothing to act on. On a + * report that often holds one group, that meant opening the page to a heading and a count and no + * rows — the customer was there, but reading the report took a click to discover it. The toggle + * stays for folding a long section away. + */ + collapsedGroups: Record = {}; + + /** + * The summary tiles read as a question each — "which customers?", "which alerts still need a + * decision?" — so each one narrows the table to the rows it counted. They were plain text + * before, which invited a click that did nothing and left the reviewer to rebuild the same + * filter by hand from the dropdowns. + * + * Clicking an active tile clears it again, so a tile is never a one-way trip into a filter the + * reviewer then has to hunt for. + */ + focusAllCustomers(): void { + this.currentRisk = ''; + this.direction = ''; + this.openOnly = false; + this.expanded = null; + this.collapsedGroups = {}; + } + + focusOpenAlerts(): void { + this.openOnly = !this.openOnly; + this.direction = ''; + this.expanded = null; + this.collapsedGroups = {}; + } + + /** Only the customers whose rating the review says should move, either way. */ + focusRatingsToMove(): void { + // Nothing to narrow to when no rating needs moving; leaving the filter alone keeps the + // table showing what is actually there rather than emptying it. + if (this.summary.upgradeCandidates + this.summary.downgradeCandidates === 0) { + return; + } + this.direction = this.direction === 'up' ? 'down' : this.direction === 'down' ? '' : 'up'; + this.openOnly = false; + this.expanded = null; + this.collapsedGroups = {}; + } + + focusEscalated(): void { + if (this.summary.escalatedCustomers === 0) { + return; + } + this.status = this.status === 'ESCALATED_STR' ? '' : 'ESCALATED_STR'; + this.openOnly = false; + this.expanded = null; + this.collapsedGroups = {}; + } + + toggleGroup(key: string): void { + this.collapsedGroups[key] = !this.collapsedGroups[key]; + } + get txnRows(): RiskTxnView[] { return this.model.txns; } @@ -393,6 +621,192 @@ export class RiskReviewReportComponent implements OnInit { return 'rr-verdict'; } + /** Colours the target rating by which way it moves. */ + moveClass(c: RiskCustomerRow): string { + if (c.direction === 'up') { + return 'rr-move rr-move--up'; + } + if (c.direction === 'down') { + return 'rr-move rr-move--down'; + } + return 'rr-move rr-move--hold'; + } + + /** + * The short "from → to" line under the target rating. + * + * The reason column already carries the full argument; this only has to say what is + * happening to the rating, so a reviewer scanning the column never has to read a + * paragraph to find out whether a customer is moving. + */ + moveHint(c: RiskCustomerRow): string { + const from = this.riskText(c.currentRisk); + const to = this.riskText(c.recommendedRisk); + + if (c.escalated) { + return `${from} → hold at ${to} till STR outcome`; + } + if (c.direction === 'up') { + return `${from} → ${to} as per AML rules`; + } + if (c.direction === 'down') { + return `${from} → ${to}, activity does not support ${from}`; + } + // Nothing to move: say why it is staying rather than leaving the cell mute. + return c.currentRisk ? `Behaviour matches ${from} — no change` : 'No rating on record yet'; + } + + /** + * Opens the customer's KYC tab so the reviewer can act on the verdict. + * + * The report only ever recommends — nothing here writes the rating. Moving a customer + * is a KYC amendment and stays on the KYC screen, where the override checkbox, the + * remarks field and the review-date recalculation all live together. + */ + openKyc(c: RiskCustomerRow, event: Event): void { + // The row itself expands on click; the button must not do both. + event.stopPropagation(); + this.router.navigate([ + '/clients', + c.clientId, + 'kyc' + ]); + } + + /** + * Sends a case that is with the Principal Officer to the STR queue. + * + * The rating cannot be moved while an STR is open, so the useful thing to offer is the case + * itself. Sending the reviewer to the customer's KYC record instead made them land on a screen + * where the one action they might take is the one thing they must not do yet. + */ + openStrCase(c: RiskCustomerRow, event: Event): void { + event.stopPropagation(); + this.router.navigate(['/fiu'], { queryParams: { tab: 'str' } }); + } + + /** + * Whether this row's rating can be moved from here. + * + * A case with the Principal Officer is deliberately excluded: the rating is held where it is + * until the STR concludes, which is what the section heading on that group says. Offering the + * button there would invite a change nobody is in a position to make yet. + */ + canChangeRisk(c: RiskCustomerRow): boolean { + return !c.escalated; + } + + /** + * Raises a rating change for approval, rather than sending the reviewer to the KYC screen to make + * it themselves. + * + * The button used to navigate away: the reviewer left the report, found the customer, edited the + * record, and the change took effect on their say-so alone with no second pair of eyes and no + * record of why. The rating governs re-KYC frequency and AML thresholds, so it goes through a + * checker like every other decision of that weight. + */ + changeRisk(c: RiskCustomerRow, event: Event): void { + event.stopPropagation(); + if (!this.canChangeRisk(c)) { + return; + } + this.dialog + .open(RiskChangeDialogComponent, { + width: '520px', + maxWidth: '95vw', + disableClose: true, + data: { + clientId: c.clientId, + customerName: c.customerName, + currentCategory: riskRank(c.currentRisk) || null, + recommendedCategory: riskRank(c.recommendedRisk) || null, + recommendationReason: c.directionReason + } as RiskChangeDialogData + }) + .afterClosed() + .subscribe((sent) => { + if (sent) { + // The rating has not moved yet - only a request exists - so the figures on screen are + // still correct. Reloaded anyway so a second click cannot raise a duplicate request the + // server would refuse. + this.load(); + } + }); + } + + /** + * What the reviewer is going to the KYC screen to do. + * + * An escalated case is worded as a review rather than a move: the rating is held + * pending the Principal Officer's conclusion, so there is nothing to apply yet. + */ + /** + * The verdict in the words a reviewer needs: which way the rating moves, and to what. + * + * An escalated case is deliberately not given a direction — the rating is held where it is + * until the Principal Officer reports back, so showing "raise to High" there would invite a + * change nobody is authorised to make yet. + */ + directionText(c: RiskCustomerRow): string { + // Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one + // filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports + // the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing + // needed doing. + if (c.escalated && c.direction === 'same') { + return 'Held — with Principal Officer'; + } + // Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the + // officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in + // one glance, and on an exported sheet it still says what happened without the column beside it. + if (c.direction === 'up' || c.direction === 'down') { + const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated'; + return `${from} → ${RISK_LABEL[c.recommendedRisk]}`; + } + // An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops + // there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most. + return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer'; + } + + directionIcon(c: RiskCustomerRow): string { + // Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the + // rating still has to be moved. + if (c.escalated && c.direction === 'same') { + return 'lock'; + } + if (c.direction === 'up') { + return 'arrow-up'; + } + if (c.direction === 'down') { + return 'arrow-down'; + } + // A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question + // rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered + // names resolve, and an unregistered icon throws at render and takes the whole row down with it. + return c.currentRisk ? 'check' : 'exclamation-circle'; + } + + /** + * A live row carries the office name straight off the client record, not a GL branch code, so + * it is already the label to print. Only a coded row needs the lookup. + */ + branchLabelOf(branch: string): string { + if (!branch) { + return '—'; + } + const named = glBranchName(branch); + return named && named !== branch ? named : branch; + } + + kycActionText(c: RiskCustomerRow): string { + if (c.escalated) { + return 'Open KYC'; + } + if (c.direction === 'up' || c.direction === 'down') { + return `Set ${RISK_LABEL[c.recommendedRisk]} in KYC`; + } + return 'Open KYC'; + } + /** Transaction detail as the reviewer would read it in one line. */ txnDetail(a: RiskAlertView): string { const amount = `₹${a.amount.toLocaleString('en-IN')}`; @@ -409,9 +823,7 @@ export class RiskReviewReportComponent implements OnInit { try { const s = this.summary; const subtitle = - `Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` + - `Source: live AML monitoring engine` + - (this.sampleCount ? ` + ${this.sampleCount} DEMONSTRATION rows (not compliance data)` : ''); + `Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` + `Source: live AML monitoring engine`; await exportWorkbook( [ @@ -533,6 +945,7 @@ export class RiskReviewReportComponent implements OnInit { 'Last Alert', 'Rules Point At', 'Recommended Risk', + 'Move To', 'Open Cases', 'Position', 'Basis' @@ -551,6 +964,7 @@ export class RiskReviewReportComponent implements OnInit { 12, 14, 16, + 38, 11, 24, 70 @@ -559,7 +973,7 @@ export class RiskReviewReportComponent implements OnInit { String(c.clientId), c.customerName, c.accountNo || '—', - c.branchCode ? glBranchName(c.branchCode) : '—', + this.branchLabelOf(c.branchCode), this.riskText(c.currentRisk), c.alertCount, c.ruleLabels.join('; '), @@ -569,6 +983,7 @@ export class RiskReviewReportComponent implements OnInit { c.lastAlertDate, RISK_LABEL[c.proposedRisk], RISK_LABEL[c.recommendedRisk], + this.moveHint(c), c.openCount, this.verdictText(c), c.directionReason || '—' @@ -618,7 +1033,7 @@ export class RiskReviewReportComponent implements OnInit { String(a.clientId), a.customerName, a.accountNo || '—', - a.branchCode ? glBranchName(a.branchCode) : '—', + this.branchLabelOf(a.branchCode), this.riskText(a.currentRisk), this.txnDetail(a), a.rule.label, @@ -684,7 +1099,7 @@ export class RiskReviewReportComponent implements OnInit { String(t.clientId), t.customerName, t.accountNo || '—', - t.branchCode ? glBranchName(t.branchCode) : '—', + this.branchLabelOf(t.branchCode), this.riskText(t.currentRisk), channelLabel(t.channel), t.drCr, diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts index ce0dd9000..215fcac21 100644 --- a/src/app/risk-review/risk-review.compute.ts +++ b/src/app/risk-review/risk-review.compute.ts @@ -10,7 +10,7 @@ import { AmlRuleMeta, CTR_THRESHOLD, MATERIALITY, - NON_MITIGABLE_RULES, + isNonMitigableRule, REVIEW_STATUS_META, RISK_LABEL, RiskAlertRow, @@ -26,6 +26,17 @@ export type RatingDirection = 'up' | 'down' | 'same'; const RANK_TO_LEVEL: Record = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' }; +/** + * What has to have been seen before this screen's own reckoning will argue for lowering a rating. + * + * The nightly behaviour review is the real test — it reads a 270-day window, counts transactions rather than alerts, + * and applies the blocks. These are the floor for the fallback used until that job has run: deliberately modest, + * because the fallback sees only the alerts inside the report's date filter, but not absent, because judging on + * amounts alone let three small deposits in one month argue for taking a customer off Medium. + */ +const MIN_ALERTS_TO_LOWER = 5; +const MIN_MONTHS_TO_LOWER = 3; + export interface DateRange { from: string; to: string; @@ -109,6 +120,8 @@ export interface RiskCustomerRow { needsUpgrade: boolean; /** True when the rating on record is higher than the activity warrants. */ needsDowngrade: boolean; + /** The review period the recommendation was drawn from, where the nightly job has computed one. */ + behaviourWindow?: BehaviourWindowByClient[number] | null; } export interface RiskReviewSummary { @@ -155,17 +168,26 @@ function recommendRating( largestAmount: number, totalAmount: number, escalated: boolean, - ruleCodes: string[] + ruleCodes: string[], + alertCount: number, + distinctMonths: number ): { risk: RiskLevel; reason: string } { const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW'; - const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c)); + const grave = ruleCodes.find((c) => isNonMitigableRule(c)); const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal; - const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal; + + // Small amounts are not on their own a reason to lower a rating. A rating describes a period of behaviour, so + // bringing one down needs a period to have been observed: enough transactions to be a pattern, and spread over + // enough months that they are not one busy week. Judging on amounts alone let three small deposits in a single + // month argue for taking a customer off Medium — which is the same mistake as re-rating on one transaction, only + // three times over. + const enoughActivity = alertCount >= MIN_ALERTS_TO_LOWER && distinctMonths >= MIN_MONTHS_TO_LOWER; + const small = enoughActivity && largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal; // What the rating should be. Amounts only ever pull the rules' bound down, and // only where nothing weightier holds it up. let risk: RiskLevel; - let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate'; + let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate' | 'thin'; if (escalated) { risk = 'HIGH'; basis = 'escalated'; @@ -175,6 +197,12 @@ function recommendRating( } else if (material) { risk = implied; basis = 'material'; + } else if (!enoughActivity) { + // Too little observed to say anything about the rating either way, so it stands. Stating this rather than + // quietly leaving the rating alone is the point: an officer reading the row learns the period was too thin, + // not that the customer was examined and found unremarkable. + risk = currentRisk ?? implied; + basis = 'thin'; } else { risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW'; basis = small ? 'small' : 'moderate'; @@ -218,7 +246,35 @@ function recommendRating( }; } - return { risk, reason: '' }; + if (basis === 'thin') { + // A customer with no rating is not waiting for more evidence — there is no rating for evidence to move. Telling + // them the period is too thin, under a verdict that says "Rate this customer", asks for the work and argues + // against it in the same row. The thin-period test is about lowering a rating that exists. + if (!currentRisk) { + return { + risk, + reason: `Transacting with no rating on file — ${inr(totalAmount)} across ${alertCount} alert${ + alertCount === 1 ? '' : 's' + }. Rate the customer; behaviour can only be judged against a rating.` + }; + } + // Says which of the two tests fell short, because "not enough activity" leaves an officer unable to tell whether + // the customer needs watching for another month or another five. + const shortOn = + alertCount < MIN_ALERTS_TO_LOWER + ? `only ${alertCount} alert${alertCount === 1 ? '' : 's'} in the period, ${MIN_ALERTS_TO_LOWER} needed` + : `activity falls in ${distinctMonths} month${distinctMonths === 1 ? '' : 's'}, ${MIN_MONTHS_TO_LOWER} separate months needed`; + return { risk, reason: `Too little to re-rate on — ${shortOn}. ${rated}; it stands until more is seen.` }; + } + + // The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank + // read as "not assessed yet" on screen, which is the opposite of what the review concluded. + return { + risk, + reason: currentRisk + ? `${sizes}. Consistent with ${RISK_LABEL[risk]} — the rating on file stands.` + : `${sizes}. Activity points at ${RISK_LABEL[risk]}; no rating on record to compare against.` + }; } /** Applies every filter and joins each alert to its rule. */ @@ -264,7 +320,36 @@ export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter) * escalated, then rated below what the rules point at, then most alerts — * come to the top of the worklist. */ -export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] { +/** + * The behaviour window the nightly job computed for one customer, where there is one. + * + * Keyed on client id. A customer with no window - one the job has not seen yet, or a tenant where the job has never + * run - falls back to the per-alert reckoning below, so the report renders either way. + */ +export interface BehaviourWindowByClient { + [clientId: number]: { + clientId: number; + clientName: string | null; + accountNo: string | null; + officeName: string | null; + riskCategoryAtReview: number | null; + txnCount: number | null; + distinctMonths: number | null; + windowFrom: any; + windowTo: any; + creditTotal: number | null; + debitTotal: number | null; + largestTxn: number | null; + cashTotal: number | null; + alertCount: number | null; + strCaseCount: number | null; + suggestedDirection: string | null; + suggestedCategory: number | null; + suggestionReason: string | null; + }; +} + +export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindowByClient = {}): RiskCustomerRow[] { const byClient = new Map(); for (const v of views) { const list = byClient.get(v.clientId); @@ -290,19 +375,59 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] { const largestAmount = Math.max(...alerts.map((a) => a.amount)); const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED'); - const recommendation = recommendRating( - proposedRank, - currentRisk, - largestAmount, - totalAmount, - escalated, - alerts.map((a) => a.ruleCode) - ); + // The server's own reckoning, where the nightly job has produced one. + // + // It is the better answer, and not because it is newer: it is drawn from a review period rather than from the + // alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts + // alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is + // supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the + // activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids + // lowering it. + // + // Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert + // reckoning below still stands, so the report never renders blank while the two are in step. + // A window is only good for as long as the rating it was computed against. The job takes a snapshot each night + // and records the rating the customer held at the time; once an officer moves that rating the snapshot describes + // a customer who no longer exists, and the screen would go on asking for a change that has already been applied + // and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up. + const raw = windows[clientId]; + const staleWindow = + raw?.riskCategoryAtReview != null && + currentRisk != null && + RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk; + const window = staleWindow ? undefined : raw; + const recommendation = window?.suggestedDirection + ? { + risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW', + reason: window.suggestionReason ?? '' + } + : recommendRating( + proposedRank, + currentRisk, + largestAmount, + totalAmount, + escalated, + alerts.map((a) => a.ruleCode), + alerts.length, + // Distinct calendar months the alerts fall in. Count alone does not establish a period: five alerts in one + // week is a busy fortnight, not five months of steady conduct. + new Set(alerts.map((a) => a.alertDate.slice(0, 7))).size + ); // Where the customer carries no rating there is nothing to move, so an // unrated customer is never counted as an upgrade or a downgrade. const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0; - const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same'; + const direction: RatingDirection = window?.suggestedDirection + ? window.suggestedDirection === 'UP' + ? 'up' + : window.suggestedDirection === 'DOWN' + ? 'down' + : 'same' + : delta > 0 + ? 'up' + : delta < 0 + ? 'down' + : 'same'; rows.push({ clientId, @@ -325,7 +450,10 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] { openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length, escalated, needsUpgrade: direction === 'up', - needsDowngrade: direction === 'down' + needsDowngrade: direction === 'down', + // Carried through so the screen can show the period a recommendation was drawn from. A rating an officer is + // asked to approve has to show its working, and the alerts on this row are not it. + behaviourWindow: window ?? null }); } @@ -454,9 +582,13 @@ export interface RiskReviewModel { * the summary all run once per filter change rather than once per template * binding, which matters because every view reads from the same result. */ -export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel { +export function buildRiskReview( + alerts: RiskAlertRow[], + filter: RiskReviewFilter, + windows: BehaviourWindowByClient = {} +): RiskReviewModel { const base = riskAlertViews(alerts, filter); - const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction); + const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction); return { alerts: narrowed.views, diff --git a/src/app/savings-daily-interest/savings-daily-interest-routing.module.ts b/src/app/savings-daily-interest/savings-daily-interest-routing.module.ts new file mode 100644 index 000000000..c73fbb6ec --- /dev/null +++ b/src/app/savings-daily-interest/savings-daily-interest-routing.module.ts @@ -0,0 +1,29 @@ +/** + * 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 { SavingsDailyInterestComponent } from './savings-daily-interest.component'; + +const routes: Routes = [ + Route.withShell([ + { + path: 'savings-daily-interest', + component: SavingsDailyInterestComponent, + data: { title: 'Savings Daily Interest', breadcrumb: 'Savings Daily Interest' } + } + ]) +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class SavingsDailyInterestRoutingModule {} diff --git a/src/app/savings-daily-interest/savings-daily-interest.component.html b/src/app/savings-daily-interest/savings-daily-interest.component.html new file mode 100644 index 000000000..59c3d7334 --- /dev/null +++ b/src/app/savings-daily-interest/savings-daily-interest.component.html @@ -0,0 +1,318 @@ + + +

+
+

Savings Daily Interest

+
+ + + +
+
+ + @if (message) { +
+ {{ message }} +
+ } + +
+ + + @if (selectedProduct) { + + } + @if (selectedAccount) { + + } + +
+ + @if (loading) { +
Loading…
+ } + + + @if (activeTab === 'summary' && !loading) { +
+ + + + {{ filteredSummary.length }} accounts · accrued this cycle + {{ summaryAccruedTotal | number: '1.2-2' }} · posted to date + {{ summaryPostedTotal | number: '1.2-2' }} + +
+ + @if (filteredSummary.length === 0) { +
+ No accounts in the ledger yet. Run the accrual, or use Backfill to rebuild it from activation. +
+ } @else { + + + + + + + + + + + + + + + + + + + @for (row of filteredSummary; track row.savings_account_id) { + + + + + + + + + + + + + + + } + +
Account NoCustomerProductAs OnBalanceRateInterest TodayCycleAccrued This CycleNext PostingPosted To Date
{{ row.account_no }}{{ row.client_name || '—' }} + {{ row.product_name }} +
{{ postingPeriodLabel(row.posting_period_enum ?? 0) }} posting
+
{{ formatDate(row.as_on_date) }}{{ row.closing_balance | number: '1.2-2' }}{{ row.interest_rate | number: '1.2-2' }}%{{ row.interest_today | number: '1.4-4' }} + #{{ row.cycle_number }} +
{{ formatDate(row.cycle_start_date) }}
+
{{ row.interest_this_cycle | number: '1.2-2' }} + {{ formatDate(row.cycle_end_date) }} + @if (row.is_posting_day) { +
posting today
+ } @else { +
in {{ row.days_to_posting }} days
+ } +
{{ row.interest_posted_total | number: '1.2-2' }} + + +
+ } + } + + + @if (activeTab === 'products' && !loading) { + + + + + + + + + + + + + + + @for (product of products; track product.id) { + + + + + + + + + + + } @empty { + + + + } + +
ProductRatePostsAccountsAccrued This CycleLast PostedNext PostingStatus
+ {{ product.name }} + {{ product.short_name }} + {{ product.rate }}%{{ postingPeriodLabel(product.posting_period_enum) }}{{ product.active_accounts }}{{ product.accrued_this_cycle | number: '1.2-2' }}{{ formatDate(product.last_posting_date) }}{{ formatDate(product.next_posting_date) }} + @if (product.last_run_status) { + + {{ product.last_run_status }} + + } @else { + Not run + } +
No savings products carry a ledger yet.
+ } + + + @if (activeTab === 'accounts' && !loading) { + + + + + + + + + + + + + + + + @for (account of accounts; track account.savings_account_id) { + + + + + + + + + + + + } @empty { + + + + } + +
AccountClientAs AtOpeningClosingDay InterestSum This CycleCyclePays On
+ {{ account.account_no }} + {{ account.client_name || '—' }}{{ formatDate(account.balance_date) }}{{ account.opening_balance | number: '1.2-2' }}{{ account.closing_balance | number: '1.2-2' }} + {{ account.daily_interest | number: '1.4-4' }} + @if (account.below_min_balance) { + below min + } + {{ account.cycle_cumulative_interest | number: '1.2-2' }}{{ account.cycle_number }}{{ formatDate(account.cycle_end_date) }}
No ledger rows for this product yet.
+ } + + + @if (activeTab === 'ledger' && !loading) { + + + + + + + + + + + + + + @for (day of ledger; track day.balance_date) { + + + + + + + + + + } @empty { + + + + } + +
DateOpeningClosingDay InterestRunning SumCyclePosted
+ {{ formatDate(day.balance_date) }} + @if (day.is_posting_day) { + + } + {{ day.opening_balance | number: '1.2-2' }}{{ day.closing_balance | number: '1.2-2' }} + {{ day.daily_interest | number: '1.4-4' }} + @if (day.below_min_balance) { + below min + } + {{ day.cycle_cumulative_interest | number: '1.4-4' }}{{ day.cycle_number }} + @if (day.posted_amount !== null) { + {{ day.posted_amount | number: '1.2-2' }} + } @else { + — + } +
No days recorded for this account.
+ } + + + @if (activeTab === 'runs' && !loading) { + + + + + + + + + + + + + + @for (run of runs; track run.id) { + + + + + + + + + + } @empty { + + + + } + +
ProductPosting DateAccountsPooledDistributedResidualStatus
{{ run.product_name }}{{ formatDate(run.posting_date) }}{{ run.account_count }}{{ run.pooled_amount | number: '1.2-2' }}{{ run.distributed_amount | number: '1.2-2' }}{{ run.rounding_residual | number: '1.2-2' }} + {{ + run.status + }} + @if (run.failure_reason) { +
{{ run.failure_reason }}
+ } +
No posting runs yet.
+ } +
diff --git a/src/app/savings-daily-interest/savings-daily-interest.component.scss b/src/app/savings-daily-interest/savings-daily-interest.component.scss new file mode 100644 index 000000000..2d6021694 --- /dev/null +++ b/src/app/savings-daily-interest/savings-daily-interest.component.scss @@ -0,0 +1,237 @@ +/** + * 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/. + */ + +.sdi-container { + padding: 1rem; +} + +.sdi-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1rem; + + h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + } +} + +.sdi-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.sdi-message { + padding: 0.6rem 0.9rem; + margin-bottom: 1rem; + border-radius: 4px; + cursor: pointer; + background: #e8f4ea; + border-left: 4px solid #2e7d32; + font-size: 0.9rem; + + &.error { + background: #fdecea; + border-left-color: #c62828; + } +} + +.sdi-tabs { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + border-bottom: 1px solid rgb(0 0 0 / 12%); + margin-bottom: 1rem; + + button { + background: none; + border: none; + padding: 0.6rem 1rem; + cursor: pointer; + font-size: 0.9rem; + border-bottom: 2px solid transparent; + + &.active { + border-bottom-color: #1565c0; + font-weight: 600; + } + } +} + +.sdi-loading { + padding: 2rem; + text-align: center; + opacity: 0.7; +} + +// Wide tables scroll inside their own box rather than pushing the page sideways. +.sdi-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + display: block; + overflow-x: auto; + white-space: nowrap; + + th, + td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid rgb(0 0 0 / 8%); + } + + th { + font-weight: 600; + background: rgb(0 0 0 / 3%); + position: sticky; + top: 0; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + tr.clickable { + cursor: pointer; + + &:hover { + background: rgb(21 101 192 / 6%); + } + } + + // The day the cycle is paid out and the sum goes back to zero. + tr.posting-row { + background: rgb(46 125 50 / 8%); + font-weight: 600; + } +} + +.accent { + color: #1565c0; + font-weight: 600; +} + +// A day that earned nothing because the balance sat under the product minimum. +.zero { + opacity: 0.55; +} + +.sdi-sub { + display: block; + font-size: 0.75rem; + opacity: 0.65; +} + +.sdi-empty { + text-align: center; + padding: 2rem; + opacity: 0.6; +} + +.sdi-chip { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 10px; + font-size: 0.75rem; + background: rgb(0 0 0 / 8%); + + &.ok { + background: rgb(46 125 50 / 15%); + color: #2e7d32; + } + + &.bad { + background: rgb(198 40 40 / 15%); + color: #c62828; + } +} + +.sdi-flag { + display: inline-block; + margin-left: 0.4rem; + padding: 0.1rem 0.4rem; + border-radius: 3px; + font-size: 0.7rem; + font-weight: 500; + background: rgb(0 0 0 / 8%); + + &.paid { + background: rgb(46 125 50 / 20%); + color: #2e7d32; + } +} + +// ── All Accounts: one line per account ─────────────────────────────────────── + +.sdi-summary-bar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.sdi-search { + min-width: 20rem; + padding: 0.45rem 0.7rem; + border: 1px solid rgb(0 0 0 / 20%); + border-radius: 4px; + font: inherit; + font-size: 0.85rem; +} + +.sdi-spacer { + flex: 1; +} + +.sdi-total { + font-size: 0.8rem; + opacity: 0.75; +} + +.mono { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.8rem; +} + +.strong { + font-weight: 700; +} + +// The day the cycle is paid out — the accrued figure goes back to zero after it. +tr.sdi-row--posting { + background: rgb(46 125 50 / 8%); +} + +// Under the product minimum, so this day earns nothing at all. +tr.sdi-row--below { + opacity: 0.6; +} + +.sdi-sub--due { + color: #2e7d32; + font-weight: 700; + opacity: 1; +} + +.sdi-link { + border: 0; + background: none; + padding: 0; + font: inherit; + font-size: 0.8rem; + color: #1565c0; + text-decoration: underline; + cursor: pointer; +} diff --git a/src/app/savings-daily-interest/savings-daily-interest.component.ts b/src/app/savings-daily-interest/savings-daily-interest.component.ts new file mode 100644 index 000000000..952aae7d7 --- /dev/null +++ b/src/app/savings-daily-interest/savings-daily-interest.component.ts @@ -0,0 +1,292 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { + SavingsDailyInterestService, + SavingsInterestAccount, + SavingsInterestDay, + SavingsInterestProduct, + SavingsInterestRun, + SavingsInterestSummaryRow +} from './savings-daily-interest.service'; + +/** + * The savings daily interest ledger. + * + * Interest has always been a single number on the account screen with nothing behind it. This + * shows the working: pick a product, see its accounts and where each stands in its cycle, then + * open one account and read it day by day - what the balance was, what that day earned, and the + * running total climbing until the posting day pays it out and resets it to zero. + */ +@Component({ + selector: 'mifosx-savings-daily-interest', + templateUrl: './savings-daily-interest.component.html', + styleUrls: ['./savings-daily-interest.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class SavingsDailyInterestComponent implements OnInit { + private service = inject(SavingsDailyInterestService); + + activeTab: 'products' | 'summary' | 'accounts' | 'ledger' | 'runs' = 'products'; + + products: SavingsInterestProduct[] = []; + accounts: SavingsInterestAccount[] = []; + ledger: SavingsInterestDay[] = []; + runs: SavingsInterestRun[] = []; + + /** One row per account across every product. */ + summary: SavingsInterestSummaryRow[] = []; + summarySearch = ''; + + selectedProduct: SavingsInterestProduct | null = null; + selectedAccount: SavingsInterestAccount | null = null; + + loading = false; + message = ''; + messageIsError = false; + + ngOnInit(): void { + this.loadProducts(); + } + + // -------------------- loading -------------------- + + loadProducts(): void { + this.loading = true; + this.service.getProducts().subscribe({ + next: (data) => { + this.products = data ?? []; + this.loading = false; + }, + error: (err) => this.fail(err) + }); + } + + openProduct(product: SavingsInterestProduct): void { + this.selectedProduct = product; + this.selectedAccount = null; + this.activeTab = 'accounts'; + this.loading = true; + this.service.getAccountsForProduct(product.id).subscribe({ + next: (data) => { + this.accounts = data ?? []; + this.loading = false; + }, + error: (err) => this.fail(err) + }); + } + + openAccount(account: SavingsInterestAccount): void { + this.selectedAccount = account; + this.activeTab = 'ledger'; + this.loading = true; + this.service.getAccountLedger(account.savings_account_id).subscribe({ + next: (data) => { + this.ledger = data ?? []; + this.loading = false; + }, + error: (err) => this.fail(err) + }); + } + + /** + * Opens the day-by-day working for a row on the All Accounts list. + * + * The summary row carries what the ledger tab needs to caption itself, so it is mapped across + * rather than the account being re-fetched: the two views read the same ledger, and a second call + * would only be able to disagree with the row the user just clicked. + */ + openLedgerFor(row: SavingsInterestSummaryRow): void { + this.selectedAccount = { + savings_account_id: row.savings_account_id, + account_no: row.account_no, + client_name: row.client_name ?? '', + balance_date: row.as_on_date, + opening_balance: row.opening_balance, + closing_balance: row.closing_balance, + daily_interest: row.interest_today, + cycle_cumulative_interest: row.interest_this_cycle, + cycle_number: row.cycle_number, + cycle_start_date: row.cycle_start_date, + cycle_end_date: row.cycle_end_date, + below_min_balance: row.below_min_balance + }; + this.activeTab = 'ledger'; + this.loading = true; + this.service.getAccountLedger(row.savings_account_id).subscribe({ + next: (data) => { + this.ledger = data ?? []; + this.loading = false; + }, + error: (err) => this.fail(err) + }); + } + + loadRuns(): void { + this.activeTab = 'runs'; + this.loading = true; + this.service.getRuns().subscribe({ + next: (data) => { + this.runs = data ?? []; + this.loading = false; + }, + error: (err) => this.fail(err) + }); + } + + /** + * Every account on one line: today's balance and interest, the cycle it is in, and what it has + * been paid. + * + * Loaded unfiltered and narrowed in the browser. The whole book is one row per account, which is + * small enough to hold, and filtering locally means typing in the search box does not re-query. + */ + loadSummary(): void { + this.activeTab = 'summary'; + this.loading = true; + this.service.getSummary().subscribe({ + next: (data) => { + this.summary = data ?? []; + this.loading = false; + }, + error: (err) => this.fail(err) + }); + } + + get filteredSummary(): SavingsInterestSummaryRow[] { + const needle = this.summarySearch.trim().toLowerCase(); + if (!needle) { + return this.summary; + } + return this.summary.filter((r) => + `${r.account_no} ${r.client_name ?? ''} ${r.product_name}`.toLowerCase().includes(needle) + ); + } + + get summaryAccruedTotal(): number { + return this.filteredSummary.reduce((s, r) => s + Number(r.interest_this_cycle ?? 0), 0); + } + + get summaryPostedTotal(): number { + return this.filteredSummary.reduce((s, r) => s + Number(r.interest_posted_total ?? 0), 0); + } + + // -------------------- actions -------------------- + + /** + * The chain check and the unposted-total check. Nothing found is the answer you want: every day + * picks up where the last one left off, and the ledger still agrees with the accounts. + */ + runReconciliation(): void { + this.loading = true; + this.service.reconcile().subscribe({ + next: (result) => { + this.loading = false; + this.messageIsError = !result.clean; + this.message = result.clean + ? 'Reconciliation clean: the balance chain is unbroken and every cycle agrees with its account.' + : `${result.problemCount} problem(s) found: ${result.problems.slice(0, 3).join(' | ')}`; + }, + error: (err) => this.fail(err) + }); + } + + runToday(): void { + this.loading = true; + this.service.runForDate().subscribe({ + next: (result) => { + this.loading = false; + this.messageIsError = false; + this.message = `Run completed for ${result?.ranFor ?? 'today'}.`; + this.loadProducts(); + }, + error: (err) => this.fail(err) + }); + } + + runBackfill(): void { + this.loading = true; + this.service.backfill().subscribe({ + next: (result) => { + this.loading = false; + this.messageIsError = false; + this.message = `Backfill wrote ${result?.rowsWritten ?? 0} rows up to ${result?.upTo ?? 'today'}.`; + this.loadProducts(); + }, + error: (err) => this.fail(err) + }); + } + + // -------------------- display helpers -------------------- + + /** Fineract returns dates as [year, month, day]; month is 1-based. */ + formatDate(value: number[] | null | undefined): string { + if (!value || value.length < 3) { + return '—'; + } + const [ + year, + month, + day + ] = value; + return `${String(day).padStart(2, '0')}/${String(month).padStart(2, '0')}/${year}`; + } + + postingPeriodLabel(value: number): string { + switch (value) { + case 1: + return 'Daily'; + case 4: + return 'Monthly'; + case 5: + return 'Quarterly'; + case 6: + return 'Half-Yearly'; + case 7: + return 'Annual'; + default: + return 'Not set'; + } + } + + /** + * Accounts can carry a posting period of their own that differs from the product they were + * opened under, and the engine follows the account. Worth showing rather than hiding: a + * quarterly product whose accounts all post daily is almost never what was intended. + */ + productPostingMismatch(product: SavingsInterestProduct): boolean { + return this.accounts.length > 0 && this.selectedProduct?.id === product.id; + } + + backToProducts(): void { + this.activeTab = 'products'; + this.selectedProduct = null; + this.selectedAccount = null; + } + + backToAccounts(): void { + this.activeTab = 'accounts'; + this.selectedAccount = null; + } + + dismissMessage(): void { + this.message = ''; + } + + private fail(error: any): void { + this.loading = false; + this.messageIsError = true; + this.message = error?.error?.defaultUserMessage ?? error?.message ?? 'Request failed.'; + } +} diff --git a/src/app/savings-daily-interest/savings-daily-interest.module.ts b/src/app/savings-daily-interest/savings-daily-interest.module.ts new file mode 100644 index 000000000..8092f409e --- /dev/null +++ b/src/app/savings-daily-interest/savings-daily-interest.module.ts @@ -0,0 +1,22 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; +import { SavingsDailyInterestRoutingModule } from './savings-daily-interest-routing.module'; +import { SavingsDailyInterestComponent } from './savings-daily-interest.component'; + +@NgModule({ + imports: [ + SharedModule, + SavingsDailyInterestRoutingModule, + SavingsDailyInterestComponent + ] +}) +export class SavingsDailyInterestModule {} diff --git a/src/app/savings-daily-interest/savings-daily-interest.service.ts b/src/app/savings-daily-interest/savings-daily-interest.service.ts new file mode 100644 index 000000000..dfeb0fc45 --- /dev/null +++ b/src/app/savings-daily-interest/savings-daily-interest.service.ts @@ -0,0 +1,271 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** A savings product and where it stands in its interest posting timetable. */ +export interface SavingsInterestProduct { + id: number; + name: string; + short_name: string; + rate: number; + posting_period_enum: number; + run_at_time: string; + last_posting_date: number[] | null; + next_posting_date: number[] | null; + last_run_status: string | null; + is_active: boolean; + active_accounts: number; + accrued_this_cycle: number; +} + +/** One account's position in the product ledger, as at the latest day recorded. */ +export interface SavingsInterestAccount { + savings_account_id: number; + account_no: string; + client_name: string; + balance_date: number[]; + opening_balance: number; + closing_balance: number; + daily_interest: number; + cycle_cumulative_interest: number; + cycle_number: number; + cycle_start_date: number[]; + cycle_end_date: number[]; + below_min_balance: boolean; +} + +/** + * One row per savings account: where that account stands right now. + * + * The ledger behind this holds a row per account per day, because interest is worked out on the + * balance each day carried and a reversed backdated entry means every day after it is recomputed. + * This is the current position read off the top of that ledger — today's figures, the cycle the + * account is in, and what it has been paid so far — so a screen can show one line per account + * without reading a month of rows to find it. + */ +export interface SavingsInterestSummaryRow { + savings_account_id: number; + account_no: string; + client_id: number | null; + client_name: string | null; + office_name: string | null; + savings_product_id: number; + product_name: string; + currency_code: string; + + /** The latest day the ledger has accrued for. */ + as_on_date: number[]; + opening_balance: number; + closing_balance: number; + interest_today: number; + + cycle_number: number; + cycle_start_date: number[]; + cycle_end_date: number[]; + /** Accrued since the cycle began; resets to the day's interest after a posting. */ + interest_this_cycle: number; + days_to_posting: number; + is_posting_day: boolean; + posting_date: number[] | null; + + interest_rate: number; + days_in_year: number; + compounding_period_enum: number | null; + posting_period_enum: number | null; + calculation_type_enum: number | null; + min_balance_for_interest: number | null; + below_min_balance: boolean; + + /** Everything ever posted to the account from this ledger. */ + interest_posted_total: number; + last_posted_on: number[] | null; + /** How many days the ledger holds for the account. */ + days_accrued: number; + last_recomputed_on: string | null; +} + +/** + * One interest posting: what a member was actually paid, and on what. + * + * Read from the savings transaction rather than the ledger — the ledger clears its posted amount + * when the next cycle opens, so it cannot answer what was paid last quarter. The transaction is + * the payment and stays. + */ +export interface SavingsInterestPostingRow { + transaction_id: number; + savings_account_id: number; + account_no: string; + client_id: number | null; + client_name: string | null; + office_name: string | null; + savings_product_id: number; + product_name: string; + /** The product's own rhythm: 1 daily, 4 monthly, 5 quarterly, 6 half-yearly, 7 annual. */ + posting_period_enum: number; + interest_rate: number; + currency_code: string; + /** ISO yyyy-MM-dd — formatted server-side, because this endpoint serialises with a plain Gson. */ + posting_date: string; + interest_amount: number; + /** Balance the interest was credited onto, and what it became. */ + balance_before: number; + balance_after: number; + recorded_on: string | null; +} + +/** One day of one account: the working behind that day's interest. */ +export interface SavingsInterestDay { + balance_date: number[]; + opening_balance: number; + closing_balance: number; + daily_interest: number; + cycle_cumulative_interest: number; + cycle_number: number; + cycle_start_date: number[]; + cycle_end_date: number[]; + is_posting_day: boolean; + posted_amount: number | null; + savings_transaction_id: number | null; + below_min_balance: boolean; + interest_rate: number; + days_in_year: number; + min_balance_for_interest: number | null; + last_recomputed_on: number[] | null; +} + +/** What a posting run pooled and paid, and whether it finished. */ +export interface SavingsInterestRun { + id: number; + savings_product_id: number; + product_name: string; + posting_date: number[]; + account_count: number; + pooled_amount: number; + distributed_amount: number; + rounding_residual: number; + status: string; + failure_reason: string | null; + started_at: number[] | null; + completed_at: number[] | null; +} + +export interface SavingsInterestReconciliation { + clean: boolean; + problemCount: number; + problems: string[]; +} + +@Injectable({ + providedIn: 'root' +}) +export class SavingsDailyInterestService { + private http = inject(HttpClient); + + private readonly base = '/savingsdailyinterest'; + + getProducts(): Observable { + return this.http.get(`${this.base}/products`); + } + + getAccountsForProduct(productId: number): Observable { + return this.http.get(`${this.base}/products/${productId}/accounts`); + } + + /** + * One row per account, across every product — the whole book's current interest position. + * + * Narrow it with any of the filters; all three are optional and combine. + */ + getSummary(filters?: { + productId?: number; + officeName?: string; + accountNo?: string; + }): Observable { + let params = new HttpParams(); + if (filters?.productId) { + params = params.set('productId', filters.productId); + } + if (filters?.officeName) { + params = params.set('officeName', filters.officeName); + } + if (filters?.accountNo) { + params = params.set('accountNo', filters.accountNo); + } + return this.http.get(`${this.base}/summary`, { params }); + } + + /** + * Interest actually paid to members, one row per posting. + * + * `period` narrows to one posting rhythm (1 daily, 4 monthly, 5 quarterly, 6 half-yearly, + * 7 annual), matching the product's own posting period. + */ + getPostings(filters?: { + period?: number; + productId?: number; + fromDate?: string; + toDate?: string; + officeName?: string; + }): Observable { + let params = new HttpParams(); + if (filters?.period) { + params = params.set('period', filters.period); + } + if (filters?.productId) { + params = params.set('productId', filters.productId); + } + if (filters?.fromDate) { + params = params.set('fromDate', filters.fromDate); + } + if (filters?.toDate) { + params = params.set('toDate', filters.toDate); + } + if (filters?.officeName) { + params = params.set('officeName', filters.officeName); + } + return this.http.get(`${this.base}/postings`, { params }); + } + + getAccountLedger(accountId: number, limit = 400): Observable { + const params = new HttpParams().set('limit', limit); + return this.http.get(`${this.base}/accounts/${accountId}`, { params }); + } + + getRuns(productId?: number): Observable { + let params = new HttpParams(); + if (productId) { + params = params.set('productId', productId); + } + return this.http.get(`${this.base}/runs`, { params }); + } + + reconcile(): Observable { + return this.http.get(`${this.base}/reconcile`); + } + + /** Runs a day by hand: accrual for every account, then any product whose cycle ends that day. */ + runForDate(date?: string): Observable { + let params = new HttpParams(); + if (date) { + params = params.set('date', date); + } + return this.http.post(`${this.base}/run`, {}, { params }); + } + + /** Rebuilds the ledger for every account from activation up to the given date. */ + backfill(upTo?: string): Observable { + let params = new HttpParams(); + if (upTo) { + params = params.set('upTo', upTo); + } + return this.http.post(`${this.base}/backfill`, {}, { params }); + } +} diff --git a/src/app/savings/create-savings-account/create-savings-account.component.html b/src/app/savings/create-savings-account/create-savings-account.component.html index 7bdf5abed..f90406dab 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.html +++ b/src/app/savings/create-savings-account/create-savings-account.component.html @@ -12,9 +12,7 @@

Create Savings Account

-
- Pick a product, confirm currency, set digital channels and review before submit. -
+
Pick a product, confirm currency and review before submit.
@@ -76,33 +74,7 @@
- - - - - Digital - -
- @if (savingsAccountProductTemplate) { - - - } - -
- - - -
-
-
- - + @@ -119,16 +91,10 @@ [savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []" [submitting]="submitting" (submitEvent)="submit()" + (previousEvent)="prev()" > } - -
- -
diff --git a/src/app/savings/create-savings-account/create-savings-account.component.ts b/src/app/savings/create-savings-account/create-savings-account.component.ts index e5d7c2989..525c98f2b 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.ts +++ b/src/app/savings/create-savings-account/create-savings-account.component.ts @@ -62,8 +62,8 @@ export class CreateSavingsAccountComponent { savingsAccountProductTemplate: any; /** Currently visible wizard tab (0-indexed). */ selectedTabIndex = 0; - /** Total wizard tabs — Product, Currency, Digital, Preview. */ - private readonly totalTabs = 4; + /** Total wizard tabs — Product, Currency, Preview. */ + private readonly totalTabs = 3; /** Guards against duplicate submissions from a double-click while the create request is in flight. */ submitting = false; @@ -146,6 +146,13 @@ export class CreateSavingsAccountComponent { }); return; } + if (detailsStep.shareLinkingEnabled && !detailsStep.shareAccountId) { + this.alertService.alert({ + type: 'Error', + message: 'Share Linking is enabled — please select the share account to link before submitting.' + }); + return; + } const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; const monthDayFormat = 'dd MMMM'; @@ -208,6 +215,14 @@ export class CreateSavingsAccountComponent { type: 'Error', message: 'An account with this External ID already exists. Please use a unique External ID.' }); + } else if ( + errorCode === 'error.msg.savingsaccount.share.account.required' || + errorCode === 'error.msg.savingsaccount.share.account.not.active.for.client' + ) { + this.alertService.alert({ + type: 'Error', + message: 'Please select an active share account belonging to this client before submitting.' + }); } } }); diff --git a/src/app/savings/edit-savings-account-features/edit-savings-account-features.component.ts b/src/app/savings/edit-savings-account-features/edit-savings-account-features.component.ts index 00e9e1c85..1739355ba 100644 --- a/src/app/savings/edit-savings-account-features/edit-savings-account-features.component.ts +++ b/src/app/savings/edit-savings-account-features/edit-savings-account-features.component.ts @@ -43,7 +43,6 @@ const DEFAULT_FACILITY_CONFIG: Record = { neftFacility: false, rtgsFacility: false, interBankTotalCapFacility: false, - withdrawalFacility: false, depositFacilityKyc: false, virtualDebitCard: false }; @@ -64,7 +63,6 @@ const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [ { key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' }, { key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' }, { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' }, - { key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' }, { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' }, { key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' } ]; diff --git a/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.html b/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.html index c38a3f304..3d92c8391 100644 --- a/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.html +++ b/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.html @@ -14,7 +14,9 @@

Activate Savings Account

-

Set the activation date and opening balance to bring this account live.

+

+ Set the activation date to bring this account live -- funding happens afterwards, as a separate deposit. +

@@ -45,18 +47,6 @@ }
- - - Opening Balance - - ₹  - @if (minRequiredOpeningBalance) { - Minimum required: ₹{{ minRequiredOpeningBalance }} - } - @if (activateSavingsAccountForm.controls.openingBalance.hasError('min')) { - Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }} - } -
diff --git a/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.ts b/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.ts index e9add4f43..d7a41c94a 100644 --- a/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.ts +++ b/src/app/savings/saving-account-actions/activate-savings-account/activate-savings-account.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, OnInit, Input, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Dates } from 'app/core/utils/dates'; @@ -41,8 +41,6 @@ export class ActivateSavingsAccountComponent implements OnInit { private router = inject(Router); private settingsService = inject(SettingsService); - @Input() minRequiredOpeningBalance: number | null = null; - /** Minimum date allowed. */ minDate = new Date(2000, 0, 1); /** Maximum date allowed. */ @@ -76,18 +74,10 @@ export class ActivateSavingsAccountComponent implements OnInit { * Creates the activate savings account form. */ createActivateSavingsAccountForm() { - const minBalance = this.minRequiredOpeningBalance ?? 0; this.activateSavingsAccountForm = this.formBuilder.group({ activatedOnDate: [ '', Validators.required - ], - openingBalance: [ - minBalance, - [ - Validators.required, - Validators.min(minBalance) - ] ] }); } @@ -109,9 +99,6 @@ export class ActivateSavingsAccountComponent implements OnInit { dateFormat, locale }; - if (data.openingBalance === 0 || data.openingBalance === null) { - delete data.openingBalance; - } this.savingsService.executeSavingsAccountCommand(this.accountId, 'activate', data).subscribe(() => { this.router.navigate(['../../transactions'], { relativeTo: this.route }); }); diff --git a/src/app/savings/saving-account-actions/add-charge-savings-account/add-charge-savings-account.component.ts b/src/app/savings/saving-account-actions/add-charge-savings-account/add-charge-savings-account.component.ts index b82742f42..69d8c6465 100644 --- a/src/app/savings/saving-account-actions/add-charge-savings-account/add-charge-savings-account.component.ts +++ b/src/app/savings/saving-account-actions/add-charge-savings-account/add-charge-savings-account.component.ts @@ -21,6 +21,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router'; import { SavingsService } from '../../savings.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; +import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -43,6 +44,7 @@ export class AddChargeSavingsAccountComponent implements OnInit { private dateUtils = inject(Dates); private savingsService = inject(SavingsService); private settingsService = inject(SettingsService); + private facilityChargeFilter = inject(FacilityChargeFilterService); /** Minimum Due Date allowed. */ minDate = new Date(2000, 0, 1); @@ -68,7 +70,9 @@ export class AddChargeSavingsAccountComponent implements OnInit { */ constructor() { this.route.data.subscribe((data: { savingsAccountActionData: any }) => { - this.savingsChargeOptions = data.savingsAccountActionData.chargeOptions; + this.facilityChargeFilter + .excludeFacilityCharges(data.savingsAccountActionData.chargeOptions) + .subscribe((charges: any[]) => (this.savingsChargeOptions = charges)); }); this.savingAccountId = this.route.snapshot.params['savingAccountId']; } diff --git a/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.html b/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.html index 8f61d900d..b6d897fb1 100644 --- a/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.html +++ b/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.html @@ -10,6 +10,7 @@
+

This approves and activates the account in one step -- it goes straight to Active.

{{ 'labels.inputs.Approved On Date' | translate }} diff --git a/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.ts b/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.ts index 167f6cb2c..d1654136d 100644 --- a/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.ts +++ b/src/app/savings/saving-account-actions/approve-savings-account/approve-savings-account.component.ts @@ -81,8 +81,9 @@ export class ApproveSavingsAccountComponent implements OnInit { } /** - * Submits the form and approves the saving account, - * if successful redirects to the saving account. + * Submits the form, approves the saving account, then immediately activates it on the same + * date -- Approve and Activate are still two separate Fineract commands underneath (each shows + * up in the audit trail), but the officer only sees and clicks through one step. */ submit() { const approveSavingsAccountFormData = this.approveSavingsAccountForm.value; @@ -98,7 +99,14 @@ export class ApproveSavingsAccountComponent implements OnInit { locale }; this.savingsService.executeSavingsAccountCommand(this.accountId, 'approve', data).subscribe(() => { - this.router.navigate(['../../transactions'], { relativeTo: this.route }); + const activationData = { + activatedOnDate: approveSavingsAccountFormData.approvedOnDate, + dateFormat, + locale + }; + this.savingsService.executeSavingsAccountCommand(this.accountId, 'activate', activationData).subscribe(() => { + this.router.navigate(['../../transactions'], { relativeTo: this.route }); + }); }); } } diff --git a/src/app/savings/saving-account-actions/saving-account-actions.component.html b/src/app/savings/saving-account-actions/saving-account-actions.component.html index 45d3df9ca..aaf4f6fc4 100644 --- a/src/app/savings/saving-account-actions/saving-account-actions.component.html +++ b/src/app/savings/saving-account-actions/saving-account-actions.component.html @@ -13,9 +13,7 @@ } @if (actions['Activate']) { - + } @if (actions['Undo Approval']) { diff --git a/src/app/savings/savings-account-stepper/savings-account-charges-step/savings-account-charges-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-charges-step/savings-account-charges-step.component.ts index fc6e7b6b5..6124a0c32 100644 --- a/src/app/savings/savings-account-stepper/savings-account-charges-step/savings-account-charges-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-charges-step/savings-account-charges-step.component.ts @@ -36,6 +36,7 @@ import { TranslateService } from '@ngx-translate/core'; import { MatButton, MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; +import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service'; import { ChargesFilterPipe } from '../../../pipes/charges-filter.pipe'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { FormatNumberPipe } from '../../../pipes/format-number.pipe'; @@ -73,6 +74,7 @@ export class SavingsAccountChargesStepComponent implements OnInit { private dialog = inject(MatDialog); private dateUtils = inject(Dates); private translateService = inject(TranslateService); + private facilityChargeFilter = inject(FacilityChargeFilterService); private _savingsAccountProductTemplate: any; @@ -81,7 +83,9 @@ export class SavingsAccountChargesStepComponent implements OnInit { set savingsAccountProductTemplate(value: any) { this._savingsAccountProductTemplate = value; if (value) { - this.chargeData = value.chargeOptions || []; + this.facilityChargeFilter + .excludeFacilityCharges(value.chargeOptions) + .subscribe((charges: any[]) => (this.chargeData = charges)); this.chargesDataSource = (value.charges || []).map((charge: any) => ({ ...charge, id: charge.chargeId })); } } diff --git a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html index 1f8ce8870..e15a7cfa1 100644 --- a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html +++ b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html @@ -126,6 +126,28 @@ No + + @if (shareLinkingEnabled) { + + Default Share Account + + @for (account of activeShareAccounts; track account.id) { + {{ account.accountNo }} - {{ account.productName }} + } + + @if (!shareAccountId) { + Default Share Account is required + } + @if (activeShareAccounts.length === 0) { + This client has no active share account. Open one first, or turn off Share Linking. + } + + }
@@ -491,7 +513,7 @@ Age - Must be below 18 years + Must be below {{ minorAgeThreshold }} years diff --git a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts index cb93fd19e..d9fa352f4 100644 --- a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts @@ -16,6 +16,12 @@ import { MatDialog } from '@angular/material/dialog'; /** Custom Services */ import { SavingsService } from 'app/savings/savings.service'; import { ClientsService } from 'app/clients/clients.service'; +import { + AGE_OF_MAJORITY, + MINOR_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -72,6 +78,11 @@ export class SavingsAccountDetailsStepComponent implements OnInit { productTemplate: any = null; shareLinkingEnabled = false; + /** The client's active share accounts, offered as the "Default Share Account" once Share Linking + * is enabled — the reverse of the "Default Savings Account" picker Share Account creation + * already shows. Fetched once for the client this form is for, since it never changes mid-form. */ + activeShareAccounts: any[] = []; + shareAccountId: number | null = null; accountTypeOptions: string[] = []; customerSegmentOptions: string[] = []; selectedAccountType: string | null = null; @@ -80,6 +91,8 @@ export class SavingsAccountDetailsStepComponent implements OnInit { // ── Client info (auto-fetched) ───────────────────────────────────────── clientDob: Date | null = null; clientAge: number | null = null; + /** Exposed for tooltips so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; clientGender: string | null = null; primaryClientName = ''; primaryClientCif = ''; @@ -201,6 +214,10 @@ export class SavingsAccountDetailsStepComponent implements OnInit { const clientId = this.savingsAccountTemplate?.clientId; if (clientId) { this.primaryClientId = clientId; + this.savingsService.getClientActiveShareAccounts(clientId).subscribe({ + next: (accounts) => (this.activeShareAccounts = accounts || []), + error: () => (this.activeShareAccounts = []) + }); this.clientsService.getClientData(String(clientId)).subscribe((client: any) => { this.primaryClientName = client?.displayName ?? client?.fullname ?? ''; this.primaryClientCif = client?.cifNumber ?? client?.cifNo ?? ''; @@ -282,16 +299,16 @@ export class SavingsAccountDetailsStepComponent implements OnInit { isTileDisabled(value: string): boolean { if (this.readOnly) return true; - if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; - if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; + if (value === 'MINOR') return !isMinorAge(this.clientAge); + if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge); return false; } getTileTooltip(value: string): string { - if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) { - return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`; + if (value === 'MINOR' && !isMinorAge(this.clientAge)) { + return `Age ${this.clientAge} yrs — Minor only allowed below years`; } - if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) { + if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) { return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`; } if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age'; @@ -306,10 +323,10 @@ export class SavingsAccountDetailsStepComponent implements OnInit { } if (this.clientAge === null) return; - if (this.clientAge < 18) { + if (isMinorAge(this.clientAge)) { this.selectedAccountStatus = 'MINOR'; this.minorDob = this.clientDob; - } else if (this.clientAge >= 60) { + } else if (isSeniorCitizenAge(this.clientAge)) { this.selectedAccountStatus = 'SENIOR_CITIZEN'; } else if (this.clientGender === 'female') { this.selectedAccountStatus = 'INDIVIDUAL_FEMALE'; @@ -442,6 +459,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit { if (ea.shareLinkingEnabled !== undefined) { this.shareLinkingEnabled = !!ea.shareLinkingEnabled; } + if (ea.shareAccountId) { + this.shareAccountId = ea.shareAccountId; + } this.cdr.detectChanges(); } @@ -597,7 +617,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit { const dob = this.minorDob ?? this.clientDob; if (!dob) return ''; const d = new Date(dob); - d.setFullYear(d.getFullYear() + 18); + d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY); return this.dateUtils.formatDate(d, 'M/d/yyyy') || ''; } @@ -661,6 +681,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit { } value.shareLinkingEnabled = this.shareLinkingEnabled; + if (this.shareLinkingEnabled && this.shareAccountId) { + value.shareAccountId = this.shareAccountId; + } return value; } diff --git a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.html b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.html index 5b0970996..d266a76ed 100644 --- a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.html +++ b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.html @@ -15,7 +15,6 @@

Digital & Account Features

Toggle the facilities available for this account (as per RBI guidelines)

- FROM PRODUCT
@@ -175,18 +174,6 @@
-
-
-
- Withdrawal Facility - {{ facilityConfig.withdrawalFacility ? 'Enabled' : 'Disabled' }} -
- -
-
diff --git a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts index 30da85e32..b3aac19d9 100644 --- a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts @@ -24,7 +24,6 @@ const DEFAULT_FACILITY_CONFIG = { neftFacility: false, rtgsFacility: false, interBankTotalCapFacility: false, - withdrawalFacility: false, depositFacilityKyc: false, virtualDebitCard: false }; @@ -50,12 +49,11 @@ export class SavingsAccountDigitalStepComponent { } } + // Note: a new account always starts with every digital facility off, even if the product has + // its own defaults configured -- those are enabled deliberately, one at a time, after the + // account is created and active, not inherited automatically at opening. @Input() - set savingsAccountProductTemplate(value: any) { - if (!this.hasExistingAccount && value?.accountFacilityConfig) { - this.facilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...value.accountFacilityConfig }; - } - } + savingsAccountProductTemplate: any; get savingsAccountDigital() { return { accountFacilityConfig: { ...this.facilityConfig } }; diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html index 44831e2d5..d67ed5cbb 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html @@ -585,25 +585,30 @@
@if (!hideButtons) {
- - +
+ + +
} diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss index cffbf47eb..8c772a802 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss @@ -13,17 +13,21 @@ padding: 0.5rem 0; } -/* Submit + Cancel row at the bottom of the preview. Previous lives in the - parent wizard's tab action bar. */ +/* Previous, Cancel & Submit row at the bottom of the preview. */ .preview-submit-row { display: flex; - justify-content: flex-end; + justify-content: space-between; align-items: center; - gap: 12px; margin-top: 18px; padding-top: 16px; border-top: 1px solid #f1f5f9; + &__right { + display: flex; + align-items: center; + gap: 12px; + } + button { height: 42px !important; border-radius: 10px !important; diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts index f8837abfc..c86dc9198 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts @@ -73,7 +73,6 @@ export class SavingsAccountPreviewStepComponent implements OnChanges { { key: 'neftFacility', label: 'NEFT Facility' }, { key: 'rtgsFacility', label: 'RTGS Facility' }, { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' }, - { key: 'withdrawalFacility', label: 'Withdrawal Facility' }, { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' }, { key: 'virtualDebitCard', label: 'Virtual Debit Card' } ]; @@ -117,6 +116,9 @@ export class SavingsAccountPreviewStepComponent implements OnChanges { /** Form submission event */ @Output() submitEvent = new EventEmitter(); + /** Goes back a tab in the parent wizard — kept here so Previous renders in the same row as + * Cancel/Submit instead of a separate action bar underneath. */ + @Output() previousEvent = new EventEmitter(); ngOnChanges(): void { if (this.activeClientMembers?.length > 0) { @@ -124,13 +126,11 @@ export class SavingsAccountPreviewStepComponent implements OnChanges { } } - /** Returns user-modified digital config (from digital step), falling back to product template */ + /** Digital facility config as actually captured by a digital step in this wizard -- no fallback + * to the product's own defaults, since account creation no longer has a digital step at all and + * must not preview facilities the officer never actually chose. */ get digitalFacilityConfig(): any { - return ( - this.savingsAccountDigital?.accountFacilityConfig ?? - this.savingsAccountProductTemplate?.accountFacilityConfig ?? - null - ); + return this.savingsAccountDigital?.accountFacilityConfig ?? null; } get hasDigitalSection(): boolean { diff --git a/src/app/savings/savings-account-view/general-tab/general-tab.component.html b/src/app/savings/savings-account-view/general-tab/general-tab.component.html index 68ddc9f50..29831808d 100644 --- a/src/app/savings/savings-account-view/general-tab/general-tab.component.html +++ b/src/app/savings/savings-account-view/general-tab/general-tab.component.html @@ -35,6 +35,12 @@ {{ savingsAccountData.clientName || savingsAccountData.groupName || '—' }}
+ @if (savingsAccountData.shareLinkingEnabled) { +
+ {{ 'labels.inputs.Linked Share Account' | translate }} + {{ savingsAccountData.shareAccountNo || 'Unassigned' }} +
+ } @if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
{{ 'labels.inputs.Activated On' | translate }} diff --git a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html index ee2fe2d98..0efb2fbcf 100644 --- a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html +++ b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html @@ -60,8 +60,8 @@ account_balance
-
LincPay Co-operative Bank Ltd.
-
Account Statement
+
{{ bankName }}
+
{{ bankSubtitle }}
@@ -70,6 +70,25 @@
+ + @if (branchName || ifscCode || micrCode) { +
+ @if (branchName) { + {{ branchName }} Branch + } + @if (branchAddress) { + {{ branchAddress }} + } + @if (ifscCode) { + IFSC: {{ ifscCode }} + } + @if (micrCode) { + MICR: {{ micrCode }} + } +
+ } +