diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 000000000..f953cb223 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,296 @@ +kind: pipeline +type: docker +name: auto-deploy + +trigger: + branch: + - dev + event: + - push + +steps: + # ============================================================ + # DOCKER BUILD & PUSH + # + # Angular build (npm ci + ng build) Dockerfile ke andar + # multi-stage me hota hai, isliye alag build step nahi chahiye. + # ============================================================ + - name: docker-build-push + image: docker + + volumes: + - name: dockersock + path: /var/run/docker.sock + + commands: + - | + set -e + + # -------------------------------------------------------- + # Repository name comes from Drone + # -------------------------------------------------------- + REPO="$${DRONE_REPO_NAME}" + REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')" + + IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}" + + echo "==========================================" + echo "DOCKER BUILD & PUSH" + echo "==========================================" + echo "Repository : $$REPO" + echo "Build : $${DRONE_BUILD_NUMBER}" + echo "Image : $$IMAGE" + echo "==========================================" + + # -------------------------------------------------------- + # Harbor login + # -------------------------------------------------------- + echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \ + docker login harbor.lincpay:8082 \ + -u 'robot$lincpay+drone' \ + --password-stdin + + # -------------------------------------------------------- + # Build + # -------------------------------------------------------- + echo "Building Docker image..." + + docker build \ + -t "$$IMAGE" \ + . + + # -------------------------------------------------------- + # Push + # -------------------------------------------------------- + echo "Pushing Docker image..." + + docker push "$$IMAGE" + + echo "Docker image pushed successfully." + + # ============================================================ + # DEPLOY + # ============================================================ + - name: deploy + image: docker + + volumes: + - name: dockersock + path: /var/run/docker.sock + + - name: envfiles + path: /platform-env + + commands: + - | + set -e + + # ======================================================== + # REPOSITORY / SERVICE / IMAGE + # ======================================================== + + REPO="$${DRONE_REPO_NAME}" + REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')" + + SERVICE_NAME="$${REPO}_app" + + IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}" + + echo "==========================================" + echo "DEPLOYMENT" + echo "==========================================" + echo "Repository : $$REPO" + echo "Service : $$SERVICE_NAME" + echo "Image : $$IMAGE" + echo "Placement : node.labels.environment == dev2" + echo "==========================================" + + + # ======================================================== + # VALIDATION + # ======================================================== + + if [ -z "$$REPO" ]; then + echo "ERROR: DRONE_REPO_NAME is empty." + exit 1 + fi + + if [ -z "$$SERVICE_NAME" ]; then + echo "ERROR: Service name is empty." + exit 1 + fi + + if [ -z "$$IMAGE" ]; then + echo "ERROR: Docker image is empty." + exit 1 + fi + + echo "Repository verified : $$REPO" + echo "Service verified : $$SERVICE_NAME" + echo "Image verified : $$IMAGE" + + + # ======================================================== + # HARBOR LOGIN + # + # Zaroori hai taaki --with-registry-auth worker node (dev2) ko + # valid Harbor credentials propagate kare — warna cross-node pull + # "No such image" / "no basic auth credentials" se fail hota hai. + # ======================================================== + + echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \ + docker login harbor.lincpay:8082 \ + -u 'robot$lincpay+drone' \ + --password-stdin + + + # ======================================================== + # EXISTING SERVICE + # ======================================================== + + if docker service ls --format '{{.Name}}' | grep -Fxq "$$SERVICE_NAME"; then + + echo "" + echo "==========================================" + echo "EXISTING SERVICE" + echo "==========================================" + echo "Service: $$SERVICE_NAME" + + + # ------------------------------------------------------ + # Check whether dev constraint already exists + # ------------------------------------------------------ + + CONSTRAINT_EXISTS=$$(docker service inspect "$$SERVICE_NAME" \ + --format '{{range .Spec.TaskTemplate.Placement.Constraints}}{{println .}}{{end}}' \ + | grep -Fx 'node.labels.environment == dev2' || true) + + + # ------------------------------------------------------ + # Add constraint if missing + # ------------------------------------------------------ + + if [ -z "$$CONSTRAINT_EXISTS" ]; then + + echo "Constraint missing." + echo "Adding: node.labels.environment == dev2" + + docker service update \ + --constraint-add 'node.labels.environment == dev2' \ + "$$SERVICE_NAME" + + else + + echo "Constraint already exists." + + fi + + + # ------------------------------------------------------ + # Update image + # ------------------------------------------------------ + + echo "Updating service image..." + + docker service update \ + --image "$$IMAGE" \ + --replicas 1 \ + --with-registry-auth \ + "$$SERVICE_NAME" + + + # ======================================================== + # NEW SERVICE + # ======================================================== + + else + + echo "" + echo "==========================================" + echo "NEW SERVICE" + echo "==========================================" + echo "Creating: $$SERVICE_NAME" + + + # ------------------------------------------------------ + # Traefik rule + # + # Construct it without allowing Drone to consume + # the shell backticks. + # ------------------------------------------------------ + + TRAEFIK_RULE='Host(`'$$REPO'.lincpay`)' + + echo "Traefik rule: $$TRAEFIK_RULE" + + + # ------------------------------------------------------ + # Create service + # ------------------------------------------------------ + + docker service create \ + --name "$$SERVICE_NAME" \ + --network edge-network \ + --replicas 1 \ + --constraint 'node.labels.environment == dev2' \ + --with-registry-auth \ + --env-file /platform-env/global.env \ + --env-file /platform-env/dev.env \ + --label traefik.enable=true \ + --label "traefik.docker.network=edge-network" \ + --label "traefik.http.routers.$$REPO.rule=$$TRAEFIK_RULE" \ + --label "traefik.http.services.$$REPO.loadbalancer.server.port=80" \ + "$$IMAGE" + + fi + + + # ======================================================== + # FINAL STATUS + # ======================================================== + + echo "" + echo "==========================================" + echo "FINAL SERVICE STATUS" + echo "==========================================" + + docker service ls \ + --filter "name=$$SERVICE_NAME" + + + echo "" + echo "==========================================" + echo "PLACEMENT CONSTRAINT" + echo "==========================================" + + docker service inspect "$$SERVICE_NAME" \ + --format '{{json .Spec.TaskTemplate.Placement}}' + + + echo "" + echo "==========================================" + echo "SERVICE TASKS" + echo "==========================================" + + docker service ps "$$SERVICE_NAME" --no-trunc + + + echo "" + echo "==========================================" + echo "DEPLOYMENT COMPLETE" + echo "==========================================" + +volumes: + # ============================================================ + # DOCKER SOCKET + # ============================================================ + - name: dockersock + host: + path: /var/run/docker.sock + + # ============================================================ + # ENVIRONMENT FILES + # ============================================================ + - name: envfiles + host: + path: /opt/platform/env 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/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/src/app/accounting/accounting-routing.module.ts b/src/app/accounting/accounting-routing.module.ts index ed14fee6f..928b95931 100644 --- a/src/app/accounting/accounting-routing.module.ts +++ b/src/app/accounting/accounting-routing.module.ts @@ -72,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 = [ @@ -99,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, @@ -110,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, @@ -194,6 +197,11 @@ const routes: Routes = [ 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' }, @@ -244,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: '', @@ -285,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: '', @@ -332,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 de5ec4653..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 + }} +
+
+ } @@ -113,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 69b896779..2143fbd22 100644 --- a/src/app/accounting/accounting.service.ts +++ b/src/app/accounting/accounting.service.ts @@ -116,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/disabled-features.ts b/src/app/accounting/disabled-features.ts new file mode 100644 index 000000000..32c9c30eb --- /dev/null +++ b/src/app/accounting/disabled-features.ts @@ -0,0 +1,43 @@ +/** + * 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 { inject } from '@angular/core'; +import { Router, CanActivateFn } from '@angular/router'; + +/** + * Accounting features switched off for this installation. + * + * Nothing is deleted — the components, routes and services all stay in place, and flipping a flag + * back to `false` brings the feature back with its tile and its route. The matching API block lives + * in DisabledFeatureApiFilter on the backend, so a hidden feature cannot be reached by calling the + * endpoint directly either. + */ +export const DISABLED_ACCOUNTING_FEATURES = { + createJournalEntries: true, + frequentPostings: true, + closingEntries: true, + accruals: true, + provisioningEntries: true, + accountingRules: true +}; + +/** + * Blocks a route belonging to a disabled feature, for anyone who reaches it by typing the URL + * rather than through a tile. `data.feature` names the flag to check. + */ +export const accountingFeatureEnabledGuard: CanActivateFn = (route) => { + const router = inject(Router); + const feature = route.data?.['feature'] as keyof typeof DISABLED_ACCOUNTING_FEATURES | undefined; + + if (!feature || !DISABLED_ACCOUNTING_FEATURES[feature]) { + return true; + } + + router.navigate(['/accounting'], { replaceUrl: true }); + return false; +}; diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html index d6e56e66e..8c6cb227e 100644 --- a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html @@ -49,119 +49,144 @@ } @if (glStatus && !glError) { - -
- account_balance -
-
Cash GL Account
-
({{ glStatus.glCode }}) {{ glStatus.glAccountName }}
- @if (glHasBalance) { + @if (todayAlreadyOpen) { + +
+ lock +
+
Business Day Already Open
+
+ {{ todayOpenInfo.businessDate }} — opened by {{ todayOpenInfo.openedByName }}, opening cash ₹{{ + todayOpenInfo.openingCash | number: '1.2-2' + }} +
- Already holds - ₹{{ glTarget.toLocaleString('en-IN') }} - — count the drawer to exactly this amount. + This branch has already opened today's business day — counting the drawer again here would only be rejected + on submit. Deposits, withdrawals and closing today's day happen from Day Register instead.
- } @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') }} + } @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. +
+ }
- } -
- @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? - - +
+
Denomination
+
Count
+
Amount
+
+ @for (d of allDenoms; track d.id) { +
+
+ {{ d.label }} {{ d.kind }} +
+
+ + + +
+
₹{{ subtotalFor(d).toLocaleString('en-IN') }}
+
+ }
- } - @if (errorMsg) { -
{{ errorMsg }}
- } - - @if (successMsg) { -
- check_circle -
- {{ successMsg }} -
- + +
+
+ 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 (!successMsg) { -
- -
+ @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 index 6dcf31aa0..c68d75abd 100644 --- a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss @@ -115,6 +115,10 @@ color: #64748b; line-height: 1.4; } + + button { + margin-top: 10px; + } } .mcd-grid { @@ -171,7 +175,11 @@ } &__input { - width: 48px; + /* 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; 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 index a66fb5651..2509a0fce 100644 --- a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts @@ -66,6 +66,14 @@ export class MigrateCashDenominationComponent implements OnInit { 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 = {}; @@ -118,6 +126,8 @@ export class MigrateCashDenominationComponent implements OnInit { this.result = null; this.successMsg = ''; this.errorMsg = ''; + this.todayAlreadyOpen = false; + this.todayOpenInfo = null; if (!this.officeId) return; this.glLoading = true; @@ -131,6 +141,15 @@ export class MigrateCashDenominationComponent implements OnInit { 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 { @@ -149,6 +168,12 @@ export class MigrateCashDenominationComponent implements OnInit { 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); } 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 728b7cf77..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 @@ -162,7 +162,18 @@
{{ openingBalancesData.glAccounts[i].glAccountCode }}
-
{{ openingBalancesData.glAccounts[i].glAccountName }}
+
+ {{ openingBalancesData.glAccounts[i].glAccountName }} + @if (isDenominationManaged(openingBalancesData.glAccounts[i].glAccountId)) { + + lock + Migrate Cash Denomination + + } +
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 14cf2ca5a..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 @@ -219,6 +219,33 @@ 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; } 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 b1636d9df..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 @@ -17,6 +17,7 @@ import { } 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'; @@ -43,7 +44,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; ...STANDALONE_SHARED_IMPORTS, GlAccountDisplayComponent, CurrencyPipe, - MatIconModule + MatIconModule, + MatTooltipModule ] }) export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { @@ -74,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; @@ -95,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(); + } + }); + }); } /** @@ -136,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); } /** @@ -176,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..76bbc1905 100644 --- a/src/app/aml/aml.service.ts +++ b/src/app/aml/aml.service.ts @@ -122,6 +122,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 }); diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index e5db06861..65d27eb6e 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -49,6 +49,18 @@
} + @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) {
@@ -703,10 +715,10 @@ · {{ timeOnly(d.closedAt) }} } - {{ d.closingCash != null ? '₹' + (d.closingCash | number: '1.2-2') : '—' }} - {{ d.bookCash != null ? '₹' + (d.bookCash | number: '1.2-2') : '—' }} + {{ hasValue(d.closingCash) ? '₹' + (d.closingCash | number: '1.2-2') : '—' }} + {{ hasValue(d.bookCash) ? '₹' + (d.bookCash | number: '1.2-2') : '—' }} - @if (d.cashDifference == null) { + @if (!hasValue(d.cashDifference)) { — } @else if (d.cashDifference === 0) { Tallied diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss index fa49ed69d..c97aebcc9 100644 --- a/src/app/cashier/branch-day/branch-day.component.scss +++ b/src/app/cashier/branch-day/branch-day.component.scss @@ -30,6 +30,36 @@ } } +/* 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; diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts index 579a3364a..da476f658 100644 --- a/src/app/cashier/branch-day/branch-day.component.ts +++ b/src/app/cashier/branch-day/branch-day.component.ts @@ -10,7 +10,7 @@ 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 } from '@angular/router'; +import { ActivatedRoute, RouterLink } from '@angular/router'; import { catchError, forkJoin, map, of, switchMap } from 'rxjs'; import { CashierService } from '../cashier.service'; @@ -61,7 +61,8 @@ interface BranchDay { imports: [ CommonModule, FormsModule, - MatIconModule + MatIconModule, + RouterLink ] }) export class BranchDayComponent implements OnInit { @@ -85,6 +86,12 @@ export class BranchDayComponent implements OnInit { 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; @@ -202,6 +209,7 @@ export class BranchDayComponent implements OnInit { this.today = res?.today ?? ''; this.canOperate = !!res?.canOperate; this.openDate = this.today; + this.checkFirstOpenCashMigration(); }); this.service @@ -212,9 +220,42 @@ export class BranchDayComponent implements OnInit { }); } + /** + * 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; + 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 { diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.html b/src/app/cashier/closing-cash-report/closing-cash-report.component.html index b6faf9d5c..c964fd540 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.html +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.html @@ -303,8 +303,8 @@ Voucher No Date - A/C No - A/C Name + Debit + Credit Payment Type Teller DR (₹) @@ -326,8 +326,25 @@
{{ r.txnTime }}
} - {{ r.acNo }} - {{ r.acName }} + + +
{{ r.debitParty || r.acName }}
+ @if (r.debitAccount || r.acNo) { +
{{ r.debitAccount || r.acNo }}
+ } + + + @if (r.creditParty) { +
{{ r.creditParty }}
+ @if (r.creditAccount) { +
{{ r.creditAccount }}
+ } + } @else { + — + } + @if (r.paymentType) { {{ r.paymentType }} diff --git a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts index 50e51c402..89206900c 100644 --- a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts +++ b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts @@ -71,8 +71,11 @@ export interface CashierIssueDdDialogData {
+
- Auto-Selected Leaflet + DD Number Being Issued {{ data.leaflet.ddNumber }} {{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}
@@ -233,9 +236,7 @@ export interface CashierIssueDdDialogData { - +
`, diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 98ae9647e..de55bc0f0 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -178,7 +178,7 @@ (row.dbCr = 'Cr') " > - @for (a of acTypOptions; track a) { + @for (a of acTypOptionsFor(row); track a) { } @@ -432,8 +432,14 @@ : 'Auto-selects next available leaflet and opens walk-in purchaser form' " > - post_add - Fill DD Details & Send for Approval + {{ + row.ddDetails ? 'edit_note' : 'post_add' + }} + + {{ + row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details' + }}
@@ -1529,7 +1535,7 @@ (row.dbCr = 'Cr') " > - @for (a of acTypOptions; track a) { + @for (a of acTypOptionsFor(row); track a) { } @@ -1756,7 +1762,7 @@ " > post_add - Fill DD Details & Send for Approval + Fill DD Details
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 5a49e956d..2ea622557 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -57,6 +57,27 @@ interface TxnRow { * account — governs the share redemption (see submitShareRedemptionTransfer()). */ resolutionNumber?: string; meetingDate?: string; + /** + * Beneficiary and leaflet captured in the DD dialog, held on the row until Save. + * + * The dialog used to submit the request itself, which left the worksheet's own Save button doing + * nothing for a DD row — the clerk filled the dialog, the request was already gone, and pressing + * Save afterwards had no effect. Filling in details and committing them are two different + * actions, so the dialog now only records what was entered and Save is what sends it. + */ + ddDetails?: { + leafletId: number; + ddNumber: string; + beneficiaryName: string; + payableAtBranch: string; + payableAtCity: string; + /** Present only for a walk-in cash draft. */ + purchaser?: { name: string; address: string; aadhaar: string }; + /** Note-and-coin breakdown, cash drafts only. */ + denominations?: { [key: string]: number }; + fundingAccountId?: number; + fundingClientId?: number; + }; } interface Denomination { @@ -1617,6 +1638,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { private lastLoadedAcNo = ''; private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>(); cashVaultGlId: number | null = null; + /** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL + * entry has to be filed against THIS account, not whichever account the teller typed in, since + * that account is never the one physical notes actually moved through (see submitGlJournalEntry). + * Both resolved dynamically off whatever this tenant's own financial-activity mapping names as + * its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */ + cashVaultGlCode: string | null = null; + cashVaultGlName: string | null = null; // Joint holder expanded details (keyed by index) expandedHolder: number | null = null; @@ -1671,6 +1699,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { 'GL', 'LA' ]; + + /** + * Account types a clerk may fund a demand draft from. + * + * A clerk raises a draft against a member's own balance, so the only sensible sources are the + * accounts a member actually holds money in. GL was offered here too, and picking it quietly + * switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a + * note-and-coin count, neither of which a clerk debiting an account has. The option is removed + * rather than validated afterwards, so the wrong path cannot be reached at all. + */ + private static readonly DD_CLERK_ACTYPS = [ + 'SD', + 'CA' + ]; + + /** + * The account types this particular row may use. + * + * Every other row keeps the full list; only a clerk's DD row is narrowed, because that is the + * only row where the account type silently decides which funding flow runs. + */ + acTypOptionsFor(row: TxnRow): string[] { + if (this.isClerk && row.typ === 'DD') { + return TransactionEntryComponent.DD_CLERK_ACTYPS; + } + return this.acTypOptions; + } dbCrOptions = [ 'Dr', 'Cr' @@ -2230,12 +2285,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // panel never appears and the row would post as an ordinary entry with the charges silently // dropped. // - // A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no - // longer offers cash, so defaulting to it would open the field on a value it cannot show. - // isDdCashGlRow() still holds for them through AC.TP = GL. + // A clerk funds a draft by debiting a member's own account, so their row opens on savings and + // is debited, and GL is not on their AC.TP list at all (see acTypOptionsFor). Opening them on + // GL put the row on the cash path, which asks for a walk-in purchaser and a note-and-coin + // count that a clerk debiting an account does not have. case 'DD': - row.dbCr = 'Cr'; - row.acTyp = 'GL'; + row.dbCr = this.isClerk ? 'Dr' : 'Cr'; + row.acTyp = this.isClerk ? 'SD' : 'GL'; row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId(); row.chequeNo = ''; row.cheqDate = ''; @@ -2416,13 +2472,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } }); + // Recorded on the row, not sent. Save is what commits the worksheet, and a DD row that + // submitted itself here left that button with nothing to do — so the clerk had no way to + // review the entered details, and no way to abandon them either. dialogRef.afterClosed().subscribe((result) => { if (!result) return; - if (accountDebit) { - this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!); - } else { - this.submitWalkinCashDd(row, leaflet, result); - } + row.ddDetails = { + leafletId: leaflet.id, + ddNumber: leaflet.ddNumber, + beneficiaryName: result.beneficiaryName, + payableAtBranch: result.payableAtBranch, + payableAtCity: result.payableAtCity, + purchaser: accountDebit + ? undefined + : { + name: result.walkinPurchaserName, + address: result.walkinPurchaserAddress, + aadhaar: result.walkinPurchaserAadhaar + }, + fundingAccountId: accountDebit ? fundingAccountId! : undefined, + fundingClientId: accountDebit ? fundingClientId! : undefined + }; + this.showAlert('success', 'DD Details Captured', [ + `DD No. ${leaflet.ddNumber} · in favour of ${result.beneficiaryName}.`, + accountDebit + ? `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} once a checker approves.` + : `₹${this.ddTotalPayableFor(row)} to be collected at the counter.`, + 'Press Save to send this draft for approval.' + ]); }); }, error: (err) => { @@ -2480,6 +2557,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { row.amount = null; row.ddCharges = null; row.description = ''; + // Cleared with the row: leaving the captured details behind would let a second Save raise + // the same draft again against a leaflet that has already been used. + row.ddDetails = undefined; }, error: (err) => { this.saving = false; @@ -2488,6 +2568,53 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }); } + /** + * Sends the drafts on the worksheet for approval. + * + * Each row carries the beneficiary details the dialog captured; a row that was never filled in is + * refused by name rather than skipped, because a clerk who pressed Save expecting one draft and + * got none has no way of telling which row was missing. + */ + private saveDdRows(ddRows: TxnRow[]): void { + const unfilled = ddRows.filter((r) => !r.ddDetails); + if (unfilled.length > 0) { + this.showAlert('error', 'DD Details Not Entered', [ + unfilled.length === 1 + ? 'One DD row has no beneficiary details yet.' + : `${unfilled.length} DD rows have no beneficiary details yet.`, + 'Use "Fill DD Details & Send for Approval" on each DD row first, then press Save.' + ]); + return; + } + + for (const row of ddRows) { + const details = row.ddDetails!; + const leaflet = { id: details.leafletId, ddNumber: details.ddNumber }; + if (this.isDdAccountDebitRow(row)) { + this.submitAccountDebitDd( + row, + leaflet, + { + beneficiaryName: details.beneficiaryName, + payableAtBranch: details.payableAtBranch, + payableAtCity: details.payableAtCity + }, + details.fundingAccountId!, + details.fundingClientId! + ); + } else { + this.submitWalkinCashDd(row, leaflet, { + beneficiaryName: details.beneficiaryName, + payableAtBranch: details.payableAtBranch, + payableAtCity: details.payableAtCity, + walkinPurchaserName: details.purchaser?.name, + walkinPurchaserAddress: details.purchaser?.address, + walkinPurchaserAadhaar: details.purchaser?.aadhaar + }); + } + } + } + private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void { // Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked // right but matched nothing, so every cash DD went in with no breakdown at all and its money @@ -2557,6 +2684,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { row.amount = null; row.ddCharges = null; row.description = ''; + // See the account-debit path: a second Save must not re-raise a draft already issued. + row.ddDetails = undefined; this.clearDenominations(); }, error: (err) => { @@ -3121,6 +3250,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { byName(/vault/i) ?? byName(/cash/i) ?? items.find((a: any) => a.glAccountData?.type?.value === 'ASSET'); if (found?.glAccountData?.id) { this.cashVaultGlId = found.glAccountData.id; + this.cashVaultGlCode = found.glAccountData.glCode ?? null; + this.cashVaultGlName = found.glAccountData.name ?? found.financialActivityData?.name ?? null; } }); } @@ -3189,7 +3320,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Step 1: search on the correct endpoint for this account type this.http .get( - `${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}` + `${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}${cfg.extraListQueryParams ? `&${cfg.extraListQueryParams}` : ''}` ) .pipe(catchError(() => of(null))) .subscribe((res: any) => { @@ -3278,6 +3409,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { matchField: string; detailParams: string; supportsPagination: boolean; + /** Extra fixed query params (e.g. "usage=1") appended to every list-search request for this + * account type — used to keep Header GL accounts out of the results a clerk/cashier can pick + * for a transaction; a Header can never receive a posting, so it should never be offered. */ + extraListQueryParams?: string; } { const t = acTyp?.toUpperCase(); if (t === 'LA') { @@ -3331,7 +3466,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { listQueryParam: 'searchParam', matchField: 'glCode', detailParams: '?fetchRunningBalance=true', - supportsPagination: false + supportsPagination: false, + // Detail-only: a Header account is a pure grouping node and can never receive a + // transaction, so it must never be a selectable match here in the first place. + extraListQueryParams: 'usage=1' }; } if (t === 'CA' || t === 'CB') { @@ -4361,6 +4499,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } onSave(): void { + // Demand drafts commit here like everything else on the worksheet. Checked first because a DD + // row shares its shape with the ordinary entries below — without this it would fall through and + // post as a plain transaction, with the draft never raised and the charges silently dropped. + const ddRows = this.rows.filter((r) => this.isDdRow(r)); + if (ddRows.length > 0) { + this.saveDdRows(ddRows); + return; + } + // Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution. 'Fund Transfer' // is the same flow under a second TYPE label — onTypChange() already defaults it identically to // 'Enter Bank Transfer', so submission has to recognise both or a share redemption or bank @@ -5641,11 +5788,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { count: this.countFor(d), subtotal: this.subtotalFor(d) })); + // Filed against the Cash Vault GL, not the account the teller selected on screen: that + // selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and + // never the one whose physical notes actually changed hands. Tagging the breakdown to it + // instead of the vault is why this cash never reached the cashier/vault position. this.http .post('/cashier/gl-denomination', { txnId: res.transactionId ?? res.resourceId, - accountNo: this.accountDetails?.glCode ?? row.acNo, - accountName: this.accountDetails?.name ?? '', + accountNo: this.cashVaultGlCode ?? row.acNo, + accountName: this.cashVaultGlName ?? 'Cash at Vault', txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL', txnDate: txnDateIso, paymentTypeName: selectedPt?.name ?? 'Cash', 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 dc8109323..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 @@ -969,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 } @@ -979,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 5fafd6717..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'; @@ -922,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. */ @@ -961,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: [ @@ -1334,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 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 }} - } -
- - {{ existing.ID_PROOF.name }} - @if (existing.ID_PROOF.documentKey) { - {{ existing.ID_PROOF.documentKey }} - } - @if (existing.ID_PROOF.fileCount && existing.ID_PROOF.fileCount > 0) { - - {{ existing.ID_PROOF.fileCount }} - {{ existing.ID_PROOF.fileCount === 1 ? 'file' : 'files' }} - - } -
- -
- } - - @if (!existing.ID_PROOF.present || existing.ID_PROOF.expanded) { -

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

-
- - - - - -
- - @if (uploadErrors['ID_PROOF']) { -
- error_outline - {{ uploadErrors['ID_PROOF'] }} -
- } - @if (uploadResponses['ID_PROOF']) { -
-

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

-

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

-

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

-

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

-
- } - } - - - @@ -325,8 +207,25 @@ }
- @if (existing.ADDRESS_PROOF.previewUrl && isImage('ADDRESS_PROOF')) { - Address proof + @if ( + (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) && isImage('ADDRESS_PROOF') + ) { +
+ @if (existing.ADDRESS_PROOF.previewUrl) { + Address proof scan - front + } + @if (existing.ADDRESS_PROOF.previewUrlBack) { + Address proof scan - back + } +
} @if (existing.ADDRESS_PROOF.previewUrl) {
+ @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 }} -

-
- } } @@ -448,8 +320,23 @@ }
- @if (existing.PHOTO_PROOF.previewUrl && isImage('PHOTO_PROOF')) { - Photo proof + @if ((existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) && isImage('PHOTO_PROOF')) { +
+ @if (existing.PHOTO_PROOF.previewUrl) { + ID proof scan - front + } + @if (existing.PHOTO_PROOF.previewUrlBack) { + ID proof scan - back + } +
} @if (existing.PHOTO_PROOF.previewUrl) { + + } @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 }} -

-
- } } @@ -570,8 +428,10 @@ }
- @if (existing.CLIENT_IMAGE.previewUrl) { + @if (existing.CLIENT_IMAGE.previewUrl && isImage('CLIENT_IMAGE')) { Client photo + } + @if (existing.CLIENT_IMAGE.previewUrl) { + + } @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 }} -

-
- } } @@ -678,7 +497,7 @@ }
- @if (existing.CLIENT_SIGNATURE.previewUrl) { + @if (existing.CLIENT_SIGNATURE.previewUrl && isImage('CLIENT_SIGNATURE')) { 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 51f57dc01..94b6f1336 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 ===================== */ @@ -760,6 +745,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 c5e72c1f0..b90ec1d8c 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,52 +9,41 @@ /** Angular Imports */ import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Subject, takeUntil, catchError, of } 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; /** Set when the stored file could not be fetched - the row exists but its content is gone. */ previewUnavailable?: boolean; /** Stored MIME type, so the card knows whether it can render an inline thumbnail. */ mimeType?: string; } -/** - * Multipart `name` / `description` sent for each CKYC artefact type. - * - * The platform has no CKYC-specific document API; these are stored through the generic - * DocumentApiResource, whose contract carries no document-type field. The artefact type therefore - * travels in `name`, which is the same mechanism the client signature already uses - so - * `CLIENT_SIGNATURE` deliberately reuses the existing `clientSignature` value that - * loadExistingArtefacts() (and uploadClientSignatureImage()) match on. Changing that string would - * orphan every previously uploaded signature. - */ -export type CkycDocumentType = 'ID_PROOF' | 'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE'; - -const CKYC_DOCUMENT_META: Record = { - ID_PROOF: { name: 'ckycIdProof', description: 'CKYC ID proof' }, - ADDRESS_PROOF: { name: 'ckycAddressProof', description: 'CKYC address proof' }, - PHOTO_PROOF: { name: 'ckycPhotoProof', description: 'CKYC photo proof' }, - CLIENT_IMAGE: { name: 'ckycClientImage', description: 'CKYC client photograph' }, - CLIENT_SIGNATURE: { name: 'clientSignature', description: 'Client signature' } -}; - -const CKYC_DOCUMENT_TYPES = Object.keys(CKYC_DOCUMENT_META) as CkycDocumentType[]; +type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF'; +type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE'; /** * Client-side upload guards, kept deliberately in step with the backend so a file that passes here @@ -62,8 +51,8 @@ const CKYC_DOCUMENT_TYPES = Object.keys(CKYC_DOCUMENT_META) as CkycDocumentType[ * - 5 MB matches `spring.servlet.multipart.max-file-size` * - the extension list is the intersection of the backend `fineract.content.regex-whitelist` * with what these cards can actually render. - * `accept=` on the input is only a file-picker hint and is trivially bypassed, so the real check - * lives in onFileSelect(). + * The dialog's `accept=` is only a file-picker hint and is trivially bypassed, so the real check + * runs here, on the file the dialog hands back, before anything is sent. */ const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; const IMAGE_EXTENSIONS = [ @@ -76,8 +65,8 @@ const DOCUMENT_EXTENSIONS = [ 'pdf' ]; -/** Types whose card renders an inline preview, so a PDF would not display. */ -const IMAGE_ONLY_TYPES: CkycDocumentType[] = [ +/** Cards that render an inline , so a PDF would not display in them. */ +const IMAGE_ONLY_KINDS: CkycDocKind[] = [ 'CLIENT_IMAGE', 'CLIENT_SIGNATURE' ]; @@ -98,6 +87,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(); @@ -131,37 +122,55 @@ export class CkycTabComponent implements OnInit, OnDestroy { return this.panVerified && this.aadhaarVerified; } - /** CKYC artefacts already uploaded for this client, keyed by artefact type. */ - existing: Record = { - ID_PROOF: { present: false }, + /** Pre-existing artefacts detected from client data */ + 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 */ - idProofFile: File | null = null; - addressProofFile: File | null = null; - photoProofFile: File | null = null; clientImageFile: File | null = null; clientSignatureFile: File | null = null; /** Per-document upload state */ uploading: { [key: string]: boolean } = { - ID_PROOF: false, ADDRESS_PROOF: false, PHOTO_PROOF: false, CLIENT_IMAGE: false, CLIENT_SIGNATURE: false }; - /** Per-document upload response */ - uploadResponses: { [key: string]: any } = {}; - /** Per-document error messages */ uploadErrors: { [key: string]: string | null } = { - ID_PROOF: null, ADDRESS_PROOF: null, PHOTO_PROOF: null, CLIENT_IMAGE: null, @@ -171,6 +180,11 @@ export class CkycTabComponent implements OnInit, OnDestroy { ngOnInit(): void { this.route.parent.params.subscribe((params: any) => { this.clientId = params['clientId']; + // Angular reuses this component when only the clientId in the route changes, and + // loadExistingArtefacts() only ever *assigns* what it finds - it has no "not found" branch. + // Without clearing first, a client with no photo (or no signature, or no proof) keeps showing + // the previous client's card. + this.resetArtefactState(); this.loadClientInfo(); this.loadAccounts(); this.loadCkycData(); @@ -178,6 +192,46 @@ export class CkycTabComponent implements OnInit, OnDestroy { }); } + /** + * Returns every per-client card to its empty state, so nothing from the client just viewed can + * survive into the next one. Covers all four cards plus the details staged for an upload - a file + * or document number left over from another client must never be submitted against this one. + */ + private resetArtefactState(): void { + this.revokePreviewUrls(); + this.existing = { + ADDRESS_PROOF: { present: false }, + PHOTO_PROOF: { present: false }, + CLIENT_IMAGE: { present: false }, + CLIENT_SIGNATURE: { present: false } + }; + this.selectedProofType = { ADDRESS_PROOF: null, PHOTO_PROOF: null }; + this.docFiles = { + ADDRESS_PROOF: { front: null, back: null }, + PHOTO_PROOF: { front: null, back: null } + }; + this.documentDetails = { + ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null }, + PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null } + }; + this.clientImageFile = null; + this.clientSignatureFile = null; + this.uploadErrors = { + ADDRESS_PROOF: null, + PHOTO_PROOF: null, + CLIENT_IMAGE: null, + CLIENT_SIGNATURE: null + }; + } + + /** Frees the blob URLs held by the current cards before their references are dropped. */ + private revokePreviewUrls(): void { + Object.values(this.existing).forEach((e) => { + if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl); + if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack); + }); + } + private loadClientInfo(): void { this.route.parent.data.subscribe((data: any) => { const cv = data?.clientViewData; @@ -263,123 +317,270 @@ export class CkycTabComponent implements OnInit, OnDestroy { } /** - * Loads every CKYC artefact from the client's documents (`m_document`), which is the single - * source of truth for this screen: each card matches the `name` its own upload writes, so what - * the checker sees is exactly what CKYC stored. - * - * Deliberately does NOT read the profile image or client identifiers any more. Those belong to - * client registration and the Identities section; reading them here showed artefacts this screen - * had never uploaded, and hid the ones it had. Those other screens are untouched. - * - * A client can accumulate several rows for one artefact (older uploads pre-date replacement, and - * the signature is also written by client registration), so the newest row - highest `id` - wins. + * Fetch photo (profile image), signature (client document), + * ID proof and address proof (client identifiers) so already-uploaded + * artefacts are shown here instead of forcing re-upload. */ private loadExistingArtefacts(): void { this.checkingExisting = true; + forkJoin({ + photo: this.clientsService.getClientProfileImage(this.clientId).pipe(catchError(() => of(null))), + documents: this.clientsService.getClientDocuments(this.clientId).pipe(catchError(() => of([]))), + identifiers: this.clientsService.getClientIdentifiers(this.clientId).pipe(catchError(() => of([]))), + proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ idProofs: [], addressProofs: [] }))) + }) + .pipe(takeUntil(this.destroy$)) + .subscribe(({ photo, documents, identifiers, proofTypes }) => { + this.checkingExisting = false; + + // 1. Photo / image of client — from m_client.image_id + if (photo && typeof photo === 'string' && photo.length > 0) { + this.existing.CLIENT_IMAGE = { + present: true, + source: 'Client registration', + previewUrl: photo, + name: 'Profile photo' + }; + } + + // 2. Signature — client document with name='clientSignature'. Multiple rows can exist + // for clients who re-cropped/replaced their signature before uploads became single-slot, + // so pick the highest id (latest) rather than the first unordered match. + const signatureDoc = (documents as any[]) + ?.filter((d: any) => d?.name === 'clientSignature') + ?.sort((a: any, b: any) => b.id - a.id)?.[0]; + if (signatureDoc) { + this.existing.CLIENT_SIGNATURE = { + present: true, + source: 'Client registration', + name: signatureDoc.fileName || 'Client signature', + documentId: signatureDoc.id, + mimeType: signatureDoc.type + }; + // Same endpoint getClientSignatureImage() uses, but opted out of the global error alert: + // a m_document row whose file is no longer in the content store returns 500, and the card + // below reports that itself. Without `silent` the interceptor pops an "Internal Server + // Error" modal on every page load, for a failure the user cannot act on. + this.clientsService + .downloadClientDocument(this.clientId, String(signatureDoc.id), true) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (blob: any) => { + if (blob && blob.size > 0) { + this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); + } else { + // A 200 with an empty body still means there is nothing to show. Without this the + // card would claim "Already available" with no thumbnail and no explanation. + this.existing.CLIENT_SIGNATURE.previewUnavailable = true; + } + }, + error: () => { + this.existing.CLIENT_SIGNATURE.previewUnavailable = true; + } + }); + } + + // 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))); + + const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || []; + + for (const identity of idList) { + const docTypeId = String(identity?.documentType?.id ?? ''); + const isActive = + !identity?.status || + identity?.status?.code === 'clientIdentifierStatusType.active' || + identity?.status === 'clientIdentifierStatusType.active' || + identity?.status === 'Active'; + if (!isActive) continue; + + const proofName = identity?.documentType?.name || 'Identifier'; + // Address proof documentKey is auto-generated as " - " + // in identities-tab; hide it when it's just that synthetic key. + const rawKey = identity?.documentKey || ''; + const isSyntheticKey = rawKey.startsWith(`${proofName} - `); + const common = { + present: true, + source: 'Identities section', + name: proofName, + documentKey: isSyntheticKey ? '' : rawKey, + documentId: identity?.id, + 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); + } + } + }); + } + + /** + * 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 - .getClientDocuments(this.clientId) + .getClientIdentificationDocuments(identifierId) .pipe( takeUntil(this.destroy$), catchError(() => of([])) ) - .subscribe((documents: any) => { - this.checkingExisting = false; + .subscribe((docs: any) => { + const list = Array.isArray(docs) ? docs : []; + if (!list.length) return; + this.existing[kind].fileCount = list.length; + this.existing[kind].previewUnavailable = false; - const list: any[] = Array.isArray(documents) ? documents : (documents?.pageItems ?? []); + 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]; - for (const type of CKYC_DOCUMENT_TYPES) { - const expectedName = CKYC_DOCUMENT_META[type].name; - - const doc = list - .filter((d: any) => d?.name === expectedName) - .sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0))[0]; - - if (!doc) { - // No upload yet for this artefact - leave the card in its empty state. - this.existing[type] = { present: false }; - continue; - } - - this.existing[type] = { - present: true, - source: 'CKYC upload', - name: doc.fileName || expectedName, - documentId: doc.id, - mimeType: doc.type - }; - - this.loadPreview(type, doc.id); - } + // A proof can be scanned to PDF, which no can render - remember what was stored so the + // card offers "View document" instead of a broken thumbnail. + if (front) this.existing[kind].mimeType = front.type; + if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front'); + if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back'); }); } - /** - * Fetches the stored file so the card can show a thumbnail. A failure here is reported on the - * card rather than swallowed: the usual cause is a `m_document` row whose file is no longer in - * the content store, and silently showing "Already available" with no image hides that. - */ - private loadPreview(type: CkycDocumentType, documentId: string | number): void { + private downloadIdentifierDocPreview( + identifierId: string, + documentId: string, + kind: ProofKind, + slot: 'front' | 'back' + ): void { this.clientsService - // silent: a missing stored file returns 500, and the card below already reports that. Without - // this the global error interceptor pops an "Internal Server Error" modal on every page load - // and after every upload, for a failure the user cannot act on. - .downloadClientDocument(this.clientId, String(documentId), true) + .downloadClientIdentificationDocument(identifierId, documentId) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (blob: any) => { - if (blob && blob.size > 0) { - this.existing[type].previewUrl = URL.createObjectURL(blob); - } else { - // A 200 with an empty body still means there is nothing to show. Without this the card - // would sit there claiming "Already available" with no thumbnail and no explanation. - this.existing[type].previewUnavailable = true; + if (!blob || blob.size === 0) { + this.existing[kind].previewUnavailable = true; + return; } + const url = URL.createObjectURL(blob); + if (slot === 'front') this.existing[kind].previewUrl = url; + else this.existing[kind].previewUrlBack = url; }, + // The row exists but its file is gone from the content store. Saying so on the card beats + // showing "Already available" next to a blank space. error: () => { - this.existing[type].previewUnavailable = true; + this.existing[kind].previewUnavailable = true; } }); } - /** - * True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls - * back to the "View document" button alone rather than rendering a broken image. - */ - isImage(documentType: CkycDocumentType): boolean { - const entry = this.existing[documentType]; - if (entry?.mimeType) { - return entry.mimeType.startsWith('image/'); - } - // Older rows can have no stored MIME type - fall back to the file extension. - const extension = (entry?.name?.split('.').pop() || '').toLowerCase(); - return IMAGE_EXTENSIONS.includes(extension); - } - - /** - * Opens the uploaded document full size in a new tab. Uses the blob already fetched for the - * thumbnail, so it works for PDFs too and costs no extra request. - */ - openDocument(documentType: CkycDocumentType): void { - const url = this.existing[documentType]?.previewUrl; - if (url) { - window.open(url, '_blank', 'noopener'); - } - } - - toggleReplace(documentType: string): void { - const entry = (this.existing as any)[documentType]; - if (entry) entry.expanded = !entry.expanded; - } - onAccountSelect(accountNo: string): void { this.selectedAccount = this.allAccounts.find((a) => a.accountNo === accountNo) || null; this.selectedAccountType = this.selectedAccount?.accountType || ''; } + getFile(documentType: string): File | null { + switch (documentType) { + case 'CLIENT_IMAGE': + return this.clientImageFile; + case 'CLIENT_SIGNATURE': + return this.clientSignatureFile; + default: + return null; + } + } + + 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; + + // The dialog's `accept=` filter is only a hint to the file picker and is trivially bypassed, + // so the size/type guard runs here on what actually came back. Catching it now turns a raw + // server rejection into a message on the card. + const rejection = + this.validationError(result.front, kind) ?? (result.back ? this.validationError(result.back, kind) : null); + if (rejection) { + this.uploadErrors[kind] = rejection; + return; + } + this.uploadErrors[kind] = null; + + 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); + }); + } + /** Extensions a given card can both store and render. */ - private allowedExtensions(documentType: string): string[] { - return IMAGE_ONLY_TYPES.includes(documentType as CkycDocumentType) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS; + private allowedExtensions(kind: CkycDocKind): string[] { + return IMAGE_ONLY_KINDS.includes(kind) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS; } /** @@ -387,8 +588,11 @@ export class CkycTabComponent implements OnInit, OnDestroy { * so the user gets a clear message instead of a raw server error. Returns null when the file is * acceptable. */ - private validationError(file: File, documentType: string): string | null { - const allowed = this.allowedExtensions(documentType); + private validationError(file: File | null | undefined, kind: CkycDocKind): string | null { + if (!file) { + return null; + } + const allowed = this.allowedExtensions(kind); const extension = (file.name.split('.').pop() || '').toLowerCase(); if (!allowed.includes(extension)) { @@ -404,125 +608,279 @@ export class CkycTabComponent implements OnInit, OnDestroy { return null; } - private setFile(documentType: string, file: File | null): void { - switch (documentType) { - case 'ID_PROOF': - this.idProofFile = file; - break; - case 'ADDRESS_PROOF': - this.addressProofFile = file; - break; - case 'PHOTO_PROOF': - this.photoProofFile = file; - break; - case 'CLIENT_IMAGE': - this.clientImageFile = file; - break; - case 'CLIENT_SIGNATURE': - this.clientSignatureFile = file; - break; + /** + * True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls + * back to the "View document" button alone rather than rendering a broken image. + */ + isImage(kind: CkycDocKind): boolean { + const entry = this.existing[kind]; + // The profile image arrives as a `data:image/...;base64,` URL, which declares its own type - + // it has neither a stored MIME type nor a file extension for the checks below to read. + if (entry?.previewUrl?.startsWith('data:')) { + return entry.previewUrl.startsWith('data:image/'); + } + if (entry?.mimeType) { + return entry.mimeType.startsWith('image/'); + } + // Older rows can have no stored MIME type - fall back to the file extension. + const extension = (entry?.name?.split('.').pop() || '').toLowerCase(); + return IMAGE_EXTENSIONS.includes(extension); + } + + /** + * Opens the uploaded document full size in a new tab. Uses the blob already fetched for the + * thumbnail, so it works for PDFs too and costs no extra request. + */ + openDocument(kind: CkycDocKind): void { + const url = this.existing[kind]?.previewUrl; + if (url) { + window.open(url, '_blank', 'noopener'); } } - onFileSelect(event: any, documentType: string): void { - const file = event.target?.files?.[0]; - if (!file) return; + /** 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 + }; + } - const error = this.validationError(file, documentType); - if (error) { - this.setFile(documentType, null); - this.uploadErrors[documentType] = error; - event.target.value = ''; + 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; } - this.setFile(documentType, file); - this.uploadErrors[documentType] = null; - } - - getFile(documentType: string): File | null { - switch (documentType) { - case 'ID_PROOF': - return this.idProofFile; - case 'ADDRESS_PROOF': - return this.addressProofFile; - case 'PHOTO_PROOF': - return this.photoProofFile; - case 'CLIENT_IMAGE': - return this.clientImageFile; - case 'CLIENT_SIGNATURE': - return this.clientSignatureFile; - default: - return null; - } - } - - uploadDocument(documentType: CkycDocumentType): void { const file = this.getFile(documentType); if (!file) return; this.uploading[documentType] = true; this.uploadErrors[documentType] = null; + const key = documentType as keyof typeof this.existing; - // DocumentApiResource consumes multipart/form-data with body parts `file`, `name` and - // `description` (file + description mandatory). There is no document-type field in that - // contract, so the artefact type is carried in `name` - which is also the key - // loadExistingArtefacts() matches on when reading the document back. - const meta = CKYC_DOCUMENT_META[documentType]; - - const formData = new FormData(); - formData.append('file', file); - formData.append('name', meta.name); - formData.append('description', meta.description); - - // Replace in place when this artefact already has a usable document, so a re-upload updates the - // existing row (same document id, same content-store location) instead of stacking up another - // copy. Falls back to create for the first upload of an artefact. - // - // Deliberately create instead of replace when the stored file is missing: the backend's update - // path writes the new content relative to the row's existing `location` and never calls - // setLocation(), so replacing a row whose file is already gone writes the upload to a fresh - // folder while the row keeps pointing at the missing one - the artefact would stay broken and - // the user would get a success message. Creating a new row repairs the card instead. - const entry = this.existing[documentType]; - const replacing = !!entry?.documentId && !entry?.previewUnavailable; - const existingId = entry?.documentId; - - const request$ = replacing - ? this.clientsService.replaceClientDocument(this.clientId, String(existingId), formData) - : this.clientsService.uploadCkycDocument(this.clientId, formData); + 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; + } request$.pipe(takeUntil(this.destroy$)).subscribe({ - next: (response: any) => { + next: () => { this.uploading[documentType] = false; - this.uploadResponses[documentType] = response; - this.setFile(documentType, null); - this.alertService.alert({ - type: 'success', - message: - response?.message || (replacing ? 'Document replaced successfully.' : 'Document uploaded successfully.') - }); - // Re-read from m_document so the card reflects what was actually stored, rather than - // assuming the write succeeded exactly as sent. - this.loadExistingArtefacts(); + // The card is gated on `present`; without this a successful upload left it reading + // "Not uploaded yet". uploadProofDocument() sets the same flag for the proof cards. + this.existing[key].present = true; + this.existing[key].source = this.existing[key].source || 'CKYC upload'; + this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' }); + this.refreshPreview(documentType); }, error: (err: any) => { this.uploading[documentType] = false; - this.uploadErrors[documentType] = - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - err?.message || - 'Failed to upload document.'; + 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!); + } + // The upload that triggered this refresh is what repairs a previously missing file, so the + // warning has to be cleared before the re-read - otherwise a fixed card keeps the old badge. + this.existing[staleKey].previewUrl = undefined; + this.existing[staleKey].previewUrlBack = undefined; + this.existing[staleKey].previewUnavailable = false; + 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; + } else { + this.existing.CLIENT_IMAGE.previewUnavailable = true; + } + }); + 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.existing.CLIENT_SIGNATURE.mimeType = doc.type; + this.clientsService + .downloadClientDocument(this.clientId, String(doc.id), true) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (blob: any) => { + if (blob && blob.size > 0) { + this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); + } else { + this.existing.CLIENT_SIGNATURE.previewUnavailable = true; + } + }, + error: () => { + this.existing.CLIENT_SIGNATURE.previewUnavailable = true; + } + }); + }); + break; + case 'PHOTO_PROOF': + case 'ADDRESS_PROOF': + this.loadIdentifierDocPreview(documentType, this.existing[documentType].documentId!); + break; + } + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); - // Release object URLs - Object.values(this.existing).forEach((e) => { - if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl); - }); + this.revokePreviewUrls(); } } 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/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 57311257a..b4ae05100 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 }}

+ @@ -470,7 +471,11 @@ } - + + } @@ -1188,15 +1193,81 @@ - - - photo_library - Replace the scanned front / back of this document - chevron_right - + +
+
+ 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 }} +
+ } +
} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss index 101a3e073..0f4520920 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss @@ -1681,3 +1681,129 @@ .kyc-doc-scans-link__go { margin-left: auto; } + +/* The scan itself, beside the number and dates it belongs to. */ +.kyc-doc-scans { + margin-top: 10px; + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; +} + +.kyc-doc-scans__head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + color: #475569; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #64748b; + } +} + +.kyc-doc-scans__empty { + margin-bottom: 10px; + font-size: 12.5px; + color: #64748b; +} + +.kyc-doc-scans__sides { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +.kyc-doc-side { + display: flex; + flex-direction: column; + gap: 6px; + width: 190px; +} + +.kyc-doc-side__label { + font-size: 11.5px; + font-weight: 700; + color: #334155; +} + +/* Fixed height so front and back line up whatever the scan's aspect ratio is. */ +.kyc-doc-side__frame { + display: flex; + align-items: center; + justify-content: center; + height: 118px; + padding: 6px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #f8fafc; + overflow: hidden; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 3px; + } +} + +.kyc-doc-side__none { + font-size: 12px; + color: #94a3b8; +} + +.kyc-doc-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; + } +} + +.kyc-doc-side__btn { + font-size: 12px; + + mat-progress-spinner { + display: inline-block; + margin-right: 6px; + vertical-align: middle; + } +} + +.kyc-doc-scans__msg { + display: flex; + align-items: center; + gap: 6px; + margin-top: 10px; + font-size: 12.5px; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + } + + &--err { + color: #b91c1c; + } + + &--ok { + color: #15803d; + } +} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts index 365e9c0a7..32587d1e9 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts @@ -11,7 +11,7 @@ import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; -import { catchError, of, Subject, takeUntil } from 'rxjs'; +import { catchError, forkJoin, map, of, Subject, takeUntil } from 'rxjs'; /** Custom Services */ import { ClientsService } from '../../clients.service'; @@ -30,6 +30,11 @@ import { AadhaarReceiptDialogComponent, AadhaarReceiptResult } from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component'; +import { + KycDocumentUpdateDialogComponent, + KycDocumentUpdateData, + KycDocumentUpdateResult +} from '../../shared/kyc-document-update-dialog/kyc-document-update-dialog.component'; import { Dates } from 'app/core/utils/dates'; import { jsPDF } from 'jspdf'; import { MatIcon } from '@angular/material/icon'; @@ -38,6 +43,15 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +/** One side of the scanned KYC document, with the preview once it has been fetched. */ +interface DocScanSide { + documentId: string; + /** The identifier row this scan hangs off; the two sides can sit on different rows. */ + identifierId: string; + fileName: string; + previewUrl: string | null; +} + /** * KYC Tab Component. * Displays and manages KYC (Know Your Customer) details for an existing client. @@ -149,9 +163,42 @@ export class KycTabComponent implements OnInit, OnDestroy { exceptionReason: [[]] }); + // ── Officially valid document scans ──────────────────────────────────────── + + /** + * The scans of the document named above, by side. + * + * The screen used to show the document's number and dates but not the image behind them, and + * sent the officer to the client edit screen to change it. Re-collecting an expiring document is + * the one thing this panel exists for, so the scan is shown and replaced here. + */ + docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null }; + + /** The two sides, in display order, so the template loops rather than repeating itself. */ + readonly docScanSlots: ('front' | 'back')[] = [ + 'front', + 'back' + ]; + + docScanOf(slot: 'front' | 'back') { + return this.docScans[slot]; + } + + /** Identifier row replacements are filed against. Null when the document has no identifier. */ + private docIdentifierId: string | null = null; + /** Every identifier row carrying this document, since the sides can be split across them. */ + private docIdentifierIds: string[] = []; + docScansLoading = false; + /** Which side is mid-upload, so only that button shows a spinner. */ + docScanUploading: 'front' | 'back' | null = null; + docScanError: string | null = null; + docScanSuccess: string | null = null; + ngOnInit(): void { this.route.parent.params.subscribe((params: any) => { 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(); }); @@ -190,6 +237,8 @@ export class KycTabComponent implements OnInit, OnDestroy { 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(); } }); } @@ -1307,6 +1356,305 @@ 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; @@ -1365,5 +1713,14 @@ 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/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 0f300376a..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({ @@ -1105,7 +1114,7 @@ export class EditClientComponent { 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' }; + const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'ACTIVE' }; if (documentTypeId != null) { update.documentTypeId = documentTypeId; } @@ -1128,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/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..bf720407f --- /dev/null +++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html @@ -0,0 +1,131 @@ + + +

+ 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/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 }} + + }

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. +
+ } +
@@ -450,7 +471,7 @@ {{ alert.transactionType }} {{ alert.ruleCode }} — {{ alert.ruleDescription }} ₹{{ alert.amount | number: '1.0-0' }} - {{ alert.raisedOn | date: 'medium' }} + {{ asDate(alert.raisedOn) | date: 'medium' }} {{ alert.status }} @@ -523,7 +544,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 || '—' }} @@ -744,295 +765,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..25f51ebcf 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -13,17 +13,14 @@ 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, @@ -127,7 +124,7 @@ 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); @@ -140,11 +137,33 @@ export class FiuComponent implements OnInit { readonly obligations = FIU_OBLIGATIONS; bank = FIU_BANK; - ctrTxns: CtrTransaction[] = CTR_TXNS; - tc1Txns: CardTransaction[] = TC1_TXNS; + /** + * 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[] = []; strTxns: StrTransaction[] = STR_TXNS; - kc1: KycIndividual[] = KC1_INDIVIDUALS; - kc2: KycEntity[] = KC2_ENTITIES; + /** + * 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 +174,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 = ''; @@ -372,6 +397,21 @@ export class FiuComponent implements OnInit { const creds = this.authenticationService.getCredentials(); this.isHeadOffice = this.authenticationService.isHeadOfficeUser(); this.currentOfficeName = creds?.officeName || 'Branch Office'; + + // 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 +424,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 +477,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 +488,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 +556,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 { @@ -598,6 +633,130 @@ export class FiuComponent implements OnInit { this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || [])); } + /** + * 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); + } + + /** + * 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. + */ + 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; + } + const wanted = name.trim().toLowerCase(); + return this.branches.find((b: BranchMaster) => wanted.includes(b.name.toLowerCase()))?.code || null; + } + + /** + * 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; + } + 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'); + } + } + + /** 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 + }; + } + } + 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`); @@ -654,6 +813,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') }); @@ -1016,7 +1178,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 +1418,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 +1467,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 +1513,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 +1668,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/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..13b5f7636 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,19 @@ 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 ───────── + This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the + overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip. + `position: absolute` still lives inside that clip and gets cut off — only the on-screen + scroll position prints. `position: fixed` escapes it: a fixed element's containing block is + the viewport itself, not the clipped ancestor (the classic "fixed child ignores an + overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a + transform/filter — the CDK overlay chrome doesn't. */ @media print { + body > :not(.cdk-overlay-container) { + display: none !important; + } + :host { background: #fff; } @@ -585,11 +596,29 @@ 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: fixed; + left: 0; + top: 0; + width: 100% !important; + height: auto !important; background: #fff; max-height: none; overflow: visible; - height: auto; } .cpd-page { @@ -599,6 +628,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/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/organization/institution-profile/institution-profile.component.html b/src/app/organization/institution-profile/institution-profile.component.html index 4732fdd0d..a4d4f79a2 100644 --- a/src/app/organization/institution-profile/institution-profile.component.html +++ b/src/app/organization/institution-profile/institution-profile.component.html @@ -275,9 +275,12 @@ Holiday Calendar - Financial Year / Closing Entries + + @if (!disabledFeatures.closingEntries) { + Financial Year / Closing Entries + } Business Date (Day Register) diff --git a/src/app/organization/institution-profile/institution-profile.component.ts b/src/app/organization/institution-profile/institution-profile.component.ts index 41e8b41e5..2d04f9480 100644 --- a/src/app/organization/institution-profile/institution-profile.component.ts +++ b/src/app/organization/institution-profile/institution-profile.component.ts @@ -12,6 +12,7 @@ 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' }, @@ -63,6 +64,8 @@ export class InstitutionProfileComponent implements OnInit { 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; diff --git a/src/app/platform-admin/menu-access/menu-access.component.ts b/src/app/platform-admin/menu-access/menu-access.component.ts index e98bcfebd..c5607a687 100644 --- a/src/app/platform-admin/menu-access/menu-access.component.ts +++ b/src/app/platform-admin/menu-access/menu-access.component.ts @@ -104,7 +104,8 @@ export class PlatformMenuAccessComponent { '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 ?? 'default') + 'Fineract-Platform-TenantId': + tenantForFilter || this.selectedTenant || this.tenants[0]?.identifier || environment.fineractPlatformTenantId }; } @@ -126,7 +127,9 @@ export class PlatformMenuAccessComponent { this.rememberKey(); this.loadingTenants = true; try { - const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), { headers: this.headers('default') }); + 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}).` 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/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..4bc274f94 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 @@ -250,11 +250,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/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/risk-review/risk-review-data.ts b/src/app/risk-review/risk-review-data.ts index 3e054ceda..633de824d 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. @@ -390,17 +391,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); } /** diff --git a/src/app/risk-review/risk-review-report.component.html b/src/app/risk-review/risk-review-report.component.html index 40d308e22..503cb0dad 100644 --- a/src/app/risk-review/risk-review-report.component.html +++ b/src/app/risk-review/risk-review-report.component.html @@ -150,33 +150,62 @@ } @else {
-
+ +
-
+ +
-
+
-
+ +
+
@@ -220,7 +249,7 @@ row does not have to repeat it in a Move To and a Position column. --> @for (g of customerGroups; track g.key) {
-
+ @@ -268,6 +298,20 @@ + + - - @if (expanded === c.clientId) { - - + diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.ts b/src/app/sponsor-dd/dd-list/dd-list.component.ts index a8863798f..d9df2dc0d 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.ts +++ b/src/app/sponsor-dd/dd-list/dd-list.component.ts @@ -142,6 +142,12 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request. background-color: #fef3c7; color: #b45309; } + /* Spoiled stock. Grey rather than red: nothing went wrong with the customer's draft, only + with the paper it was printed on, and the draft itself is alive on another leaflet. */ + .status-badge.revoked { + background-color: #e2e8f0; + color: #475569; + } .action-btn-group { display: flex; gap: 8px; @@ -212,7 +218,10 @@ export class DdListComponent implements OnInit, AfterViewInit { 'ISSUED', 'CANCELLED', 'STOPPED', - 'CLEARED' + 'CLEARED', + // Spoiled at the printer. Filterable on its own because it is the count that explains a pad + // whose issued drafts do not add up to the numbers consumed. + 'REVOKED' ]; selectedStatus = 'ALL'; searchText = ''; @@ -444,6 +453,23 @@ export class DdListComponent implements OnInit, AfterViewInit { } }); } + + /** The leaflet was ruined at the printer and its draft has to move onto a fresh blank. */ + openRevokeDialog(leaflet: any): void { + this.dialog + .open(RevokeDdDialogComponent, { + width: '480px', + maxWidth: '95vw', + data: { leaflet }, + disableClose: true + }) + .afterClosed() + .subscribe((result) => { + if (result) { + this.loadLeaflets(); + } + }); + } } // ── ISSUE DRAFT DIALOG COMPONENT ── @@ -1655,6 +1681,114 @@ export class RequestReprintDialogComponent implements OnInit { } } +/** + * Replacing a leaflet ruined at the printer. + * + * Not a reprint: a reprint puts the same draft back through the printer on the same paper, which is + * right when the print jammed but the leaflet survived. Here the leaflet itself is spoiled — its + * printed number can never be handed to anyone — so the draft moves onto the next blank and both + * leaflets stay on the register, or the pad comes up short against the sponsor bank's range. + */ +@Component({ + selector: 'mifosx-revoke-dd-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule + ], + template: ` +

Revoke Spoiled DD (DD No: {{ data.leaflet.ddNumber }})

+ +

+ Use this when the leaflet itself is ruined — misprinted, torn or jammed — and cannot be given to the customer. + On approval this leaflet is marked REVOKED and the draft is re-issued on the next available + blank. +

+

+ No money moves. {{ data.leaflet.clientName || data.leaflet.walkinPurchaserName || 'The purchaser' }} has already + paid for this draft and is still owed the same one. +

+
+ + What went wrong with this leaflet? + + Reason is required + + +
+ + + + + `, + styles: [ + ` + .dialog-form { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 8px; + } + .full-width { + width: 100%; + } + .hint-text { + color: #64748b; + font-size: 13px; + margin: 0 0 10px 0; + line-height: 1.5; + } + ` + ] +}) +export class RevokeDdDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private sponsorDDService = inject(SponsorDDService); + private dialogRef = inject(MatDialogRef); + private snackBar = inject(MatSnackBar); + protected data = inject(MAT_DIALOG_DATA); + + revokeForm!: FormGroup; + loading = false; + + ngOnInit(): void { + this.revokeForm = this.fb.group({ + reason: [ + '', + Validators.required + ] + }); + } + + onCancel(): void { + this.dialogRef.close(false); + } + + onSubmit(): void { + if (this.revokeForm.invalid) { + return; + } + this.loading = true; + this.sponsorDDService.requestRevoke(this.data.leaflet.id, this.revokeForm.value.reason).subscribe({ + next: () => { + this.snackBar.open('Revoke request submitted for approval', 'Close', { duration: 3000 }); + this.dialogRef.close(true); + }, + error: (err) => { + this.loading = false; + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); + } + }); + } +} + // ── PRINT DRAFT DIALOG COMPONENT ── @Component({ selector: 'mifosx-print-draft-dialog', diff --git a/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.html b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.html new file mode 100644 index 000000000..7d6481477 --- /dev/null +++ b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.html @@ -0,0 +1,130 @@ + +
+
+
+

DD Revoke Approvals

+

+ A leaflet ruined at the printer cannot be handed over, and its number can never be given to anyone else. + Approving moves the same draft onto the next available blank — this series first, then this sponsor bank, then + any other. No money moves: the customer was already debited. +

+
+
+ + + Search by DD No. or customer + + + +
+ +
+ + +
Customer Account Number Branch Rating on fileReview says Alerts AML Rules Triggered Amount under review {{ riskText(c.currentRisk) }} + + + {{ directionText(c) }} + + @if (c.directionReason) { +
{{ c.directionReason }}
+ } +
{{ c.alertCount }} @for (label of c.ruleLabels; track label) { @@ -276,10 +320,11 @@ {{ c.totalAmount | number: '1.0-0' }} {{ c.lastAlertDate }} + + - @if (c.directionReason) { -
{{ c.directionReason }}
- }
+ @for (a of c.alerts; track a.alertId) {
diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss index 117eae800..c4f9b6066 100644 --- a/src/app/risk-review/risk-review-report.component.scss +++ b/src/app/risk-review/risk-review-report.component.scss @@ -286,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); @@ -307,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); } } @@ -502,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 { @@ -538,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; } } @@ -789,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; @@ -817,9 +899,10 @@ } .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; } diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts index bd59f7bd6..efa8d1880 100644 --- a/src/app/risk-review/risk-review-report.component.ts +++ b/src/app/risk-review/risk-review-report.component.ts @@ -224,21 +224,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; }); @@ -389,8 +408,62 @@ export class RiskReviewReportComponent implements OnInit { return groups.filter((g) => g.rows.length > 0); } - /** Collapsed sections, by group key. 'No change needed' starts closed — nothing to act on. */ - collapsedGroups: Record = { same: true }; + /** + * 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]; @@ -513,6 +586,47 @@ export class RiskReviewReportComponent implements OnInit { * 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 { + if (c.escalated) { + return 'Held — with Principal Officer'; + } + if (c.direction === 'up') { + return `Raise to ${RISK_LABEL[c.recommendedRisk]}`; + } + if (c.direction === 'down') { + return `Lower to ${RISK_LABEL[c.recommendedRisk]}`; + } + return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record'; + } + + directionIcon(c: RiskCustomerRow): string { + if (c.escalated) { + return 'lock'; + } + // 'check' rather than an equals sign: faEquals is not in the shared library, and an + // unregistered icon throws at render and takes the whole row down with it. + return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check'; + } + + /** + * 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'; @@ -689,7 +803,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('; '), @@ -749,7 +863,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, @@ -815,7 +929,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..67499a2bc 100644 --- a/src/app/risk-review/risk-review.compute.ts +++ b/src/app/risk-review/risk-review.compute.ts @@ -218,7 +218,14 @@ function recommendRating( }; } - return { risk, reason: '' }; + // 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. */ 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 dfbf67e7c..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 @@ -91,16 +91,10 @@ [savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []" [submitting]="submitting" (submitEvent)="submit()" + (previousEvent)="prev()" > } - -
- -
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 880e51d90..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 @@ -116,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) { diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html index 130f27404..78a310134 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html @@ -21,8 +21,10 @@
-
Nexora Co-operative Bank Ltd.
-
Mumbai, Maharashtra, India
+
{{ bankName }}
+ @if (bankLocation) { +
{{ bankLocation }}
+ }
SHARE CERTIFICATE
Issued under the Common Seal of the Bank
@@ -51,8 +53,8 @@ {{ sharesAccountData.currentMarketPrice | currency: sharesAccountData.currency.code : 'symbol-narrow' }} - each, fully paid up, in Nexora Co-operative Bank Ltd., subject to - the memorandum, articles of association, and bye-laws of the bank. + each, fully paid up, in {{ bankName }}, subject to the memorandum, articles of association, and bye-laws of the bank.

@@ -75,7 +77,7 @@
Branch{{ clientData?.officeName || 'Mumbai Main Branch' }}{{ clientData?.officeName || 'Head Office' }} Member Since {{ diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss index 97e7362ac..840774130 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss @@ -140,6 +140,8 @@ } .meta-value { + font-family: Arial, Helvetica, sans-serif; + font-variant-numeric: tabular-nums; font-size: 15px; font-weight: bold; color: #322f2b; @@ -226,12 +228,19 @@ .lbl-col { background-color: #f7f5ee; + font-family: Georgia, 'Times New Roman', Times, serif; font-weight: bold; color: #7b6f63; width: 25%; } + // Every figure a member actually needs to read at a glance — share counts, amounts, dates, the + // resolution number — sits in this column. Georgia's numerals kept blurring into each other at + // this size, so values get a plain sans-serif with tabular figures instead; the label column + // keeps the certificate's own serif since it's static text, never a number to misread. .val-col { + font-family: Arial, Helvetica, sans-serif; + font-variant-numeric: tabular-nums; color: #322f2b; width: 25%; } @@ -242,6 +251,7 @@ } .lbl-col-highlight { + font-family: Georgia, 'Times New Roman', Times, serif; font-weight: bold; color: #0b3c5d; font-size: 14px; @@ -251,6 +261,8 @@ } .val-col-highlight { + font-family: Arial, Helvetica, sans-serif; + font-variant-numeric: tabular-nums; font-size: 16px; font-weight: bold; color: #b8860b; /* Dark Goldenrod */ @@ -270,6 +282,7 @@ padding: 8px 12px; border: 1px solid #ede8db; background-color: #f7f5ee; + font-family: Georgia, 'Times New Roman', Times, serif; font-size: 11px; font-weight: bold; color: #7b6f63; @@ -277,9 +290,14 @@ letter-spacing: 0.6px; } + // Same reasoning as .val-col above: every cell here is a figure someone has to check against the + // register, so it reads in a plain sans-serif with tabular numerals rather than the certificate's + // decorative serif. td { padding: 9px 12px; border: 1px solid #ede8db; + font-family: Arial, Helvetica, sans-serif; + font-variant-numeric: tabular-nums; font-size: 13px; color: #322f2b; } diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts index 2b4c517b9..61605cc0a 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts @@ -16,6 +16,7 @@ import { MatDialogClose } from '@angular/material/dialog'; import { ClientsService } from 'app/clients/clients.service'; +import { OrganizationService } from 'app/organization/organization.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { FormatNumberPipe } from '../../../pipes/format-number.pipe'; @@ -44,6 +45,7 @@ export class ShareCertificateDialogComponent implements OnInit { dialogRef = inject>(MatDialogRef); data = inject(MAT_DIALOG_DATA); clientsService = inject(ClientsService); + private organizationService = inject(OrganizationService); sharesAccountData: any; clientId: string; @@ -53,6 +55,11 @@ export class ShareCertificateDialogComponent implements OnInit { panNumber: string = 'Not Available'; memberId: string = ''; + /** The issuing bank's own name/location, as set on the Institution Profile — never a fixed name, + * since this certificate template is shared by every tenant this app runs for. */ + bankName: string = 'Bank'; + bankLocation: string = ''; + certificateNo: string = ''; folioNo: string = ''; shareNumbersRange: string = ''; @@ -155,6 +162,24 @@ export class ShareCertificateDialogComponent implements OnInit { }, error: () => {} }); + + // Fetch the issuing bank's own name/location from the Institution Profile, set once under + // Admin > Organization for whichever bank this instance runs for — never a fixed name baked + // into the certificate itself. + this.organizationService.getInstitutionProfile().subscribe({ + next: (profile: any) => { + this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName; + this.bankLocation = [ + profile?.city, + profile?.state, + profile?.country + ] + .filter((p) => !!p && String(p).trim()) + .join(', '); + }, + // A missing/incomplete profile must not stop the certificate from rendering. + error: () => {} + }); } /** diff --git a/src/app/shares/shares-account-view/shares-account-view.component.ts b/src/app/shares/shares-account-view/shares-account-view.component.ts index e8318cff0..84103260f 100644 --- a/src/app/shares/shares-account-view/shares-account-view.component.ts +++ b/src/app/shares/shares-account-view/shares-account-view.component.ts @@ -149,6 +149,13 @@ export class SharesAccountViewComponent implements OnInit { // capital that was never issued. if (!this.isSettled) { this.buttonConfig.removeOption('Share Certificate'); + } else { + // "Apply Collection" settles the account's ORIGINAL application funding -- a one-time + // step. Once collectionApplied is true there is nothing left to settle this way; any + // further share purchases on this account go through "Apply Additional Shares" instead, + // which raises and settles its own tranche. Leaving this button up after settlement invited + // reopening the dialog against an account with no unapplied receipt to find. + this.buttonConfig.removeButton('Apply Collection'); } } } diff --git a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts index ddfc1d72a..f0f82b719 100644 --- a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts +++ b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts @@ -240,45 +240,83 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { this.approveBatch(request); return; } - if ( - !confirm( - `Approve DD issuance for DD No. ${request.ddNumber} (₹${request.faceValue})? This will debit the account (or clear the cheque) and issue the draft.` - ) - ) { - return; + this.dialog + .open(ApproveIssueRequestDialogComponent, { + width: '440px', + maxWidth: '95vw', + data: { + isBatch: false, + ddNumber: request.ddNumber, + beneficiaryName: request.beneficiaryName, + clientName: request.clientName, + totalFaceValue: Number(request.faceValue) || 0, + // Named for how this particular draft is funded, rather than offering the approver every + // possibility and leaving them to work out which one applies. + effect: this.effectTextFor(request) + } + }) + .afterClosed() + .subscribe((confirmed) => { + if (!confirmed) { + return; + } + this.sponsorDDService.approveIssueRequest(request.id).subscribe({ + next: () => { + this.snackBar.open('DD issuance approved and posted successfully', 'Close', { duration: 3000 }); + this.loadRequests(); + }, + error: (err) => { + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); + } + }); + }); + } + + /** What approving this request actually does, in the words of its own payment method. */ + private effectTextFor(request: any, count = 1): string { + const drafts = count > 1 ? `${count} drafts` : 'the draft'; + const once = count > 1 ? ' once for the combined total' : ''; + switch ((request.paymentMethod || '').toUpperCase()) { + case 'ACCOUNT_DEBIT': + return `The customer's account is debited${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`; + case 'CHEQUE': + return `The cheque is marked cleared${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`; + default: + return `The cash collected is posted${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`; } - this.sponsorDDService.approveIssueRequest(request.id).subscribe({ - next: () => { - this.snackBar.open('DD issuance approved and posted successfully', 'Close', { duration: 3000 }); - this.loadRequests(); - }, - error: (err) => { - this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); - } - }); } private approveBatch(request: any): void { const siblings = this.batchSiblings(request); - const lines = siblings.map((r) => ` • ${r.beneficiaryName} — ₹${r.faceValue}`).join('\n'); const total = siblings.reduce((sum, r) => sum + (Number(r.faceValue) || 0), 0); - if ( - !confirm( - `Approve this DD slip - ${siblings.length} drafts, combined face value ₹${total}?\n${lines}\n\n` + - 'This debits the account (or clears the cheque) ONCE for the combined total and issues every draft in the slip.' - ) - ) { - return; - } - this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({ - next: () => { - this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 }); - this.loadRequests(); - }, - error: (err) => { - this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); - } - }); + + this.dialog + .open(ApproveIssueRequestDialogComponent, { + width: '460px', + maxWidth: '95vw', + data: { + isBatch: true, + clientName: request.clientName, + totalFaceValue: total, + lines: siblings.map((r) => ({ beneficiaryName: r.beneficiaryName, faceValue: Number(r.faceValue) || 0 })), + effect: this.effectTextFor(request, siblings.length) + } + }) + .afterClosed() + .subscribe((confirmed) => { + if (!confirmed) { + return; + } + this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({ + next: () => { + this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 }); + this.loadRequests(); + }, + error: (err) => { + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); + } + }); + }); } openRejectDialog(request: any): void { @@ -302,6 +340,123 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { } } +/** + * What the approver is about to commit, laid out rather than run together in a sentence. + * + * This was a browser `confirm()`, which the browser renders with its own "localhost:4200 says" + * chrome and no formatting — so the one figure that matters, the amount leaving the customer's + * account, sat mid-paragraph beside a parenthetical about cheques that does not apply to most + * drafts. An approval is the point of no return on someone else's money; it should read like one. + */ +@Component({ + selector: 'mifosx-approve-issue-request-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule + ], + template: ` +

+ {{ data.isBatch ? 'Approve DD Slip' : 'Approve DD Issuance' }} +

+ +
+
+ {{ data.isBatch ? 'Drafts' : 'DD Number' }} + {{ data.isBatch ? data.lines.length + ' drafts' : data.ddNumber }} +
+
+ In favour of + {{ data.beneficiaryName || '—' }} +
+
+ Purchaser + {{ data.clientName }} +
+
+ {{ data.isBatch ? 'Combined face value' : 'Face value' }} + ₹{{ data.totalFaceValue | number: '1.2-2' }} +
+
+ + +
    +
  • {{ l.beneficiaryName }} — ₹{{ l.faceValue | number: '1.2-2' }}
  • +
+ +

+ {{ data.effect }} +

+
+ + + + + `, + styles: [ + ` + .approve-title { + margin-bottom: 4px; + } + .approve-summary { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 4px 14px; + margin-bottom: 12px; + background: #f8fafc; + } + .approve-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 8px 0; + border-bottom: 1px solid #eef2f7; + font-size: 13px; + } + .approve-row:last-child { + border-bottom: none; + } + .approve-label { + color: #64748b; + } + .approve-value { + text-align: right; + } + .approve-amount { + font-size: 17px; + font-weight: 700; + } + .approve-lines { + margin: 0 0 12px; + padding-left: 20px; + font-size: 12.5px; + color: #334155; + max-height: 160px; + overflow-y: auto; + } + .approve-effect { + margin: 0; + padding: 10px 12px; + border-radius: 6px; + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; + font-size: 12.5px; + line-height: 1.5; + } + ` + ] +}) +export class ApproveIssueRequestDialogComponent { + dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); +} + // ── REJECT DIALOG COMPONENT ── @Component({ selector: 'mifosx-reject-issue-request-dialog', diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.html b/src/app/sponsor-dd/dd-list/dd-list.component.html index a7b1fa4ff..1a0b5be09 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.html +++ b/src/app/sponsor-dd/dd-list/dd-list.component.html @@ -209,6 +209,31 @@ Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }}) + + + + + Revoke Pending Approval (Req #{{ element.pendingRevokeRequestId }}) + + + + + Revoked → replaced by DD {{ element.revokedReplacedByDdNumber }} + + + Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Req. ID{{ element.id }}Spoiled DD + {{ element.ddNumber }} + Purchaser{{ element.clientName || '—' }}Beneficiary{{ element.beneficiaryName || '—' }}Face Value{{ element.faceValue | number: '1.2-2' }}Reason{{ element.reason }}Replaced By + {{ element.replacementDdNumber }} + — + Status + + {{ element.status }} + + Requested By{{ element.makerUsername }}Requested On{{ element.createdDate | date: 'dd MMM yyyy, HH:mm' }}Actions +
+ + +
+ {{ + element.rejectionReason + }} +
+
by {{ element.checkerUsername }}, {{ element.checkerDate | date: 'dd MMM, HH:mm' }}
+
+
+ +
+ No DD revoke requests match the selected filter. +
+ + + + diff --git a/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts new file mode 100644 index 000000000..b9733802a --- /dev/null +++ b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts @@ -0,0 +1,469 @@ +/** + * 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, AfterViewInit, ViewChild, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatTableModule, MatTableDataSource } from '@angular/material/table'; +import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { SponsorDDService } from '../sponsor-dd.service'; + +/** + * The queue for drafts spoiled at the printer. + * + * Kept apart from reprint approvals because the two decisions are not alike. A reprint puts the + * same draft back through the printer on the same leaflet and consumes nothing. Approving a revoke + * spends a blank from a limited pad the sponsor bank sent — so the checker is told which customer's + * draft it is, for how much, and that a fresh leaflet will be taken. + */ +@Component({ + selector: 'mifosx-dd-revoke-approvals', + standalone: true, + imports: [ + CommonModule, + RouterModule, + MatCardModule, + MatTableModule, + MatPaginatorModule, + MatButtonModule, + MatDialogModule, + MatSnackBarModule, + MatFormFieldModule, + MatInputModule + ], + templateUrl: './dd-revoke-approvals.component.html', + styles: [ + ` + .approvals-container { + padding: 24px; + max-width: 1240px; + margin: 0 auto; + } + .header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + } + .title-area h2 { + margin: 0 0 4px 0; + font-size: 24px; + font-weight: 600; + color: #1e293b; + } + .title-area p { + margin: 0; + color: #64748b; + font-size: 14px; + } + .search-field { + width: 320px; + display: block; + margin-bottom: 8px; + } + .mat-mdc-card { + border-radius: 12px; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + overflow: hidden; + } + table { + width: 100%; + } + th { + font-weight: 600; + color: #475569; + background-color: #f8fafc; + } + td { + color: #334155; + } + .status-badge { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; + } + .status-badge.pending { + background-color: #fef3c7; + color: #b45309; + } + .status-badge.approved { + background-color: #dcfce7; + color: #15803d; + } + .status-badge.rejected { + background-color: #fee2e2; + color: #b91c1c; + } + .action-btn-group { + display: flex; + gap: 8px; + } + .status-filter-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; + } + .status-filter-row button.filter-active { + background-color: #ede9fe; + } + .filter-count { + color: #64748b; + margin-left: 4px; + } + /* The replacement is the whole point of the decision, so it reads as a number rather than + sitting in the row as ordinary text. */ + .replacement-no { + font-weight: 600; + color: #15803d; + } + .muted { + color: #94a3b8; + } + ` + ] +}) +export class DdRevokeApprovalsComponent implements OnInit, AfterViewInit { + private sponsorDDService = inject(SponsorDDService); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + + @ViewChild(MatPaginator) paginator!: MatPaginator; + + displayedColumns: string[] = [ + 'id', + 'ddNumber', + 'clientName', + 'beneficiaryName', + 'faceValue', + 'reason', + 'replacementDdNumber', + 'status', + 'makerUsername', + 'createdDate', + 'actions' + ]; + dataSource = new MatTableDataSource([]); + + readonly statusOptions: string[] = [ + 'PENDING', + 'APPROVED', + 'REJECTED', + 'ALL' + ]; + selectedStatus = 'PENDING'; + searchText = ''; + + ngOnInit(): void { + this.dataSource.filterPredicate = (data) => { + const statusMatches = this.selectedStatus === 'ALL' || data.status === this.selectedStatus; + const needle = this.searchText.trim().toLowerCase(); + const searchMatches = + !needle || + (data.ddNumber || '').toLowerCase().includes(needle) || + (data.clientName || '').toLowerCase().includes(needle); + return statusMatches && searchMatches; + }; + this.loadRequests(); + } + + ngAfterViewInit(): void { + this.dataSource.paginator = this.paginator; + } + + loadRequests(): void { + this.sponsorDDService.getAllRevokeRequests().subscribe({ + next: (data) => { + this.dataSource.data = data || []; + this.applyFilter(); + }, + error: (err) => { + this.snackBar.open('Error loading revoke requests: ' + (err.error?.message || err.message), 'Close', { + duration: 5000 + }); + } + }); + } + + statusCount(status: string): number { + const all = this.dataSource.data; + return status === 'ALL' ? all.length : all.filter((r) => r.status === status).length; + } + + filterByStatus(status: string): void { + this.selectedStatus = status; + this.applyFilter(); + } + + onSearchChange(value: string): void { + this.searchText = value; + this.applyFilter(); + } + + private applyFilter(): void { + this.dataSource.filter = `${this.selectedStatus}|${this.searchText}`; + if (this.dataSource.paginator) { + this.dataSource.paginator.firstPage(); + } + } + + approve(request: any): void { + this.dialog + .open(ApproveRevokeRequestDialogComponent, { + width: '460px', + maxWidth: '95vw', + data: { request }, + disableClose: true + }) + .afterClosed() + .subscribe((confirmed) => { + if (!confirmed) { + return; + } + this.sponsorDDService.approveRevokeRequest(request.id).subscribe({ + next: (res: any) => { + // The replacement number is what the clerk has to write on and hand over, so it is said + // plainly rather than left for them to find by reopening the register. + const replacement = res?.subResourceId ? ` Replacement DD issued — leaflet #${res.subResourceId}.` : ''; + this.snackBar.open(`DD ${request.ddNumber} revoked.${replacement}`, 'Close', { duration: 6000 }); + this.loadRequests(); + }, + error: (err) => { + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 7000 }); + } + }); + }); + } + + openRejectDialog(request: any): void { + this.dialog + .open(RejectRevokeRequestDialogComponent, { + width: '450px', + data: { request }, + disableClose: true + }) + .afterClosed() + .subscribe((result) => { + if (result) { + this.loadRequests(); + } + }); + } +} + +/** + * What approving actually costs, laid out before it is done. + * + * A browser `confirm()` would run the customer, the amount and the fact that a blank is consumed + * together in one sentence under the browser's own "localhost says" chrome. This is a decision + * about someone else's draft and about stock that cannot be replaced, so it reads like one. + */ +@Component({ + selector: 'mifosx-approve-revoke-request-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule + ], + template: ` +

Approve DD Revoke

+ +
+
+ Spoiled DD + {{ data.request.ddNumber }} +
+
+ Purchaser + {{ data.request.clientName }} +
+
+ In favour of + {{ data.request.beneficiaryName }} +
+
+ Face value + ₹{{ data.request.faceValue | number: '1.2-2' }} +
+
+ +

Reason given
{{ data.request.reason }}

+ +

+ This marks {{ data.request.ddNumber }} as REVOKED and re-issues the same draft on the next available blank — + preferring this series, then this sponsor bank, then any other. No money moves: the customer was already debited + and is still owed this one draft. +

+
+ + + + + `, + styles: [ + ` + .rv-summary { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 14px; + background: #f8fafc; + } + .rv-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 4px 0; + } + .rv-row--total { + border-top: 1px solid #e2e8f0; + margin-top: 6px; + padding-top: 8px; + } + .rv-label { + color: #64748b; + font-size: 12.5px; + } + .rv-value { + color: #1e293b; + font-size: 13.5px; + text-align: right; + } + .rv-amount { + color: #1e293b; + font-size: 17px; + font-weight: 700; + } + .rv-reason { + font-size: 13px; + color: #334155; + margin: 0 0 12px; + } + .rv-effect { + font-size: 12.5px; + line-height: 1.55; + color: #92400e; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 6px; + padding: 10px 12px; + margin: 0; + } + ` + ] +}) +export class ApproveRevokeRequestDialogComponent { + dialogRef = inject(MatDialogRef); + data = inject<{ request: any }>(MAT_DIALOG_DATA); +} + +// ── REJECT DIALOG ── +@Component({ + selector: 'mifosx-reject-revoke-request-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule + ], + template: ` +

Reject DD Revoke (DD No: {{ data.request.ddNumber }})

+ +

+ The leaflet stays issued and no replacement is taken. The clerk keeps a draft they say they cannot hand over, so + the reason should tell them what to do instead. +

+
+ + Rejection Reason + + A reason is required + +
+
+ + + + + `, + styles: [ + ` + .full-width { + width: 100%; + } + .hint-text { + font-size: 13px; + color: #64748b; + margin: 0 0 12px; + line-height: 1.5; + } + ` + ] +}) +export class RejectRevokeRequestDialogComponent { + private fb = inject(FormBuilder); + private sponsorDDService = inject(SponsorDDService); + private snackBar = inject(MatSnackBar); + dialogRef = inject(MatDialogRef); + data = inject<{ request: any }>(MAT_DIALOG_DATA); + + rejectForm: FormGroup; + loading = false; + + constructor() { + this.rejectForm = this.fb.group({ + rejectionReason: [ + '', + Validators.required + ] + }); + } + + onSubmit(): void { + if (this.rejectForm.invalid) { + return; + } + this.loading = true; + this.sponsorDDService.rejectRevokeRequest(this.data.request.id, this.rejectForm.value.rejectionReason).subscribe({ + next: () => { + this.loading = false; + this.snackBar.open('Revoke request rejected', 'Close', { duration: 3000 }); + this.dialogRef.close(true); + }, + error: (err) => { + this.loading = false; + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); + } + }); + } +} diff --git a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts index 3426b8bda..b4516b3c4 100644 --- a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts +++ b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts @@ -206,7 +206,9 @@ export class GlSettingsDialogComponent implements OnInit { loadGlAccountsAndMapping(): void { this.loading = true; - this.http.get('/glaccounts?manualEntriesAllowed=true&disabled=false').subscribe({ + // usage=1 (Detail only): these settings map real Sponsor DD transactions onto a GL account, and + // a Header account can never receive one, so it must never be offered here. + this.http.get('/glaccounts?manualEntriesAllowed=true&disabled=false&usage=1').subscribe({ next: (accounts) => { this.glAccounts = accounts || []; this.sponsorDDService.getGlMappings().subscribe({ diff --git a/src/app/sponsor-dd/sponsor-dd-routing.module.ts b/src/app/sponsor-dd/sponsor-dd-routing.module.ts index d9d6c3500..4f5609233 100644 --- a/src/app/sponsor-dd/sponsor-dd-routing.module.ts +++ b/src/app/sponsor-dd/sponsor-dd-routing.module.ts @@ -13,6 +13,7 @@ import { DdInventoryListComponent } from './dd-inventory-list/dd-inventory-list. import { DdListComponent } from './dd-list/dd-list.component'; import { DdIssueApprovalsComponent } from './dd-issue-approvals/dd-issue-approvals.component'; import { DdReprintApprovalsComponent } from './dd-reprint-approvals/dd-reprint-approvals.component'; +import { DdRevokeApprovalsComponent } from './dd-revoke-approvals/dd-revoke-approvals.component'; const routes: Routes = [ Route.withShell([ @@ -44,6 +45,11 @@ const routes: Routes = [ path: 'reprint-approvals', component: DdReprintApprovalsComponent, data: { title: 'DD Reprint Approvals', breadcrumb: 'Reprint Approvals' } + }, + { + path: 'revoke-approvals', + component: DdRevokeApprovalsComponent, + data: { title: 'DD Revoke Approvals', breadcrumb: 'Revoke Approvals' } } ] } diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index 7f6e34ff4..707a5a9da 100644 --- a/src/app/sponsor-dd/sponsor-dd.service.ts +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -218,6 +218,27 @@ export class SponsorDDService { return this.http.post(`/sponsordd/reprint-requests/${id}/reject`, { rejectionReason }); } + // ── DD revoke requests (maker-checker) ── + // A leaflet ruined at the printer. Its number can never be handed to anyone, so the draft moves + // onto the next available blank and both leaflets stay on the register — one spoiled, one + // carrying the draft. Distinct from a reprint, which re-prints the same leaflet. + requestRevoke(leafletId: number, reason: string): Observable { + return this.http.post(`/sponsordd/leaflets/${leafletId}/revoke-request`, { reason }); + } + + getAllRevokeRequests(status?: string): Observable { + const params = status ? { status } : {}; + return this.http.get('/sponsordd/revoke-requests', { params }); + } + + approveRevokeRequest(id: number): Observable { + return this.http.post(`/sponsordd/revoke-requests/${id}/approve`, {}); + } + + rejectRevokeRequest(id: number, rejectionReason: string): Observable { + return this.http.post(`/sponsordd/revoke-requests/${id}/reject`, { rejectionReason }); + } + // ── DD amendment requests (maker-checker) ── // Correcting an issued draft's beneficiary, or the branch and city it is payable at. Nothing else // is amendable: the money behind the face value has already moved, so a draft wrong in that way diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 5fab7cf1b..9a5bb2c85 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -226,6 +226,12 @@ DD Reprint Requests + + + + DD Revoke Requests +
diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index f379b5c81..03713aea3 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -235,6 +235,14 @@ const routes: Routes = [ (m) => m.DdReprintApprovalsComponent ), data: { title: 'DD Reprint Approvals' } + }, + { + path: 'dd-revoke-approvals', + loadComponent: () => + import('../sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component').then( + (m) => m.DdRevokeApprovalsComponent + ), + data: { title: 'DD Revoke Approvals' } } ] }, diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html index 45d79127d..27cd9f12e 100644 --- a/src/app/tds-reports/tds-interest-report.component.html +++ b/src/app/tds-reports/tds-interest-report.component.html @@ -80,7 +80,7 @@