Compare commits

..
1 Commits
112 changed files with 3052 additions and 7087 deletions
-302
View File
@@ -1,302 +0,0 @@
kind: pipeline
type: docker
name: auto-deploy
trigger:
branch:
- dev
event:
- push
steps:
# ============================================================
# DOCKER BUILD & PUSH
#
# Angular build (npm ci + ng build) Dockerfile ke andar
# multi-stage me hota hai, isliye alag build step nahi chahiye.
# ============================================================
- name: docker-build-push
image: docker
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- |
set -e
# --------------------------------------------------------
# Repository name comes from Drone
# --------------------------------------------------------
REPO="$${DRONE_REPO_NAME}"
REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')"
IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}"
echo "=========================================="
echo "DOCKER BUILD & PUSH"
echo "=========================================="
echo "Repository : $$REPO"
echo "Build : $${DRONE_BUILD_NUMBER}"
echo "Image : $$IMAGE"
echo "=========================================="
# --------------------------------------------------------
# Harbor login
# --------------------------------------------------------
echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \
docker login harbor.lincpay:8082 \
-u 'robot$lincpay+drone' \
--password-stdin
# --------------------------------------------------------
# Build
# --------------------------------------------------------
echo "Building Docker image..."
docker build \
-t "$$IMAGE" \
.
# --------------------------------------------------------
# Push
# --------------------------------------------------------
echo "Pushing Docker image..."
docker push "$$IMAGE"
echo "Docker image pushed successfully."
# ============================================================
# DEPLOY
# ============================================================
- name: deploy
image: docker
volumes:
- name: dockersock
path: /var/run/docker.sock
- name: envfiles
path: /platform-env
commands:
- |
set -e
# ========================================================
# REPOSITORY / SERVICE / IMAGE
# ========================================================
REPO="$${DRONE_REPO_NAME}"
REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')"
SERVICE_NAME="$${REPO}_app"
IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}"
echo "=========================================="
echo "DEPLOYMENT"
echo "=========================================="
echo "Repository : $$REPO"
echo "Service : $$SERVICE_NAME"
echo "Image : $$IMAGE"
echo "Placement : node.labels.environment == 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
+7 -27
View File
@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1
###############
### STAGE 1: Build app
###############
@@ -10,13 +9,10 @@ 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
# Build ko Chromium nahi chahiye — skip (warna npm ci Chromium download par atak jata hai).
ARG PUPPETEER_SKIP_DOWNLOAD_ARG=true
ARG PUPPETEER_SKIP_DOWNLOAD_ARG
# Set the environment variable to increase Node.js memory limit
# (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"
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN apk add --no-cache git
@@ -29,22 +25,14 @@ 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 config set fetch-retry-maxtimeout 120000 \
&& npm config set registry $NPM_REGISTRY_URL --location=global
RUN npm cache clear --force
# 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 npm config set fetch-retry-maxtimeout 120000
RUN npm config set registry $NPM_REGISTRY_URL --location=global
RUN npm ci
RUN sh -c "ng build --output-path=/dist $BUILD_ENVIRONMENT_OPTIONS"
@@ -53,14 +41,6 @@ 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
-45
View File
@@ -1,45 +0,0 @@
# 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;
}
}
@@ -72,7 +72,6 @@ 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 = [
@@ -100,8 +99,7 @@ const routes: Routes = [
{
path: 'frequent-postings',
component: FrequentPostingsComponent,
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Frequent Postings', breadcrumb: 'Frequent Postings', feature: 'frequentPostings' },
data: { title: 'Frequent Postings', breadcrumb: 'Frequent Postings' },
resolve: {
offices: OfficesResolver,
accountingRules: AccountingRulesAssociationsResolver,
@@ -112,8 +110,7 @@ const routes: Routes = [
{
path: 'create',
component: CreateJournalEntryComponent,
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Create Journal Entry', breadcrumb: 'Create', feature: 'createJournalEntries' },
data: { title: 'Create Journal Entry', breadcrumb: 'Create' },
resolve: {
offices: OfficesResolver,
currencies: CurrenciesResolver,
@@ -197,11 +194,6 @@ 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' },
@@ -252,8 +244,7 @@ const routes: Routes = [
},
{
path: 'closing-entries',
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Accounting Closures', breadcrumb: 'Closing Entries', feature: 'closingEntries' },
data: { title: 'Accounting Closures', breadcrumb: 'Closing Entries' },
children: [
{
path: '',
@@ -294,8 +285,7 @@ const routes: Routes = [
},
{
path: 'accounting-rules',
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Accounting Rules', breadcrumb: 'Accounting Rules', feature: 'accountingRules' },
data: { title: 'Accounting Rules', breadcrumb: 'Accounting Rules' },
children: [
{
path: '',
@@ -342,17 +332,11 @@ const routes: Routes = [
{
path: 'periodic-accruals',
component: PeriodicAccrualsComponent,
canActivate: [accountingFeatureEnabledGuard],
data: {
title: 'Periodic Accrual Accounting',
breadcrumb: 'Execute Periodic Accrual Accounting',
feature: 'accruals'
}
data: { title: 'Periodic Accrual Accounting', breadcrumb: 'Execute Periodic Accrual Accounting' }
},
{
path: 'provisioning-entries',
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Provisioning Entries', breadcrumb: 'Provisioning Entries', feature: 'provisioningEntries' },
data: { title: 'Provisioning Entries', breadcrumb: 'Provisioning Entries' },
children: [
{
path: '',
+59 -84
View File
@@ -17,39 +17,35 @@
<!-- Cards Grid -->
<div class="acc-grid">
<!-- Frequent Postings — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.frequentPostings) {
<a
class="acc-card"
[routerLink]="['journal-entries/frequent-postings']"
*mifosxHasPermission="'CREATE_JOURNALENTRY'"
#frequentPostings
>
<div class="acc-card__icon blue"><i class="fas fa-sync-alt"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Frequent Postings' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.These are predefined postings' | translate }}</span>
</div>
</a>
}
<!-- Frequent Postings -->
<a
class="acc-card"
[routerLink]="['journal-entries/frequent-postings']"
*mifosxHasPermission="'CREATE_JOURNALENTRY'"
#frequentPostings
>
<div class="acc-card__icon blue"><i class="fas fa-sync-alt"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Frequent Postings' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.These are predefined postings' | translate }}</span>
</div>
</a>
<!-- Create Journal Entries — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.createJournalEntries) {
<a
class="acc-card"
[routerLink]="['journal-entries/create']"
*mifosxHasPermission="'CREATE_JOURNALENTRY'"
#createJournalEntries
>
<div class="acc-card__icon green"><i class="fas fa-plus-circle"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Create Journal Entries' | translate }}</span>
<span class="acc-card__desc">{{
'labels.text.Manual journal entry transactions recorded in a journal' | translate
}}</span>
</div>
</a>
}
<!-- Create Journal Entries -->
<a
class="acc-card"
[routerLink]="['journal-entries/create']"
*mifosxHasPermission="'CREATE_JOURNALENTRY'"
#createJournalEntries
>
<div class="acc-card__icon green"><i class="fas fa-plus-circle"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Create Journal Entries' | translate }}</span>
<span class="acc-card__desc">{{
'labels.text.Manual journal entry transactions recorded in a journal' | translate
}}</span>
</div>
</a>
<!-- Search Journal Entries -->
<a class="acc-card" [routerLink]="['journal-entries']" *mifosxHasPermission="'READ_JOURNALENTRY'">
@@ -117,64 +113,43 @@
</div>
</a>
<!-- Balance Sheet -->
<a class="acc-card" [routerLink]="['balance-sheet']" *mifosxHasPermission="'READ_GLACCOUNT'" #balanceSheet>
<div class="acc-card__icon teal"><i class="fas fa-balance-scale"></i></div>
<!-- Closing Entries -->
<a class="acc-card" [routerLink]="['closing-entries']" *mifosxHasPermission="'READ_GLCLOSURE'" #closingEntries>
<div class="acc-card__icon red"><i class="fas fa-archive"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">Balance Sheet</span>
<span class="acc-card__desc">Header/Detail balances as on any date</span>
<span class="acc-card__title">{{ 'labels.heading.Closing Entries' | translate }}</span>
<span class="acc-card__desc">{{
'labels.text.Journal entries made at the end of accounting period' | translate
}}</span>
</div>
</a>
<!-- Closing Entries — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.closingEntries) {
<a class="acc-card" [routerLink]="['closing-entries']" *mifosxHasPermission="'READ_GLCLOSURE'" #closingEntries>
<div class="acc-card__icon red"><i class="fas fa-archive"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Closing Entries' | translate }}</span>
<span class="acc-card__desc">{{
'labels.text.Journal entries made at the end of accounting period' | translate
}}</span>
</div>
</a>
}
<!-- Accounting Rules -->
<a class="acc-card" [routerLink]="['accounting-rules']" *mifosxHasPermission="'READ_ACCOUNTINGRULE'">
<div class="acc-card__icon purple"><i class="fas fa-list-ul"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Accounting Rules' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.List of all accounting rules' | translate }}</span>
</div>
</a>
<!-- Accounting Rules — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.accountingRules) {
<a class="acc-card" [routerLink]="['accounting-rules']" *mifosxHasPermission="'READ_ACCOUNTINGRULE'">
<div class="acc-card__icon purple"><i class="fas fa-list-ul"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Accounting Rules' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.List of all accounting rules' | translate }}</span>
</div>
</a>
}
<!-- Accruals -->
<a class="acc-card" [routerLink]="['periodic-accruals']" *mifosxHasPermission="'EXECUTE_PERIODICACCRUALACCOUNTING'">
<div class="acc-card__icon green"><i class="fas fa-calendar-check"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Accruals' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.Accrues income, expenses and liabilities' | translate }}</span>
</div>
</a>
<!-- Accruals — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.accruals) {
<a
class="acc-card"
[routerLink]="['periodic-accruals']"
*mifosxHasPermission="'EXECUTE_PERIODICACCRUALACCOUNTING'"
>
<div class="acc-card__icon green"><i class="fas fa-calendar-check"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Accruals' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.Accrues income, expenses and liabilities' | translate }}</span>
</div>
</a>
}
<!-- Provisioning Entries — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.provisioningEntries) {
<a class="acc-card" [routerLink]="['provisioning-entries']" *mifosxHasPermission="'VIEW_PROVISIONING_ENTRIES'">
<div class="acc-card__icon amber"><i class="fas fa-cogs"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Provisioning Entries' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.Create provisioning entries' | translate }}</span>
</div>
</a>
}
<!-- Provisioning Entries -->
<a class="acc-card" [routerLink]="['provisioning-entries']" *mifosxHasPermission="'VIEW_PROVISIONING_ENTRIES'">
<div class="acc-card__icon amber"><i class="fas fa-cogs"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Provisioning Entries' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.Create provisioning entries' | translate }}</span>
</div>
</a>
</div>
</div>
+2 -9
View File
@@ -18,7 +18,6 @@ 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.
@@ -64,9 +63,6 @@ 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<any>.
@@ -108,15 +104,12 @@ export class AccountingComponent implements AfterViewInit {
);
});
}
// 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) {
if (this.configurationWizardService.showClosingEntries) {
setTimeout(() => {
this.showPopover(this.templateClosingEntries, this.closingEntries.nativeElement, 'bottom', true);
});
}
if (this.configurationWizardService.showCreateJournalEntries && this.createJournalEntries) {
if (this.configurationWizardService.showCreateJournalEntries) {
setTimeout(() => {
this.showPopover(this.templateCreateJournalEntries, this.createJournalEntries.nativeElement, 'bottom', true);
});
-16
View File
@@ -116,22 +116,6 @@ 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<any> {
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<any>} Journal Entries.
@@ -1,363 +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/.
*/
/*
* 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;
}
@@ -1,182 +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/.
-->
<div class="bs-page">
<!-- Controls — not part of the printed sheet. -->
<div class="bs-controls">
<label class="bs-field">
<span>As on date</span>
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
</label>
<label class="bs-field">
<span>Branch</span>
<select [(ngModel)]="officeId" (ngModelChange)="load()">
<option [ngValue]="null">All branches</option>
@for (office of offices; track office.id) {
<option [ngValue]="office.id">{{ office.name }}</option>
}
</select>
</label>
<label class="bs-check">
<input type="checkbox" [(ngModel)]="hideZeroBalances" (ngModelChange)="applyZeroFilter()" />
<span>Hide nil balances</span>
</label>
<span class="bs-controls__spacer"></span>
@if (!loading && debitSide && creditSide) {
<span class="bs-tally" [class.bs-tally--off]="!tallied">
{{ tallied ? 'Balanced' : 'Out of balance by ' + fmt(difference) }}
</span>
}
<button type="button" class="bs-btn" (click)="load()" [disabled]="loading">
{{ loading ? 'Loading…' : 'Refresh' }}
</button>
<button type="button" class="bs-btn bs-btn--primary" (click)="downloadPdf()" [disabled]="loading || !debitSide">
Download PDF
</button>
</div>
@if (error) {
<div class="bs-alert">{{ error }}</div>
}
@if (loading) {
<div class="bs-loading">Loading balance sheet…</div>
}
@if (!loading && debitSide && creditSide) {
<!-- The sheet itself — this block is what prints. -->
<div class="bs-sheet">
<header class="bs-sheet__head">
<h2>CONSOLIDATED BALANCE SHEET (SUB GROUP WISE)</h2>
<p>AS ON : {{ displayDate(asOnDateLoaded) }}</p>
<p class="bs-sheet__branches">FOR BRANCHES : {{ officeLabel }}</p>
</header>
<div class="bs-sheet__body">
<!-- Liabilities (credit) on the left, assets (debit) on the right — as the printed sheet reads. -->
<section class="bs-col">
<div class="bs-col__title">LIABILITIES ( CREDIT )</div>
<div class="bs-row bs-row--colhead">
<span>ACCOUNT NAME</span>
<span>BALANCE</span>
<span>TOTAL</span>
</div>
@for (group of creditSide.groups; track group.classification) {
@for (root of group.nodes; track root.id) {
<ng-container *ngTemplateOutlet="block; context: { $implicit: root }"></ng-container>
}
}
@if (creditSide.groups.length === 0) {
<div class="bs-empty">No liability, equity or income balances as on this date.</div>
}
<div class="bs-row bs-row--grand">
<span></span>
<span></span>
<span>{{ fmt(creditSide.total) }}</span>
</div>
</section>
<section class="bs-col">
<div class="bs-col__title">ASSETS ( DEBIT )</div>
<div class="bs-row bs-row--colhead">
<span>ACCOUNT NAME</span>
<span>BALANCE</span>
<span>TOTAL</span>
</div>
@for (group of debitSide.groups; track group.classification) {
@for (root of group.nodes; track root.id) {
<ng-container *ngTemplateOutlet="block; context: { $implicit: root }"></ng-container>
}
}
@if (debitSide.groups.length === 0) {
<div class="bs-empty">No asset or expense balances as on this date.</div>
}
<div class="bs-row bs-row--grand">
<span></span>
<span></span>
<span>{{ fmt(debitSide.total) }}</span>
</div>
</section>
</div>
<footer class="bs-sheet__foot">
<span>CLERK</span>
<span>ACCOUNTANT / OFFICER</span>
<span>ASST. GEN MANAGER</span>
<span>GENERAL MANAGER</span>
<span>CHAIRMAN</span>
</footer>
</div>
}
</div>
<!--
One top-level block: the Header account's name, a rule, its lines, then its subtotal in the TOTAL
column.
-->
<ng-template #block let-root>
@if (root.usage === 'DETAIL') {
<!-- A Detail account with no Header of its own: a group of one, so it gets a single line
carrying both its balance and its total rather than a heading repeating its own name. -->
<div class="bs-block bs-block--single">
<div class="bs-row bs-row--standalone">
<span class="bs-name" [title]="root.glCode">{{ root.name }}</span>
<span class="bs-amt">{{ fmt(root.balance) }}</span>
<span class="bs-amt">{{ fmt(root.balance) }}</span>
</div>
</div>
} @else {
<div class="bs-block">
<div class="bs-block__name" [title]="root.glCode">{{ root.name }}</div>
<div class="bs-block__rule"></div>
@for (child of root.children; track child.id) {
<ng-container *ngTemplateOutlet="lines; context: { $implicit: child, depth: 0 }"></ng-container>
}
@if (root.children.length === 0) {
<div class="bs-row bs-row--line">
<span class="bs-name bs-name--muted">No accounts under this group</span>
<span></span>
<span></span>
</div>
}
<div class="bs-row bs-row--subtotal">
<span></span>
<span></span>
<span>{{ fmt(root.balance) }}</span>
</div>
</div>
}
</ng-template>
<!--
Everything below a top-level block. A nested Header keeps its own name and its own subtotal, so a
Header inside a Header still reads as its own sub-group rather than flattening into the parent.
-->
<ng-template #lines let-node let-depth="depth">
@if (node.usage === 'DETAIL') {
<div class="bs-row bs-row--line">
<span class="bs-name" [style.paddingLeft.px]="depth * 14" [title]="node.glCode">{{ node.name }}</span>
<span class="bs-amt">{{ fmt(node.balance) }}</span>
<span></span>
</div>
} @else {
<div class="bs-row bs-row--subhead">
<span class="bs-name" [style.paddingLeft.px]="depth * 14" [title]="node.glCode">{{ node.name }}</span>
<span></span>
<span></span>
</div>
@for (child of node.children; track child.id) {
<ng-container *ngTemplateOutlet="lines; context: { $implicit: child, depth: depth + 1 }"></ng-container>
}
<div class="bs-row bs-row--subsubtotal">
<span></span>
<span class="bs-amt">{{ fmt(node.balance) }}</span>
<span></span>
</div>
}
</ng-template>
@@ -1,372 +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/.
*/
$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;
}
}
@@ -1,218 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { 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`);
}
}
-43
View File
@@ -1,43 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { 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;
};
@@ -49,144 +49,119 @@
}
@if (glStatus && !glError) {
@if (todayAlreadyOpen) {
<!-- This screen is a one-time "day 1" tool - today's business day already exists, so counting
a drawer here would only be rejected at submit (BranchDayRegisterApiResource rejects
reopening an already-open day). Day Register is where every day after the first belongs. -->
<div class="mcd-gl mcd-gl--error">
<mat-icon>lock</mat-icon>
<div>
<div class="mcd-gl__label">Business Day Already Open</div>
<div class="mcd-gl__value">
{{ todayOpenInfo.businessDate }} — opened by {{ todayOpenInfo.openedByName }}, opening cash ₹{{
todayOpenInfo.openingCash | number: '1.2-2'
}}
</div>
<div class="mcd-gl__hint">
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.
</div>
<button type="button" mat-stroked-button (click)="goToBranchDay()">Go to Day Register</button>
</div>
</div>
} @else {
<!-- Step 2: what the ledger already says, so the count is counted against something concrete. -->
<div class="mcd-gl" [class.mcd-gl--fresh]="!glHasBalance">
<mat-icon>account_balance</mat-icon>
<div>
<div class="mcd-gl__label">Cash GL Account</div>
<div class="mcd-gl__value">({{ glStatus.glCode }}) {{ glStatus.glAccountName }}</div>
@if (glHasBalance) {
<div class="mcd-gl__hint">
Already holds
<strong>₹{{ glTarget.toLocaleString('en-IN') }}</strong>
— count the drawer to exactly this amount.
</div>
} @else {
<div class="mcd-gl__hint">
No opening balance posted yet — whatever you count below becomes this account's opening balance.
</div>
}
</div>
</div>
<!-- Step 3: the actual count. -->
<mat-card class="mcd-card mcd-card--flush">
<mat-card-content>
<div class="mcd-grid mcd-grid--head">
<div class="mcd-grid__denom">Denomination</div>
<div class="mcd-grid__count">Count</div>
<div class="mcd-grid__amt">Amount</div>
</div>
@for (d of allDenoms; track d.id) {
<div class="mcd-grid mcd-grid--row">
<div class="mcd-grid__denom">
{{ d.label }} <span class="mcd-grid__kind">{{ d.kind }}</span>
</div>
<div class="mcd-grid__count">
<button type="button" mat-icon-button (click)="bumpCount(d, -1)"><mat-icon>remove</mat-icon></button>
<input
type="number"
min="0"
class="mcd-grid__input"
[style.width.ch]="countInputWidthCh(d)"
[ngModel]="countFor(d)"
(ngModelChange)="onCountChange(d, $event)"
/>
<button type="button" mat-icon-button (click)="bumpCount(d, 1)"><mat-icon>add</mat-icon></button>
</div>
<div class="mcd-grid__amt">₹{{ subtotalFor(d).toLocaleString('en-IN') }}</div>
</div>
}
</mat-card-content>
</mat-card>
<!-- The one number that matters. -->
<div class="mcd-balance" [class.mcd-balance--ok]="isReconciled">
<div class="mcd-balance__figure">
<span class="mcd-balance__label">Counted Total</span>
<span class="mcd-balance__amt">₹{{ countedTotal.toLocaleString('en-IN') }}</span>
</div>
<!-- Step 2: what the ledger already says, so the count is counted against something concrete. -->
<div class="mcd-gl" [class.mcd-gl--fresh]="!glHasBalance">
<mat-icon>account_balance</mat-icon>
<div>
<div class="mcd-gl__label">Cash GL Account</div>
<div class="mcd-gl__value">({{ glStatus.glCode }}) {{ glStatus.glAccountName }}</div>
@if (glHasBalance) {
<div class="mcd-balance__figure">
<span class="mcd-balance__label">GL Target</span>
<span class="mcd-balance__amt">₹{{ glTarget.toLocaleString('en-IN') }}</span>
<div class="mcd-gl__hint">
Already holds
<strong>₹{{ glTarget.toLocaleString('en-IN') }}</strong>
— count the drawer to exactly this amount.
</div>
} @else {
<div class="mcd-gl__hint">
No opening balance posted yet — whatever you count below becomes this account's opening balance.
</div>
}
<div class="mcd-balance__status">
@if (countedTotal <= 0) {
<mat-icon>edit_note</mat-icon>
<span>Count each denomination above</span>
} @else if (isReconciled) {
<mat-icon>check_circle</mat-icon>
<span>{{ glHasBalance ? 'Matches the GL balance' : 'Will define the GL opening balance' }}</span>
} @else {
<mat-icon>error</mat-icon>
<span
>Off by ₹{{
(countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString(
'en-IN'
)
}}</span
>
}
</div>
</div>
</div>
@if (needsDifferenceReason) {
<mat-card class="mcd-card">
<mat-card-content>
<mat-form-field class="mcd-reason">
<mat-label>Why does the count differ from the GL balance?</mat-label>
<input matInput [(ngModel)]="differenceReason" />
</mat-form-field>
</mat-card-content>
</mat-card>
}
@if (errorMsg) {
<div class="mcd-error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="mcd-success">
<mat-icon>check_circle</mat-icon>
<div>
{{ successMsg }}
<div class="mcd-success__actions">
<button type="button" mat-stroked-button (click)="goToBranchDay()">Go to Day Register</button>
<!-- Step 3: the actual count. -->
<mat-card class="mcd-card mcd-card--flush">
<mat-card-content>
<div class="mcd-grid mcd-grid--head">
<div class="mcd-grid__denom">Denomination</div>
<div class="mcd-grid__count">Count</div>
<div class="mcd-grid__amt">Amount</div>
</div>
@for (d of allDenoms; track d.id) {
<div class="mcd-grid mcd-grid--row">
<div class="mcd-grid__denom">
{{ d.label }} <span class="mcd-grid__kind">{{ d.kind }}</span>
</div>
<div class="mcd-grid__count">
<button type="button" mat-icon-button (click)="bumpCount(d, -1)"><mat-icon>remove</mat-icon></button>
<input
type="number"
min="0"
class="mcd-grid__input"
[ngModel]="countFor(d)"
(ngModelChange)="onCountChange(d, $event)"
/>
<button type="button" mat-icon-button (click)="bumpCount(d, 1)"><mat-icon>add</mat-icon></button>
</div>
<div class="mcd-grid__amt">₹{{ subtotalFor(d).toLocaleString('en-IN') }}</div>
</div>
}
</mat-card-content>
</mat-card>
<!-- The one number that matters. -->
<div class="mcd-balance" [class.mcd-balance--ok]="isReconciled">
<div class="mcd-balance__figure">
<span class="mcd-balance__label">Counted Total</span>
<span class="mcd-balance__amt">₹{{ countedTotal.toLocaleString('en-IN') }}</span>
</div>
@if (glHasBalance) {
<div class="mcd-balance__figure">
<span class="mcd-balance__label">GL Target</span>
<span class="mcd-balance__amt">₹{{ glTarget.toLocaleString('en-IN') }}</span>
</div>
}
<div class="mcd-balance__status">
@if (countedTotal <= 0) {
<mat-icon>edit_note</mat-icon>
<span>Count each denomination above</span>
} @else if (isReconciled) {
<mat-icon>check_circle</mat-icon>
<span>{{ glHasBalance ? 'Matches the GL balance' : 'Will define the GL opening balance' }}</span>
} @else {
<mat-icon>error</mat-icon>
<span
>Off by ₹{{
(countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString('en-IN')
}}</span
>
}
</div>
</div>
@if (needsDifferenceReason) {
<mat-card class="mcd-card">
<mat-card-content>
<mat-form-field class="mcd-reason">
<mat-label>Why does the count differ from the GL balance?</mat-label>
<input matInput [(ngModel)]="differenceReason" />
</mat-form-field>
</mat-card-content>
</mat-card>
}
@if (errorMsg) {
<div class="mcd-error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="mcd-success">
<mat-icon>check_circle</mat-icon>
<div>
{{ successMsg }}
<div class="mcd-success__actions">
<button type="button" mat-stroked-button (click)="goToBranchDay()">Go to Day Register</button>
</div>
</div>
}
</div>
}
@if (!successMsg) {
<div class="mcd-actions">
<button mat-raised-button color="primary" [disabled]="!canSubmit" (click)="submit()">
{{ submitting ? 'Submitting…' : 'Migrate & Open Business Day' }}
</button>
</div>
}
@if (!successMsg) {
<div class="mcd-actions">
<button mat-raised-button color="primary" [disabled]="!canSubmit" (click)="submit()">
{{ submitting ? 'Submitting…' : 'Migrate & Open Business Day' }}
</button>
</div>
}
}
</div>
@@ -115,10 +115,6 @@
color: #64748b;
line-height: 1.4;
}
button {
margin-top: 10px;
}
}
.mcd-grid {
@@ -175,11 +171,7 @@
}
&__input {
/* Width is set inline per row (see countInputWidthCh()), growing with the digit count typed —
min-width here only covers the fallback before Angular has bound anything. */
min-width: 48px;
max-width: 96px;
transition: width 0.1s ease-out;
width: 48px;
text-align: center;
font-variant-numeric: tabular-nums;
border: 1px solid #e2e8f0;
@@ -66,14 +66,6 @@ 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<number, number> = {};
@@ -126,8 +118,6 @@ 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;
@@ -141,15 +131,6 @@ 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 {
@@ -168,12 +149,6 @@ 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);
}
@@ -162,18 +162,7 @@
<div class="mob-grid mob-grid--data" formArrayName="glAccountEntries">
<div class="mob-grid__row" [formGroupName]="i">
<div class="mob-grid__code">{{ openingBalancesData.glAccounts[i].glAccountCode }}</div>
<div class="mob-grid__name">
{{ openingBalancesData.glAccounts[i].glAccountName }}
@if (isDenominationManaged(openingBalancesData.glAccounts[i].glAccountId)) {
<span
class="mob-grid__lock"
matTooltip="Set by Migrate Cash Denomination — use that screen to change it"
>
<mat-icon inline="true">lock</mat-icon>
<a [routerLink]="['/accounting/migrate-cash-denomination']">Migrate Cash Denomination</a>
</span>
}
</div>
<div class="mob-grid__name">{{ openingBalancesData.glAccounts[i].glAccountName }}</div>
<div class="mob-grid__amt">
<mat-form-field class="mob-grid__input">
<input matInput type="number" min="0" formControlName="debit" placeholder="0.00" />
@@ -219,33 +219,6 @@
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;
}
@@ -17,7 +17,6 @@ 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';
@@ -44,8 +43,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
...STANDALONE_SHARED_IMPORTS,
GlAccountDisplayComponent,
CurrencyPipe,
MatIconModule,
MatTooltipModule
MatIconModule
]
})
export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
@@ -76,13 +74,6 @@ 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<any>;
@@ -104,22 +95,6 @@ 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();
}
});
});
}
/**
@@ -161,31 +136,14 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
* @returns {FormGroup} GL Account entry form.
*/
createGLAccountEntryForm(glAccount: any): UntypedFormGroup {
// 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(
return this.formBuilder.group(
{
glAccountId: [glAccount.glAccountId],
debit: [existingDebit],
credit: [existingCredit]
debit: [null],
credit: [null]
},
{ 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);
}
/**
@@ -218,30 +176,17 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
this.openingBalancesData = openingBalancesData;
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();
entry.valueChanges.subscribe(() => {
this.debitsSum = 0;
this.creditsSum = 0;
entry.controls.forEach((value) => {
this.debitsSum += value.value.debit;
this.creditsSum += value.value.credit;
});
});
});
}
/** 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.
@@ -53,13 +53,10 @@
<fa-icon icon="sync" [class.fa-spin]="ledgerLoading"></fa-icon>
Refresh
</button>
<!-- New Entry — hidden while Create Journal Entries is disabled, see disabled-features.ts -->
@if (!disabledFeatures.createJournalEntries) {
<button class="gl-hero-btn gl-hero-btn--primary" [routerLink]="['/accounting/journal-entries/create']">
<fa-icon icon="plus"></fa-icon>
New Entry
</button>
}
<button class="gl-hero-btn gl-hero-btn--primary" [routerLink]="['/accounting/journal-entries/create']">
<fa-icon icon="plus"></fa-icon>
New Entry
</button>
</div>
</div>
</div>
@@ -718,10 +715,7 @@
></mat-row>
</mat-table>
<!-- Keyed off the rows actually rendered, not totalEntries: that count comes from the ledger
summary, which only reports a figure once a GL account is chosen, so the search view would
otherwise show "No entries found" above a full table. -->
@if (((dataSource?.entries$ | async) || []).length === 0) {
@if (totalEntries === 0) {
<div class="gl-empty">
<div class="gl-empty__icon"><fa-icon icon="search"></fa-icon></div>
<div class="gl-empty__title">No entries found</div>
@@ -730,11 +724,7 @@
}
<div class="gl-pager-wrap">
<mat-paginator
[length]="(dataSource?.records$ | async) || 0"
[pageSizeOptions]="[10, 25, 50, 100]"
showFirstLastButtons
></mat-paginator>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</div>
</div>
@@ -14,7 +14,6 @@ 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';
@@ -91,8 +90,6 @@ 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();
-9
View File
@@ -122,15 +122,6 @@ export class AmlService {
return this.http.get<any[]>('/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<any[]> {
const params = reportId ? new HttpParams().set('reportId', String(reportId)) : undefined;
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
}
/** month format: yyyy-MM */
generateCtrReport(month: string): Observable<any> {
return this.http.post('/aml/ctr-reports/generate', { month });
@@ -49,18 +49,6 @@
</div>
}
@if (firstOpenNeedsCashMigration && !missedOnly) {
<div class="bd-locked">
<mat-icon>lock</mat-icon>
<span>
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
<a [routerLink]="['/accounting/migrate-cash-denomination']">Migrate Cash Denomination</a> — Open Day here is
locked until that is done, so today's cash tracking never starts out of step with the ledger.
</span>
</div>
}
<!-- Missed closes first: these are the ones that need action. -->
@if (missed.length > 0) {
<div class="db-exceptions">
@@ -715,10 +703,10 @@
<span class="db-muted">· {{ timeOnly(d.closedAt) }}</span>
}
</td>
<td class="num">{{ hasValue(d.closingCash) ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}</td>
<td class="num">{{ hasValue(d.bookCash) ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}</td>
<td class="num">{{ d.closingCash != null ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}</td>
<td class="num">{{ d.bookCash != null ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}</td>
<td>
@if (!hasValue(d.cashDifference)) {
@if (d.cashDifference == null) {
—
} @else if (d.cashDifference === 0) {
<span class="db-badge db-badge--ok">Tallied</span>
@@ -30,36 +30,6 @@
}
}
/* 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;
@@ -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, RouterLink } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
import { CashierService } from '../cashier.service';
@@ -61,8 +61,7 @@ interface BranchDay {
imports: [
CommonModule,
FormsModule,
MatIconModule,
RouterLink
MatIconModule
]
})
export class BranchDayComponent implements OnInit {
@@ -86,12 +85,6 @@ 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;
@@ -209,7 +202,6 @@ export class BranchDayComponent implements OnInit {
this.today = res?.today ?? '';
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
this.checkFirstOpenCashMigration();
});
this.service
@@ -220,42 +212,9 @@ 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 && !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;
return this.canOperate && !this.openDay;
}
startOpen(): void {
@@ -303,8 +303,8 @@
<tr>
<th class="gl-th--voucher">Voucher No</th>
<th class="gl-th--date">Date</th>
<th>Debit</th>
<th>Credit</th>
<th>A/C No</th>
<th>A/C Name</th>
<th class="gl-th--pt">Payment Type</th>
<th class="gl-th--teller">Teller</th>
<th class="num gl-th--dr">DR (₹)</th>
@@ -326,25 +326,8 @@
<div class="gl-time">{{ r.txnTime }}</div>
}
</td>
<!-- Both legs of the voucher, the same pair My Vouchers shows — not just the one
side this row's own denomination breakdown happens to know about. Falls back
to that one side (acNo/acName) when the voucher predates m_voucher_line. -->
<td class="gl-acname">
<div>{{ r.debitParty || r.acName }}</div>
@if (r.debitAccount || r.acNo) {
<div class="gl-acno">{{ r.debitAccount || r.acNo }}</div>
}
</td>
<td class="gl-acname">
@if (r.creditParty) {
<div>{{ r.creditParty }}</div>
@if (r.creditAccount) {
<div class="gl-acno">{{ r.creditAccount }}</div>
}
} @else {
<span class="gl-pt-none">—</span>
}
</td>
<td class="gl-acno">{{ r.acNo }}</td>
<td class="gl-acname">{{ r.acName }}</td>
<td class="gl-pt">
@if (r.paymentType) {
<span class="gl-pt-badge">{{ r.paymentType }}</span>
@@ -71,11 +71,8 @@ export interface CashierIssueDdDialogData {
<mat-dialog-content class="dd-dialog-body">
<!-- Draft & Financials Summary Card -->
<div class="dd-summary-card">
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
Leaflet" named the stock item rather than the draft, so it was not obvious that this
is the DD number being issued. -->
<div class="summary-col">
<span class="label">DD Number Being Issued</span>
<span class="label">Auto-Selected Leaflet</span>
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
</div>
@@ -237,7 +234,7 @@ export interface CashierIssueDdDialogData {
<mat-dialog-actions class="dd-dialog-actions" align="end">
<button mat-button type="button" (click)="onClose()">Cancel</button>
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
Save DD Details
Submit for Manager Approval
</button>
</mat-dialog-actions>
</div>
@@ -178,7 +178,7 @@
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
@for (a of acTypOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
@@ -432,14 +432,8 @@
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
"
>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
row.ddDetails ? 'edit_note' : 'post_add'
}}</mat-icon>
<!-- Names the leaflet once details are captured, so the clerk can see at a
glance which rows are ready before pressing Save. -->
<span>{{
row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details'
}}</span>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
<span>Fill DD Details &amp; Send for Approval</span>
</button>
</div>
</div>
@@ -1535,7 +1529,7 @@
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
@for (a of acTypOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
@@ -1762,7 +1756,7 @@
"
>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
<span>Fill DD Details</span>
<span>Fill DD Details &amp; Send for Approval</span>
</button>
</div>
</div>
@@ -57,27 +57,6 @@ 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 {
@@ -1638,13 +1617,6 @@ 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;
@@ -1699,33 +1671,6 @@ 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'
@@ -2285,13 +2230,12 @@ 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 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.
// 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.
case 'DD':
row.dbCr = this.isClerk ? 'Dr' : 'Cr';
row.acTyp = this.isClerk ? 'SD' : 'GL';
row.dbCr = 'Cr';
row.acTyp = 'GL';
row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
row.chequeNo = '';
row.cheqDate = '';
@@ -2472,34 +2416,13 @@ 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;
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.'
]);
if (accountDebit) {
this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!);
} else {
this.submitWalkinCashDd(row, leaflet, result);
}
});
},
error: (err) => {
@@ -2557,9 +2480,6 @@ 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;
@@ -2568,53 +2488,6 @@ 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
@@ -2684,8 +2557,6 @@ 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) => {
@@ -3250,8 +3121,6 @@ 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;
}
});
}
@@ -3320,7 +3189,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Step 1: search on the correct endpoint for this account type
this.http
.get<any>(
`${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}${cfg.extraListQueryParams ? `&${cfg.extraListQueryParams}` : ''}`
`${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}`
)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
@@ -3409,10 +3278,6 @@ 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') {
@@ -3466,10 +3331,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
listQueryParam: 'searchParam',
matchField: 'glCode',
detailParams: '?fetchRunningBalance=true',
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'
supportsPagination: false
};
}
if (t === 'CA' || t === 'CB') {
@@ -4499,15 +4361,6 @@ 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
@@ -5788,15 +5641,11 @@ 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.cashVaultGlCode ?? row.acNo,
accountName: this.cashVaultGlName ?? 'Cash at Vault',
accountNo: this.accountDetails?.glCode ?? row.acNo,
accountName: this.accountDetails?.name ?? '',
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
txnDate: txnDateIso,
paymentTypeName: selectedPt?.name ?? 'Cash',
@@ -969,11 +969,6 @@
createClientForm.get('alternateMobileNo')?.touched
) {
<mat-error>Enter valid 10-digit number (starts with 6-9)</mat-error>
} @else if (
createClientForm.get('alternateMobileNo')?.hasError('sameAsMobile') &&
createClientForm.get('alternateMobileNo')?.touched
) {
<mat-error>Alternate number can't be the same as Mobile No</mat-error>
}
</mat-form-field>
@@ -984,11 +979,7 @@
color="primary"
type="button"
class="verify-btn"
[disabled]="
readOnly ||
createClientForm.get('alternateMobileNo')?.value?.length !== 10 ||
createClientForm.get('alternateMobileNo')?.hasError('sameAsMobile')
"
[disabled]="readOnly || createClientForm.get('alternateMobileNo')?.value?.length !== 10"
(click)="verifyAlternateDIP()"
>
<fa-icon icon="shield-alt" class="m-r-10"></fa-icon>
@@ -25,10 +25,7 @@ import {
UntypedFormGroup,
Validators,
UntypedFormControl,
ReactiveFormsModule,
ValidatorFn,
AbstractControl,
ValidationErrors
ReactiveFormsModule
} from '@angular/forms';
import { Subject, Subscription, merge } from 'rxjs';
import { filter, switchMap, takeUntil } from 'rxjs/operators';
@@ -925,16 +922,6 @@ 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.
*/
@@ -974,8 +961,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
'',
[
Validators.pattern(/^[6-9]\d{9}$/),
Validators.maxLength(10),
this.notSameAsMobileNoValidator()
Validators.maxLength(10)
]
],
emailAddress: [
@@ -1348,10 +1334,6 @@ 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
@@ -221,22 +221,6 @@
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
</mat-form-field>
}
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Expiry date (optional)</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>event_busy</mat-icon></span>
<input
matInput
[matDatepicker]="addrExpiryDatePicker"
formControlName="expiryDate"
[readonly]="readOnly || isAddressProofLinkedToKyc"
/>
<mat-datepicker-toggle
matSuffix
[for]="addrExpiryDatePicker"
[disabled]="readOnly || isAddressProofLinkedToKyc"
></mat-datepicker-toggle>
<mat-datepicker #addrExpiryDatePicker></mat-datepicker>
</mat-form-field>
</div>
<div class="addr-upload-grid" [class.addr-upload-grid--two]="selectedAddrProof.sides === 2">
@@ -254,18 +238,14 @@
@if (existingAddressProofDocuments.length > 0 && !addrFiles.front && readOnly) {
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
<div class="addr-dropzone__existing">
@if (existingAddrFrontSlotPreview) {
<img
[src]="existingAddrFrontSlotPreview"
alt="Saved address proof"
class="addr-dropzone__thumb"
/>
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
} @else {
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
}
<div class="addr-dropzone__existing-meta">
<span class="addr-dropzone__existing-name">
{{ existingAddrFrontSlotName }}
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
</span>
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
<mat-icon>visibility</mat-icon>
@@ -298,18 +278,14 @@
<!-- Edit mode WITH existing doc — let the user view it OR
click anywhere to replace. -->
<div class="addr-dropzone__existing">
@if (existingAddrFrontSlotPreview) {
<img
[src]="existingAddrFrontSlotPreview"
alt="Saved address proof"
class="addr-dropzone__thumb"
/>
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
} @else {
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
}
<div class="addr-dropzone__existing-meta">
<span class="addr-dropzone__existing-name">
{{ existingAddrFrontSlotName }}
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
</span>
<div class="addr-dropzone__existing-actions">
<button
@@ -351,18 +327,14 @@
@if (existingAddressProofDocuments.length > 0 && !addrFiles.back && readOnly) {
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
<div class="addr-dropzone__existing">
@if (existingAddrBackSlotPreview) {
<img
[src]="existingAddrBackSlotPreview"
alt="Saved address proof"
class="addr-dropzone__thumb"
/>
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
} @else {
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
}
<div class="addr-dropzone__existing-meta">
<span class="addr-dropzone__existing-name">
{{ existingAddrBackSlotName }}
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
</span>
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
<mat-icon>visibility</mat-icon>
@@ -395,18 +367,14 @@
<!-- Edit mode WITH existing combined doc — same view + replace
pattern as the front slot, anchored to the saved file. -->
<div class="addr-dropzone__existing">
@if (existingAddrBackSlotPreview) {
<img
[src]="existingAddrBackSlotPreview"
alt="Saved address proof"
class="addr-dropzone__thumb"
/>
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
} @else {
<mat-icon class="addr-dropzone__doc-ic">description</mat-icon>
}
<div class="addr-dropzone__existing-meta">
<span class="addr-dropzone__existing-name">
{{ existingAddrBackSlotName }}
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
</span>
<div class="addr-dropzone__existing-actions">
<button
@@ -102,8 +102,7 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
Validators.required
],
documentKey: [''],
issueDate: [''],
expiryDate: ['']
issueDate: ['']
});
currentForm: UntypedFormGroup = this.fb.group({
@@ -223,12 +222,6 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
size: number;
previewUrl: string | null;
}[] = [];
/** Set only when the saved documents are separately-uploaded "<Proof> — Front"/"— Back"
* files (the CKYC tab / client-kyc-step convention) rather than this component's own single
* merged-PNG upload — lets the two slots show their own file instead of both showing
* whichever one happened to be first. */
private existingAddrFrontPreview: string | null = null;
private existingAddrBackPreview: string | null = null;
residenceTypes = [
'Owned',
@@ -314,10 +307,11 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
this.existingAddressProofDocuments = [];
if (this.existingKycDocSides.front) this.existingAddressProofDocuments.push(this.existingKycDocSides.front);
if (this.existingKycDocSides.back) this.existingAddressProofDocuments.push(this.existingKycDocSides.back);
// These already carry their own per-side preview from the KYC step's own mapping —
// keep the two slots distinct rather than collapsing them into one shared preview.
this.existingAddrFrontPreview = this.existingKycDocSides.front?.previewUrl || null;
this.existingAddrBackPreview = this.existingKycDocSides.back?.previewUrl || null;
if (this.existingKycDocSides.front?.previewUrl) {
this.addrMergedPreview = this.existingKycDocSides.front.previewUrl;
} else if (this.existingKycDocSides.back?.previewUrl) {
this.addrMergedPreview = this.existingKycDocSides.back.previewUrl;
}
this.existingAddressProofIdentifierId = this.existingKycIdentifierId;
}
} else if (this.isAddressProofLinkedToKyc) {
@@ -333,8 +327,6 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
this.currentForm.patchValue({ documentKey: '', issueDate: null });
this.addrFiles = { front: null, back: null };
this.addrMergedPreview = null;
this.existingAddrFrontPreview = null;
this.existingAddrBackPreview = null;
}
/**
@@ -364,8 +356,6 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
this.addrFiles = { front: null, back: null };
this.addrMergedFile = null;
this.addrMergedPreview = null;
this.existingAddrFrontPreview = null;
this.existingAddrBackPreview = null;
this.existingAddressProofIdentifierId = null;
this.existingAddressProofDocuments = [];
this.addressLoaded = false;
@@ -498,7 +488,6 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
proofId: any;
documentKey: string;
issueDate: string;
expiryDate: string;
files: { slot: string; file: File }[];
} {
const files: { slot: string; file: File }[] = [];
@@ -511,13 +500,11 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
}
}
const rawIssueDate = this.permanentForm.get('issueDate')?.value;
const rawExpiryDate = this.permanentForm.get('expiryDate')?.value;
return {
proof: this.selectedAddrProof,
proofId: this.permanentForm.get('proofId')?.value || '',
documentKey: this.permanentForm.get('documentKey')?.value || '',
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
expiryDate: rawExpiryDate ? this.dates.getDate(rawExpiryDate) : '',
files
};
}
@@ -657,14 +644,7 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
this.tryLinkKycData();
return;
}
// Aadhaar, Passport, Voter ID and Driving Licence are valid OVDs for both this slot and
// the separate identity-proof one (client-kyc-step) — when a client has two such
// dual-purpose identifiers, that step always runs first and picks the OLDEST match (the
// one on file first, historically the identity proof), so picking the NEWEST match here
// lands this step on the other row without the two steps needing to coordinate.
const identifier = [...identifiers]
.sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0))
.find((i: any) => this.findMatchingProof(i?.documentType?.name));
const identifier = identifiers.find((i: any) => this.findMatchingProof(i?.documentType?.name));
if (!identifier?.id) {
this.existingAddressProofCheckComplete = true;
this.tryLinkKycData();
@@ -684,19 +664,14 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
proofId: matchedProof.id,
documentKey: identifier.documentKey || ''
});
// The description carries "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" when set on
// create (see create-client.saveAddressProof / CkycTabComponent) — pull both out so the
// form's date fields show what was previously entered. Each match stops at the next
// comma so adding expiry after issue doesn't get swallowed into the issue-date capture.
// The description carries `Issue date: yyyy-MM-dd` when set on create (see
// create-client.saveAddressProof) — pull it out so the form's issueDate field
// shows what was previously entered.
const desc: string = identifier.description || '';
const issueMatch = desc.match(/Issue date:\s*([^,|]+)/i);
const expiryMatch = desc.match(/Expiry date:\s*([^,|]+)/i);
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
if (issueMatch) {
this.permanentForm.patchValue({ issueDate: this.dates.parseDate(issueMatch[1].trim()) });
}
if (expiryMatch) {
this.permanentForm.patchValue({ expiryDate: this.dates.parseDate(expiryMatch[1].trim()) });
}
}
this.clientsService.getClientIdentificationDocuments(this.existingAddressProofIdentifierId).subscribe({
@@ -710,29 +685,6 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
size: d.size || 0,
previewUrl: null as string | null
}));
const sideOf = (d: { name: string; fileName: string }): 'front' | 'back' | null => {
const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase();
if (text.includes('back')) return 'back';
if (text.includes('front')) return 'front';
return null;
};
// Changing the document type (e.g. Aadhaar -> Passport) keeps the old front/back
// files attached to this same identifier rather than removing them, so there can be
// more than one "— Front"/"— Back" pair on file — sort newest-first before matching
// or this would keep showing the very first scan ever uploaded here.
const byNewest = [...this.existingAddressProofDocuments].sort((a, b) => Number(b.id) - Number(a.id));
const frontDoc = byNewest.find((d) => sideOf(d) === 'front');
const backDoc = byNewest.find((d) => sideOf(d) === 'back');
if (frontDoc || backDoc) {
// Separately-uploaded front/back — each side gets its own preview.
if (frontDoc) this.downloadAddrSidePreview(frontDoc, 'front');
if (backDoc) this.downloadAddrSidePreview(backDoc, 'back');
return;
}
// Legacy/single-file case: one combined (or unlabelled) document covers both slots.
const firstImage = this.existingAddressProofDocuments.find((d) =>
(d.type || '').toLowerCase().startsWith('image/')
);
@@ -763,50 +715,6 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
});
}
private downloadAddrSidePreview(doc: { id: string; fileName: string; name: string }, slot: 'front' | 'back'): void {
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId!, doc.id).subscribe({
next: async (blob: any) => {
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
const url = 'data:' + blob.type + ';base64,' + btoa(binary);
if (slot === 'front') this.existingAddrFrontPreview = url;
else this.existingAddrBackPreview = url;
},
error: () => {}
});
}
/** What the Front slot should show: its own saved file when front/back were uploaded
* separately, falling back to the single combined preview for legacy/merged uploads. */
get existingAddrFrontSlotPreview(): string | null {
return this.existingAddrFrontPreview || this.addrMergedPreview;
}
get existingAddrFrontSlotName(): string {
const front = this.existingAddressProofDocuments.find((d) =>
`${d.name} ${d.fileName}`.toLowerCase().includes('front')
);
const doc = front || this.existingAddressProofDocuments[0];
return doc?.fileName || doc?.name || '';
}
/** Same as above, for the Back slot. */
get existingAddrBackSlotPreview(): string | null {
return this.existingAddrBackPreview || this.addrMergedPreview;
}
get existingAddrBackSlotName(): string {
const back = this.existingAddressProofDocuments.find((d) =>
`${d.name} ${d.fileName}`.toLowerCase().includes('back')
);
const doc = back || this.existingAddressProofDocuments[0];
return doc?.fileName || doc?.name || '';
}
onPinInput(tab: string) {
const form = this.getForm(tab);
const pin = form.get('pinCode')?.value?.replace(/\D/g, '') || '';
@@ -7,7 +7,6 @@
*/
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { forkJoin, catchError, of } from 'rxjs';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import {
MatDialog,
@@ -1267,14 +1266,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.loadExistingKycIdentifier(data?.kycDocumentName);
},
error: () => {
// No m_client_kyc row yet (e.g. CERSAI sync hasn't run) — that's fine, but the client
// can still have a KYC-type identifier already on file (uploaded via the CKYC tab,
// which writes straight to client_identifier and never touches this record). Look it
// up the same way the success branch does, just without a document-name hint.
// No existing KYC — that's fine for new clients
this.kycLoaded = true;
this.loadExistingPhoto();
this.loadExistingSignature();
this.loadExistingKycIdentifier(undefined);
}
});
}
@@ -1290,13 +1283,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
*/
private loadExistingKycIdentifier(kycDocumentName: string | undefined): void {
if (!this.clientId) return;
forkJoin({
identifiers: this.clientsService.getClientIdentifiers(this.clientId),
// Only needed to spot the dual-purpose OVD types (Aadhaar/Passport/Voter ID/Driving
// Licence) below — a failure here just means that disambiguation step is skipped.
proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ addressProofs: [] })))
}).subscribe({
next: ({ identifiers, proofTypes }: { identifiers: any[]; proofTypes: any }) => {
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
next: (identifiers: any[]) => {
if (!identifiers || identifiers.length === 0) return;
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const isKycType = (i: any) => {
@@ -1309,13 +1297,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
})
);
};
// Aadhaar, Passport, Voter ID and Driving Licence are valid OVDs for BOTH this slot and
// the separate address-proof one (client-india-address-step) — a client with, say, a PAN
// identity proof and an Aadhaar address proof has two client_identifier rows that both
// pass isKycType. Without a name to go on, prefer whichever candidate is NOT also an
// address-proof type, so this slot doesn't grab the other step's file.
const addressTypeNames = new Set<string>((proofTypes?.addressProofs || []).map((p: any) => cleanStr(p?.name)));
const isAddressType = (i: any) => addressTypeNames.has(cleanStr(i?.documentType?.name));
// Re-uploading a KYC document creates a new identifier row rather than updating the
// old one in place (same pattern as OVD identifiers), so sort newest-first before
// picking — otherwise a repeat upload of the same document type would keep showing
@@ -1330,15 +1311,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return name === kName || name.includes(kName) || kName.includes(name);
})
: null;
// Last resort, when EVERY kyc-type candidate is also address-eligible (e.g. a client
// whose identity proof and address proof are both Aadhaar/Passport/Voter ID/Driving
// Licence): this stepper always runs before the address step in both the create and
// edit wizards, so the identity proof is the one that was on file first — pick the
// OLDEST match here, and the address step (client-india-address-step) picks the NEWEST
// among ITS own address-eligible candidates, so the two steps land on different rows
// without needing to coordinate at runtime.
const identifier =
match || sorted.find((i) => isKycType(i) && !isAddressType(i)) || [...sorted].reverse().find(isKycType);
const identifier = match || sorted.find(isKycType);
if (!identifier?.id) return;
this.existingKycIdentifierId = identifier.id.toString();
@@ -1354,19 +1327,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocumentNumber: identifier.documentKey || ''
});
}
// Same for issue/expiry date — the CKYC tab (and client-india-address-step, for address
// proof) write "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" into the identifier's
// description; read it back here only to fill in whatever the KYC record itself doesn't
// already have, same as the documentKey backfill above.
const desc: string = identifier?.description || '';
const issueMatch = desc.match(/Issue date:\s*([^,|]+)/i);
const expiryMatch = desc.match(/Expiry date:\s*([^,|]+)/i);
if (issueMatch && !this.kycForm.get('kycDocIssueDate')?.value) {
this.kycForm.patchValue({ kycDocIssueDate: this.parseApiDate(issueMatch[1].trim()) });
}
if (expiryMatch && !this.kycForm.get('kycDocExpiryDate')?.value) {
this.kycForm.patchValue({ kycDocExpiryDate: this.parseApiDate(expiryMatch[1].trim()) });
}
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
next: (docs: any[]) => {
@@ -187,6 +187,124 @@
}
<div class="ckyc-upload-grid">
<!-- ID Proof Document -->
<mat-card class="ckyc-upload-card">
<mat-card-content>
<div class="ckyc-upload-header">
<mat-icon class="ckyc-upload-icon">badge</mat-icon>
<span>{{ 'labels.inputs.ID Proof Document' | translate }}</span>
</div>
@if (existing.ID_PROOF.present) {
<div class="ckyc-existing">
<div class="ckyc-existing__head">
<fa-icon icon="check-circle" class="ckyc-existing__ok"></fa-icon>
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.ID_PROOF.source }}</span>
@if (existing.ID_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.ID_PROOF.previewUrl && isImage('ID_PROOF')) {
<img [src]="existing.ID_PROOF.previewUrl" alt="ID proof" class="ckyc-existing__thumb" />
}
@if (existing.ID_PROOF.previewUrl) {
<button mat-stroked-button type="button" class="ckyc-existing__view" (click)="openDocument('ID_PROOF')">
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ID_PROOF.name }}</span>
@if (existing.ID_PROOF.documentKey) {
<span class="ckyc-existing__key">{{ existing.ID_PROOF.documentKey }}</span>
}
@if (existing.ID_PROOF.fileCount && existing.ID_PROOF.fileCount > 0) {
<span class="ckyc-existing__count">
{{ existing.ID_PROOF.fileCount }}
{{ existing.ID_PROOF.fileCount === 1 ? 'file' : 'files' }}
</span>
}
</div>
<button
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="toggleReplace('ID_PROOF')"
>
<fa-icon [icon]="existing.ID_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.ID_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
</button>
</div>
}
@if (!existing.ID_PROOF.present || existing.ID_PROOF.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
type="file"
#idProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'ID_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="idProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="idProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!idProofFile || uploading['ID_PROOF']"
(click)="uploadDocument('ID_PROOF')"
>
@if (uploading['ID_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['ID_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['ID_PROOF'] }}</span>
</div>
}
@if (uploadResponses['ID_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['ID_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['ID_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['ID_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['ID_PROOF'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
<!-- Address Proof Document -->
<mat-card class="ckyc-upload-card">
<mat-card-content>
@@ -202,25 +320,24 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
@if (existing.ADDRESS_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) {
<div class="ckyc-existing__thumbs">
@if (existing.ADDRESS_PROOF.previewUrl) {
<img
[src]="existing.ADDRESS_PROOF.previewUrl"
alt="Address proof scan - front"
class="ckyc-existing__thumb"
/>
}
@if (existing.ADDRESS_PROOF.previewUrlBack) {
<img
[src]="existing.ADDRESS_PROOF.previewUrlBack"
alt="Address proof scan - back"
class="ckyc-existing__thumb"
/>
}
</div>
@if (existing.ADDRESS_PROOF.previewUrl && isImage('ADDRESS_PROOF')) {
<img [src]="existing.ADDRESS_PROOF.previewUrl" alt="Address proof" class="ckyc-existing__thumb" />
}
@if (existing.ADDRESS_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('ADDRESS_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon>
@@ -235,51 +352,78 @@
</span>
}
</div>
@if (cardDate('ADDRESS_PROOF', 'issue') || cardDate('ADDRESS_PROOF', 'expiry')) {
<div class="ckyc-existing__dates">
@if (cardDate('ADDRESS_PROOF', 'issue')) {
<span
><fa-icon icon="calendar-check"></fa-icon> Issued {{ cardDate('ADDRESS_PROOF', 'issue') }}</span
>
}
@if (cardDate('ADDRESS_PROOF', 'expiry')) {
<span
><fa-icon icon="calendar-check"></fa-icon> Expires {{ cardDate('ADDRESS_PROOF', 'expiry') }}</span
>
}
</div>
}
<button
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="openUploadDialog('ADDRESS_PROOF')"
(click)="toggleReplace('ADDRESS_PROOF')"
>
<fa-icon icon="upload"></fa-icon>
Upload new version
</button>
</div>
} @else {
<div class="ckyc-missing">
<p class="ckyc-upload-hint">Not uploaded yet. Accepted formats: PNG, JPEG, JPG, PDF</p>
<button mat-raised-button color="primary" type="button" (click)="openUploadDialog('ADDRESS_PROOF')">
<fa-icon icon="upload"></fa-icon>
&nbsp;{{ 'labels.buttons.Upload' | translate }}
<fa-icon [icon]="existing.ADDRESS_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.ADDRESS_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
</button>
</div>
}
@if (uploading['ADDRESS_PROOF']) {
<div class="ckyc-uploading">
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
<span>Uploading…</span>
</div>
}
@if (uploadErrors['ADDRESS_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['ADDRESS_PROOF'] }}</span>
@if (!existing.ADDRESS_PROOF.present || existing.ADDRESS_PROOF.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
type="file"
#addressProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'ADDRESS_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="addressProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="addressProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!addressProofFile || uploading['ADDRESS_PROOF']"
(click)="uploadDocument('ADDRESS_PROOF')"
>
@if (uploading['ADDRESS_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['ADDRESS_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['ADDRESS_PROOF'] }}</span>
</div>
}
@if (uploadResponses['ADDRESS_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['ADDRESS_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['ADDRESS_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['ADDRESS_PROOF'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
@@ -299,25 +443,24 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
@if (existing.PHOTO_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) {
<div class="ckyc-existing__thumbs">
@if (existing.PHOTO_PROOF.previewUrl) {
<img
[src]="existing.PHOTO_PROOF.previewUrl"
alt="ID proof scan - front"
class="ckyc-existing__thumb"
/>
}
@if (existing.PHOTO_PROOF.previewUrlBack) {
<img
[src]="existing.PHOTO_PROOF.previewUrlBack"
alt="ID proof scan - back"
class="ckyc-existing__thumb"
/>
}
</div>
@if (existing.PHOTO_PROOF.previewUrl && isImage('PHOTO_PROOF')) {
<img [src]="existing.PHOTO_PROOF.previewUrl" alt="Photo proof" class="ckyc-existing__thumb" />
}
@if (existing.PHOTO_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('PHOTO_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="id-card"></fa-icon>
@@ -331,49 +474,78 @@
</span>
}
</div>
@if (cardDate('PHOTO_PROOF', 'issue') || cardDate('PHOTO_PROOF', 'expiry')) {
<div class="ckyc-existing__dates">
@if (cardDate('PHOTO_PROOF', 'issue')) {
<span><fa-icon icon="calendar-check"></fa-icon> Issued {{ cardDate('PHOTO_PROOF', 'issue') }}</span>
}
@if (cardDate('PHOTO_PROOF', 'expiry')) {
<span
><fa-icon icon="calendar-check"></fa-icon> Expires {{ cardDate('PHOTO_PROOF', 'expiry') }}</span
>
}
</div>
}
<button
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="openUploadDialog('PHOTO_PROOF')"
(click)="toggleReplace('PHOTO_PROOF')"
>
<fa-icon icon="upload"></fa-icon>
Upload new version
</button>
</div>
} @else {
<div class="ckyc-missing">
<p class="ckyc-upload-hint">Not uploaded yet. Accepted formats: PNG, JPEG, JPG, PDF</p>
<button mat-raised-button color="primary" type="button" (click)="openUploadDialog('PHOTO_PROOF')">
<fa-icon icon="upload"></fa-icon>
&nbsp;{{ 'labels.buttons.Upload' | translate }}
<fa-icon [icon]="existing.PHOTO_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.PHOTO_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
</button>
</div>
}
@if (uploading['PHOTO_PROOF']) {
<div class="ckyc-uploading">
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
<span>Uploading…</span>
</div>
}
@if (uploadErrors['PHOTO_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['PHOTO_PROOF'] }}</span>
@if (!existing.PHOTO_PROOF.present || existing.PHOTO_PROOF.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
type="file"
#photoProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'PHOTO_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="photoProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="photoProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!photoProofFile || uploading['PHOTO_PROOF']"
(click)="uploadDocument('PHOTO_PROOF')"
>
@if (uploading['PHOTO_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['PHOTO_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['PHOTO_PROOF'] }}</span>
</div>
}
@if (uploadResponses['PHOTO_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['PHOTO_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['PHOTO_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['PHOTO_PROOF'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
@@ -393,42 +565,95 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span>
@if (existing.CLIENT_IMAGE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.CLIENT_IMAGE.previewUrl) {
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_IMAGE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<button
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="openUploadDialog('CLIENT_IMAGE')"
(click)="toggleReplace('CLIENT_IMAGE')"
>
<fa-icon icon="upload"></fa-icon>
Upload new version
</button>
</div>
} @else {
<div class="ckyc-missing">
<p class="ckyc-upload-hint">Not uploaded yet. Accepted formats: PNG, JPEG, JPG</p>
<button mat-raised-button color="primary" type="button" (click)="openUploadDialog('CLIENT_IMAGE')">
<fa-icon icon="upload"></fa-icon>
&nbsp;{{ 'labels.buttons.Upload' | translate }}
<fa-icon [icon]="existing.CLIENT_IMAGE.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.CLIENT_IMAGE.expanded ? 'Cancel replace' : 'Upload new version' }}
</button>
</div>
}
@if (uploading['CLIENT_IMAGE']) {
<div class="ckyc-uploading">
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
<span>Uploading…</span>
</div>
}
@if (uploadErrors['CLIENT_IMAGE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_IMAGE'] }}</span>
@if (!existing.CLIENT_IMAGE.present || existing.CLIENT_IMAGE.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
<div class="ckyc-file-row">
<input
type="file"
#clientImageInput
accept=".png,.jpeg,.jpg"
(change)="onFileSelect($event, 'CLIENT_IMAGE')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="clientImageFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="clientImageInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!clientImageFile || uploading['CLIENT_IMAGE']"
(click)="uploadDocument('CLIENT_IMAGE')"
>
@if (uploading['CLIENT_IMAGE']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['CLIENT_IMAGE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_IMAGE'] }}</span>
</div>
}
@if (uploadResponses['CLIENT_IMAGE']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['CLIENT_IMAGE'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['CLIENT_IMAGE'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['CLIENT_IMAGE'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
@@ -448,6 +673,9 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span>
@if (existing.CLIENT_SIGNATURE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.CLIENT_SIGNATURE.previewUrl) {
@@ -457,6 +685,17 @@
class="ckyc-existing__thumb ckyc-existing__thumb--wide"
/>
}
@if (existing.CLIENT_SIGNATURE.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_SIGNATURE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-signature"></fa-icon>
<span>{{ existing.CLIENT_SIGNATURE.name }}</span>
@@ -465,33 +704,75 @@
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="openUploadDialog('CLIENT_SIGNATURE')"
(click)="toggleReplace('CLIENT_SIGNATURE')"
>
<fa-icon icon="upload"></fa-icon>
Upload new version
</button>
</div>
} @else {
<div class="ckyc-missing">
<p class="ckyc-upload-hint">Not uploaded yet. Accepted formats: PNG, JPEG, JPG</p>
<button mat-raised-button color="primary" type="button" (click)="openUploadDialog('CLIENT_SIGNATURE')">
<fa-icon icon="upload"></fa-icon>
&nbsp;{{ 'labels.buttons.Upload' | translate }}
<fa-icon [icon]="existing.CLIENT_SIGNATURE.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.CLIENT_SIGNATURE.expanded ? 'Cancel replace' : 'Upload new version' }}
</button>
</div>
}
@if (uploading['CLIENT_SIGNATURE']) {
<div class="ckyc-uploading">
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
<span>Uploading…</span>
</div>
}
@if (uploadErrors['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_SIGNATURE'] }}</span>
@if (!existing.CLIENT_SIGNATURE.present || existing.CLIENT_SIGNATURE.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG</p>
<div class="ckyc-file-row">
<input
type="file"
#clientSignatureInput
accept=".png,.jpeg,.jpg"
(change)="onFileSelect($event, 'CLIENT_SIGNATURE')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="clientSignatureFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="clientSignatureInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!clientSignatureFile || uploading['CLIENT_SIGNATURE']"
(click)="uploadDocument('CLIENT_SIGNATURE')"
>
@if (uploading['CLIENT_SIGNATURE']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['CLIENT_SIGNATURE'] }}</span>
</div>
}
@if (uploadResponses['CLIENT_SIGNATURE']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['CLIENT_SIGNATURE'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['CLIENT_SIGNATURE'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
@@ -553,21 +553,15 @@
margin: 0 0 12px;
}
.ckyc-missing {
margin-top: 14px;
.ckyc-file-row {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
gap: 8px;
margin-bottom: 12px;
}
.ckyc-uploading {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
font-size: 12px;
color: #5f6368;
.ckyc-file-name-field {
flex: 1;
}
.ckyc-upload-error {
@@ -589,6 +583,19 @@
}
}
.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;
@@ -603,6 +610,14 @@
.ckyc-upload-grid {
grid-template-columns: 1fr;
}
.ckyc-file-row {
flex-direction: column;
}
.ckyc-file-name-field {
width: 100%;
}
}
/* ===================== Already-available banners ===================== */
@@ -623,6 +638,35 @@
}
}
/* Opens the uploaded artefact full size in a new tab. Sits directly under the thumbnail, and is
the only way to inspect a PDF - which cannot render as an inline <img>. */
.ckyc-existing__view {
align-self: flex-start;
color: #1d6fd0;
border-color: #bcd8f5;
background: #fff;
font-size: 13px;
font-weight: 600;
&:hover {
background: #eef6ff;
}
}
/* Shown when a document row exists but its file could not be fetched from the content store,
so "Already available" is not silently misleading. */
.ckyc-existing__warn {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 10px;
background: #fdecec;
border: 1px solid #f3c2c2;
color: #a12b2b;
font-size: 11px;
font-weight: 600;
}
.ckyc-existing {
display: flex;
flex-direction: column;
@@ -716,34 +760,6 @@
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;
@@ -9,37 +9,78 @@
/** Angular Imports */
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subject, Observable, takeUntil, catchError, of, forkJoin } from 'rxjs';
import { Subject, takeUntil, catchError, of } 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;
}
type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF';
type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
/**
* 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<CkycDocumentType, { name: string; description: string }> = {
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[];
/**
* Client-side upload guards, kept deliberately in step with the backend so a file that passes here
* is not rejected server-side:
* - 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().
*/
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const IMAGE_EXTENSIONS = [
'png',
'jpeg',
'jpg'
];
const DOCUMENT_EXTENSIONS = [
...IMAGE_EXTENSIONS,
'pdf'
];
/** Types whose card renders an inline <img> preview, so a PDF would not display. */
const IMAGE_ONLY_TYPES: CkycDocumentType[] = [
'CLIENT_IMAGE',
'CLIENT_SIGNATURE'
];
@Component({
selector: 'mifosx-ckyc-tab',
@@ -57,8 +98,6 @@ 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<void>();
@@ -92,55 +131,37 @@ export class CkycTabComponent implements OnInit, OnDestroy {
return this.panVerified && this.aadhaarVerified;
}
/** Pre-existing artefacts detected from client data */
existing: Record<CkycDocKind, ExistingArtefact> = {
/** CKYC artefacts already uploaded for this client, keyed by artefact type. */
existing: Record<CkycDocumentType, ExistingArtefact> = {
ID_PROOF: { present: false },
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<ProofKind, ProofType | null> = {
ADDRESS_PROOF: null,
PHOTO_PROOF: null
};
/** Front/back files staged for the two proof cards; other cards keep a single file each */
docFiles: Record<ProofKind, { front: File | null; back: File | null }> = {
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,
@@ -242,168 +263,191 @@ export class CkycTabComponent implements OnInit, OnDestroy {
}
/**
* 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.
* 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.
*/
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.clientsService
.getClientDocuments(this.clientId)
.pipe(
takeUntil(this.destroy$),
catchError(() => of([]))
)
.subscribe((documents: any) => {
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'
};
}
const list: any[] = Array.isArray(documents) ? documents : (documents?.pageItems ?? []);
// 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
};
// Fetch blob for preview
this.clientsService
.getClientSignatureImage(this.clientId, signatureDoc.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (blob) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
}
},
error: () => undefined
});
}
for (const type of CKYC_DOCUMENT_TYPES) {
const expectedName = CKYC_DOCUMENT_META[type].name;
// 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<string>((proofTypes?.idProofs || []).map((p: any) => String(p.id)));
const addressProofIds = new Set<string>((proofTypes?.addressProofs || []).map((p: any) => String(p.id)));
const doc = list
.filter((d: any) => d?.name === expectedName)
.sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0))[0];
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 "<ProofName> - <clientId>"
// 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);
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);
}
});
}
/**
* 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 "<Proof> — 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.
* 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 loadIdentifierDocPreview(kind: ProofKind, identifierId: string): void {
private loadPreview(type: CkycDocumentType, documentId: string | number): void {
this.clientsService
.getClientIdentificationDocuments(identifierId)
.pipe(
takeUntil(this.destroy$),
catchError(() => of([]))
)
.subscribe((docs: any) => {
const list = Array.isArray(docs) ? docs : [];
if (!list.length) return;
this.existing[kind].fileCount = list.length;
const byNewest = (a: any, b: any) => b.id - a.id;
const label = (d: any) => (d?.name || d?.fileName || '').toString();
const back = [...list].filter((d: any) => /back/i.test(label(d))).sort(byNewest)[0];
const front =
[...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] ||
[...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0];
if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front');
if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back');
});
}
private downloadIdentifierDocPreview(
identifierId: string,
documentId: string,
kind: ProofKind,
slot: 'front' | 'back'
): void {
this.clientsService
.downloadClientIdentificationDocument(identifierId, documentId)
// 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)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
if (slot === 'front') this.existing[kind].previewUrl = url;
else this.existing[kind].previewUrlBack = url;
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;
}
},
error: () => undefined
error: () => {
this.existing[type].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 || '';
}
/** 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;
}
/**
* Rejects a file the backend would refuse (or the card could not render) before it is uploaded,
* 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);
const extension = (file.name.split('.').pop() || '').toLowerCase();
if (!allowed.includes(extension)) {
return `Unsupported file type ".${extension}". Allowed: ${allowed.map((e) => e.toUpperCase()).join(', ')}.`;
}
if (file.size > MAX_UPLOAD_BYTES) {
const mb = (file.size / (1024 * 1024)).toFixed(1);
return `File is ${mb} MB. The maximum allowed size is 5 MB.`;
}
if (file.size === 0) {
return 'The selected file is empty.';
}
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;
}
}
onFileSelect(event: any, documentType: string): void {
const file = event.target?.files?.[0];
if (!file) return;
const error = this.validationError(file, documentType);
if (error) {
this.setFile(documentType, null);
this.uploadErrors[documentType] = error;
event.target.value = '';
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':
@@ -413,287 +457,72 @@ export class CkycTabComponent implements OnInit, OnDestroy {
}
}
private static readonly CARD_TITLES: Record<CkycDocKind, string> = {
ADDRESS_PROOF: 'Address Proof Document',
PHOTO_PROOF: 'Photo Proof Document',
CLIENT_IMAGE: 'Image of Client',
CLIENT_SIGNATURE: 'Signature of Client'
};
/** Opens the picker/preview popup for one card and, once the user confirms a file there, feeds
* the picked type/file(s) into the same upload path the card always used. Picking and preview
* now live entirely in the dialog — this screen only stages the result and re-runs upload. */
openUploadDialog(kind: CkycDocKind): void {
const isProofKind = kind === 'ADDRESS_PROOF' || kind === 'PHOTO_PROOF';
const action = this.existing[kind].present ? 'Replace' : 'Upload';
const existingDates = isProofKind
? this.parseDescriptionDates(this.existing[kind].description)
: { issueDate: null, expiryDate: null };
const ref = this.dialog.open(CkycUploadDialogComponent, {
width: '480px',
maxWidth: '95vw',
data: {
title: `${action} ${CkycTabComponent.CARD_TITLES[kind]}`,
isProofKind,
accept: isProofKind ? '.png,.jpeg,.jpg,.pdf' : '.png,.jpeg,.jpg',
proofOptions:
kind === 'PHOTO_PROOF'
? this.idProofOptions
: kind === 'ADDRESS_PROOF'
? this.addressProofOptions
: undefined,
initialProofType: isProofKind ? this.selectedProofType[kind as ProofKind] : undefined,
initialDocumentKey: isProofKind ? this.existing[kind].documentKey || '' : undefined,
initialIssueDate: existingDates.issueDate,
initialExpiryDate: existingDates.expiryDate
}
});
ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => {
if (!result) return;
if (isProofKind) {
const proofKind = kind as ProofKind;
this.selectedProofType[proofKind] = result.proofType || null;
this.docFiles[proofKind] = { front: result.front, back: result.back || null };
this.documentDetails[proofKind] = {
documentKey: result.documentKey || '',
issueDate: result.issueDate || null,
expiryDate: result.expiryDate || null
};
} else if (kind === 'CLIENT_IMAGE') {
this.clientImageFile = result.front;
} else {
this.clientSignatureFile = result.front;
}
this.uploadDocument(kind);
});
}
/** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and
* client-india-address-step, for address proof) writes into the identifier's description. */
private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } {
const issueMatch = (description || '').match(/Issue date:\s*([^,|]+)/i);
const expiryMatch = (description || '').match(/Expiry date:\s*([^,|]+)/i);
return {
issueDate: issueMatch ? this.dates.parseDate(issueMatch[1].trim()) : null,
expiryDate: expiryMatch ? this.dates.parseDate(expiryMatch[1].trim()) : null
};
}
private buildDescription(issueDate: string | null, expiryDate: string | null): string {
const parts: string[] = [];
if (issueDate) parts.push(`Issue date: ${issueDate}`);
if (expiryDate) parts.push(`Expiry date: ${expiryDate}`);
return parts.join(', ');
}
/** Issue/expiry date for display on the card, formatted dd-MM-yyyy — parsed from the same
* description text the upload dialog writes. */
cardDate(kind: ProofKind, which: 'issue' | 'expiry'): string {
const parsed = this.parseDescriptionDates(this.existing[kind].description);
const value = which === 'issue' ? parsed.issueDate : parsed.expiryDate;
return value ? this.dates.formatDate(value, 'dd-MM-yyyy') : '';
}
/**
* Routes each card to whichever real endpoint that artefact is actually stored through — there
* is no single "/clients/{id}/ckyc/documents" resource on the backend, so posting every card to
* one made-up URL 404'd for all four. A photo goes to the image endpoint, a signature to the
* client's own documents, and an ID/address proof to the m_client_identifier it already belongs
* to (the same one the Identities tab and create-client stepper post to) — never a fresh,
* type-less upload, since which identifier a proof belongs to is exactly what makes it that
* proof rather than some other file on the client's record.
*/
uploadDocument(documentType: string): void {
if (documentType === 'PHOTO_PROOF' || documentType === 'ADDRESS_PROOF') {
this.uploadProofDocument(documentType);
return;
}
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;
let request$: Observable<any>;
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;
}
// 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);
request$.pipe(takeUntil(this.destroy$)).subscribe({
next: () => {
next: (response: any) => {
this.uploading[documentType] = false;
this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' });
this.refreshPreview(documentType);
},
error: (err: any) => {
this.uploading[documentType] = false;
this.uploadErrors[documentType] = this.extractErrorMessage(err);
}
});
}
/**
* Proof cards work like the create-client KYC step: pick a document type first (its `sides`
* decides whether one image or a front+back pair is required), then upload. Reuses the same
* identifier the Identities tab manages — updates its documentTypeId if the picked type
* changed, or creates a fresh identifier when none exists yet, so this card never depends on
* a separate trip to the Identities tab.
*/
private uploadProofDocument(kind: ProofKind): void {
const proof = this.selectedProofType[kind];
if (!proof) {
this.uploadErrors[kind] = 'Select a document type first.';
return;
}
const files = this.docFiles[kind];
if (!files.front || (proof.sides === 2 && !files.back)) {
this.uploadErrors[kind] =
proof.sides === 2
? 'Both front and back images are required for this document type.'
: 'Select a file to upload.';
return;
}
this.uploading[kind] = true;
this.uploadErrors[kind] = null;
const existingId = this.existing[kind].documentId;
const details = this.documentDetails[kind];
const documentKey = details.documentKey || `${proof.name} - ${this.clientId}`;
const description = this.buildDescription(details.issueDate, details.expiryDate);
const identifier$: Observable<any> = !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
this.uploadResponses[documentType] = response;
this.setFile(documentType, null);
this.alertService.alert({
type: 'success',
message:
response?.message || (replacing ? 'Document replaced successfully.' : 'Document uploaded successfully.')
});
identifier$.pipe(takeUntil(this.destroy$)).subscribe({
next: (res: any) => {
const identifierId = existingId || res?.resourceId;
const uploads: Observable<any>[] = [];
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);
}
});
// Re-read from m_document so the card reflects what was actually stored, rather than
// assuming the write succeeded exactly as sent.
this.loadExistingArtefacts();
},
error: (err: any) => {
this.uploading[kind] = false;
this.uploadErrors[kind] = this.extractErrorMessage(err);
this.uploading[documentType] = false;
this.uploadErrors[documentType] =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to upload document.';
}
});
}
private extractErrorMessage(err: any): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to upload document.'
);
}
/** Re-fetches whichever artefact was just replaced, so its preview reflects the new file
* immediately instead of the one this screen loaded on entry. */
private refreshPreview(documentType: string): void {
const staleKey = documentType as keyof typeof this.existing;
if (this.existing[staleKey].previewUrl?.startsWith('blob:')) {
URL.revokeObjectURL(this.existing[staleKey].previewUrl!);
}
if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) {
URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!);
}
switch (documentType) {
case 'CLIENT_IMAGE':
this.clientsService
.getClientProfileImage(this.clientId)
.pipe(takeUntil(this.destroy$))
.subscribe((photo: any) => {
if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo;
});
break;
case 'CLIENT_SIGNATURE':
this.clientsService
.getClientDocuments(this.clientId)
.pipe(takeUntil(this.destroy$))
.subscribe((documents: any) => {
const doc = (documents as any[])
?.filter((d: any) => d?.name === 'clientSignature')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (!doc) return;
this.existing.CLIENT_SIGNATURE.documentId = doc.id;
this.clientsService
.getClientSignatureImage(this.clientId, doc.id)
.pipe(takeUntil(this.destroy$))
.subscribe((blob: any) => {
if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
});
});
break;
case 'PHOTO_PROOF':
case 'ADDRESS_PROOF':
this.loadIdentifierDocPreview(documentType, this.existing[documentType].documentId!);
break;
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// Release object URLs
Object.values(this.existing).forEach((e) => {
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
});
}
}
@@ -1,167 +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/.
-->
<div class="cud-header">
<div class="cud-header__badge">
<fa-icon icon="folder-open"></fa-icon>
</div>
<div class="cud-header__text">
<h2 mat-dialog-title class="cud-title">{{ data.title }}</h2>
<p class="cud-subtitle">Fill in the document details, then attach the scan.</p>
</div>
<button mat-icon-button mat-dialog-close class="cud-close" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<mat-dialog-content class="cud-body">
@if (data.isProofKind) {
<div class="cud-section">
<span class="cud-section__label"><fa-icon icon="id-card"></fa-icon> Document type</span>
<mat-form-field appearance="outline" class="cud-field" subscriptSizing="dynamic">
<mat-select
[value]="selectedProofType"
[compareWith]="compareProofType"
placeholder="Select document type"
(selectionChange)="onProofTypeChange($event.value)"
>
@for (p of data.proofOptions; track p.id) {
<mat-option [value]="p">{{ p.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<p class="cud-hint">Upload requirements will update automatically based on the type you pick.</p>
</div>
@if (selectedProofType) {
@if (selectedProofType.info) {
<div class="cud-banner">
<fa-icon icon="check-circle"></fa-icon>
<span>{{ selectedProofType.info }}</span>
</div>
}
<div class="cud-section">
<span class="cud-section__label">
<fa-icon icon="hashtag"></fa-icon>
{{ selectedProofType.documentKeyLabel || 'Document number' }}
</span>
<mat-form-field appearance="outline" class="cud-field" subscriptSizing="dynamic">
<input
matInput
[(ngModel)]="documentKey"
[ngModelOptions]="{ standalone: true }"
[placeholder]="selectedProofType.documentKeyPlaceholder || 'Enter document number'"
/>
</mat-form-field>
</div>
<div class="cud-section cud-section--dates">
<div class="cud-date-field">
<span class="cud-section__label"><fa-icon icon="calendar-check"></fa-icon> Issue date</span>
<mat-form-field appearance="outline" class="cud-field" subscriptSizing="dynamic">
<input
matInput
readonly
[matDatepicker]="issuePicker"
[(ngModel)]="issueDate"
[ngModelOptions]="{ standalone: true }"
placeholder="Optional"
(click)="issuePicker.open()"
/>
<mat-datepicker-toggle matSuffix [for]="issuePicker"></mat-datepicker-toggle>
<mat-datepicker #issuePicker></mat-datepicker>
</mat-form-field>
</div>
<div class="cud-date-field">
<span class="cud-section__label"><fa-icon icon="calendar-check"></fa-icon> Expiry date</span>
<mat-form-field appearance="outline" class="cud-field" subscriptSizing="dynamic">
<input
matInput
readonly
[matDatepicker]="expiryPicker"
[(ngModel)]="expiryDate"
[ngModelOptions]="{ standalone: true }"
placeholder="Optional"
(click)="expiryPicker.open()"
/>
<mat-datepicker-toggle matSuffix [for]="expiryPicker"></mat-datepicker-toggle>
<mat-datepicker #expiryPicker></mat-datepicker>
</mat-form-field>
</div>
</div>
<div class="cud-divider"><span>Scan</span></div>
<div class="cud-grid" [class.cud-grid--two]="sides === 2">
<div class="cud-slot">
<span class="cud-slot__label">{{ sides === 2 ? 'Front side' : 'Document' }}</span>
<label class="cud-dropzone" [class.cud-dropzone--has]="front">
<input type="file" [attr.accept]="data.accept" hidden (change)="onFilePick('front', $event)" />
@if (frontPreview) {
<img [src]="frontPreview" alt="Front side preview" class="cud-dropzone__img" />
} @else if (front) {
<fa-icon icon="file-alt" class="cud-dropzone__icon"></fa-icon>
<p class="cud-dropzone__name">{{ front.name }}</p>
} @else {
<fa-icon icon="cloud-upload-alt" class="cud-dropzone__icon"></fa-icon>
<p class="cud-dropzone__text">Click to choose file</p>
}
</label>
</div>
@if (sides === 2) {
<div class="cud-slot">
<span class="cud-slot__label">Back side</span>
<label class="cud-dropzone" [class.cud-dropzone--has]="back">
<input type="file" [attr.accept]="data.accept" hidden (change)="onFilePick('back', $event)" />
@if (backPreview) {
<img [src]="backPreview" alt="Back side preview" class="cud-dropzone__img" />
} @else if (back) {
<fa-icon icon="file-alt" class="cud-dropzone__icon"></fa-icon>
<p class="cud-dropzone__name">{{ back.name }}</p>
} @else {
<fa-icon icon="cloud-upload-alt" class="cud-dropzone__icon"></fa-icon>
<p class="cud-dropzone__text">Click to choose file</p>
}
</label>
</div>
}
</div>
}
} @else {
<div class="cud-grid">
<div class="cud-slot">
<span class="cud-slot__label">Document</span>
<label class="cud-dropzone" [class.cud-dropzone--has]="front">
<input type="file" [attr.accept]="data.accept" hidden (change)="onFilePick('front', $event)" />
@if (frontPreview) {
<img [src]="frontPreview" alt="Preview" class="cud-dropzone__img" />
} @else if (front) {
<fa-icon icon="file-alt" class="cud-dropzone__icon"></fa-icon>
<p class="cud-dropzone__name">{{ front.name }}</p>
} @else {
<fa-icon icon="cloud-upload-alt" class="cud-dropzone__icon"></fa-icon>
<p class="cud-dropzone__text">Click to choose file</p>
}
</label>
</div>
</div>
}
</mat-dialog-content>
<mat-dialog-actions class="cud-actions" align="end">
<button mat-stroked-button type="button" (click)="cancel()">
<fa-icon icon="times"></fa-icon>
&nbsp;Cancel
</button>
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
<fa-icon icon="upload"></fa-icon>
&nbsp;Upload
</button>
</mat-dialog-actions>
@@ -1,270 +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/.
*/
: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;
}
}
@@ -1,155 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, 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<CkycUploadDialogComponent, CkycUploadDialogResult | null>>(MatDialogRef);
data = inject<CkycUploadDialogData>(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);
}
}
@@ -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
};
@@ -82,13 +82,12 @@
<strong class="kyc-doc-alert__title">{{ kycDocumentAlertTitle }}</strong>
<p class="kyc-doc-alert__text">{{ kycDocumentAlertMessage }}</p>
</div>
<!-- Same dialog as the document's own row: the banner is asking for exactly that. -->
<button
mat-flat-button
color="primary"
type="button"
class="kyc-doc-alert__action"
(click)="updateKycDocument()"
(click)="toggleEditMode()"
>
Update Document
</button>
@@ -471,11 +470,7 @@
}
</span>
<span class="kv-table__col kv-table__col--action">
<!-- The document is re-collected in its own dialog, the way PAN and Aadhaar are dealt
with on their rows, rather than by opening the whole KYC form for editing. -->
<button mat-stroked-button class="kv-action-btn" type="button" (click)="updateKycDocument()">
Update
</button>
<button mat-stroked-button class="kv-action-btn" type="button" (click)="toggleEditMode()">Update</button>
</span>
</div>
}
@@ -1193,83 +1188,15 @@
</mat-form-field>
</div>
<!-- The scan, where the document it belongs to is. Re-collecting an expiring
document is what this panel is for, so sending the officer to the client
edit screen to change the image made them leave the record they were
working on and find the same document again. -->
<div class="kyc-doc-scans">
<div class="kyc-doc-scans__head">
<mat-icon>photo_library</mat-icon>
<span>Scanned document</span>
@if (docScansLoading) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
}
</div>
@if (!docScansLoading && !docScans.front && !docScans.back) {
<div class="kyc-doc-scans__empty">
No scan is on file for this document. Upload one below.
</div>
}
<div class="kyc-doc-scans__sides">
@for (slot of docScanSlots; track slot) {
<div class="kyc-doc-side">
<div class="kyc-doc-side__label">{{ slot === 'front' ? 'Front' : 'Back' }}</div>
<div class="kyc-doc-side__frame">
@if (docScanOf(slot)?.previewUrl) {
<img
[src]="docScanOf(slot)!.previewUrl"
[alt]="slot === 'front' ? 'Front of document' : 'Back of document'"
/>
} @else if (docScanOf(slot)) {
<!-- On file but not an image (a PDF, say) — named rather than shown. -->
<div class="kyc-doc-side__file">
<mat-icon>description</mat-icon>
<span>{{ docScanOf(slot)!.fileName }}</span>
</div>
} @else {
<div class="kyc-doc-side__none">Not on file</div>
}
</div>
<button
type="button"
mat-stroked-button
class="kyc-doc-side__btn"
[disabled]="!!docScanUploading || docScansLoading"
(click)="scanInput.click()"
>
@if (docScanUploading === slot) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
Uploading…
} @else {
{{ docScanOf(slot) ? 'Replace' : 'Upload' }}
}
</button>
<input
#scanInput
type="file"
hidden
accept="image/*,application/pdf"
(change)="onDocScanSelected($event, slot)"
/>
</div>
}
</div>
@if (docScanError) {
<div class="kyc-doc-scans__msg kyc-doc-scans__msg--err">
<mat-icon>error_outline</mat-icon><span>{{ docScanError }}</span>
</div>
}
@if (docScanSuccess) {
<div class="kyc-doc-scans__msg kyc-doc-scans__msg--ok">
<mat-icon>check_circle</mat-icon><span>{{ docScanSuccess }}</span>
</div>
}
</div>
<!-- The scans live with the document itself, on the identifier the customer's
original was attached to. Re-scanning is done there rather than duplicated
here, so both sides, the cropper and the replace-in-place all behave the
same way they did when the document was first collected. -->
<a class="kyc-doc-scans-link" [routerLink]="['/clients', clientId, 'edit']">
<mat-icon>photo_library</mat-icon>
<span>Replace the scanned front / back of this document</span>
<mat-icon class="kyc-doc-scans-link__go">chevron_right</mat-icon>
</a>
</div>
}
@@ -1681,129 +1681,3 @@
.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;
}
}
@@ -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, forkJoin, map, of, Subject, takeUntil } from 'rxjs';
import { catchError, of, Subject, takeUntil } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
@@ -30,11 +30,6 @@ 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';
@@ -43,15 +38,6 @@ 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.
@@ -163,39 +149,9 @@ 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();
});
@@ -234,8 +190,6 @@ 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();
}
});
}
@@ -1353,297 +1307,6 @@ 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
* ("<Proof> — 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;
@@ -1702,11 +1365,5 @@ 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);
}
}
}
}
+33 -6
View File
@@ -569,8 +569,19 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/documents`);
}
downloadClientDocument(parentEntityId: string, documentId: string) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' });
/**
* Fetches a client document's stored file.
*
* `silent` opts the request out of ErrorHandlerInterceptor's alert. Pass it when the caller
* renders its own failure state - notably thumbnail fetches, where a `m_document` row whose file
* is no longer in the content store returns 500 and would otherwise pop a modal the user can do
* nothing about. The interceptor still rethrows, so the caller's error callback continues to run.
*/
downloadClientDocument(parentEntityId: string, documentId: string, silent = false) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, {
responseType: 'blob',
...(silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {})
});
}
uploadClientDocument(clientId: string, documentData: any) {
@@ -992,12 +1003,28 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
}
getClientCkyc(clientId: string): Observable<any> {
return this.http.get(`/clients/${clientId}/ckyc`);
/**
* Uploads a single CKYC artefact against the client, using the platform's generic document
* endpoint (DocumentApiResource, POST /v1/{entityType}/{entityId}/documents). There is no
* CKYC-specific document API on the backend - the earlier `/clients/{id}/ckyc/documents` path
* was never implemented and returned 404.
*
* The multipart body must carry `file`, `name` and `description`; the caller builds those so the
* stored document stays identifiable by CKYC artefact type. See ckyc-tab.component.ts.
*/
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/documents`, formData);
}
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/ckyc/documents`, formData);
/**
* Replaces the content of an existing client document in place (DocumentApiResource PUT
* /v1/{entityType}/{entityId}/documents/{documentId}), keeping the same document id.
*
* Used by the CKYC screen's "Upload new version" so a re-upload updates the existing artefact
* rather than adding another row. Takes the same multipart body as the create call.
*/
replaceClientDocument(clientId: string, documentId: string, formData: FormData): Observable<any> {
return this.http.put(`/clients/${clientId}/documents/${documentId}`, formData);
}
/**
@@ -840,15 +840,6 @@ 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.
@@ -864,9 +855,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: this.buildProofDescription(payload.issueDate, payload.expiryDate)
description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
};
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
@@ -913,7 +904,7 @@ export class CreateClientComponent {
}
const identifierData: any = {
documentTypeId,
status: 'ACTIVE',
status: 'Active',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
description: 'KYC document'
};
@@ -164,13 +164,6 @@ 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();
@@ -1035,15 +1035,6 @@ 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.
@@ -1059,9 +1050,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: this.buildProofDescription(payload.issueDate, payload.expiryDate)
description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
};
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
@@ -1114,7 +1105,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;
}
@@ -1137,7 +1128,7 @@ export class EditClientComponent {
}
const identifierData: any = {
documentTypeId,
status: 'ACTIVE',
status: 'Active',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
description: 'KYC document'
};
@@ -1,137 +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/.
-->
<h2 mat-dialog-title class="kdu-title">
<mat-icon>description</mat-icon>
Update {{ data.documentName }}
</h2>
<mat-dialog-content class="kdu-body">
<!-- Why this is being asked for, in the words the alert used. -->
@if (data.reason) {
<div class="kdu-reason">
<mat-icon>schedule</mat-icon>
<span>{{ data.reason }}</span>
</div>
}
<form [formGroup]="form" class="kdu-form">
<mat-form-field appearance="outline" class="kdu-field kdu-field--wide">
<mat-label>Document Number</mat-label>
<input matInput formControlName="documentNumber" autocomplete="off" />
@if (form.get('documentNumber')?.hasError('required') && form.get('documentNumber')?.touched) {
<mat-error>Document number is required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="kdu-field">
<mat-label>Issued On</mat-label>
<input matInput [matDatepicker]="issuePicker" formControlName="issueDate" />
<mat-datepicker-toggle matSuffix [for]="issuePicker"></mat-datepicker-toggle>
<mat-datepicker #issuePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="kdu-field">
<mat-label>Valid Till</mat-label>
<input matInput [matDatepicker]="expiryPicker" formControlName="expiryDate" />
<mat-datepicker-toggle matSuffix [for]="expiryPicker"></mat-datepicker-toggle>
<mat-datepicker #expiryPicker></mat-datepicker>
<mat-hint>Blank if it does not expire</mat-hint>
</mat-form-field>
<!-- Only where the KYC record is already verified. Changing what a verified record holds is an
amendment, and the reason goes on the record with it. -->
@if (data.requiresRemarks) {
<mat-form-field appearance="outline" class="kdu-field kdu-field--wide">
<mat-label>Reason for update</mat-label>
<textarea
matInput
formControlName="remarks"
rows="2"
placeholder="e.g. Document expired — fresh copy collected from the customer"
></textarea>
<mat-hint>Recorded against the verified KYC record</mat-hint>
@if (form.get('remarks')?.hasError('required') && form.get('remarks')?.touched) {
<mat-error>A reason is required to change a verified KYC record</mat-error>
}
</mat-form-field>
}
</form>
<div class="kdu-scans">
<div class="kdu-scans__head">Scanned copy</div>
<p class="kdu-scans__hint">
Leave a side untouched to keep the scan already on file. The previous copy is kept either way.
</p>
<div class="kdu-sides">
@for (slot of slots; track slot) {
<div class="kdu-side">
<div class="kdu-side__label">
{{ slot === 'front' ? 'Front' : 'Back' }}
@if (isReplaced(slot)) {
<span class="kdu-side__new">New</span>
}
</div>
<div class="kdu-side__frame" [class.kdu-side__frame--new]="isReplaced(slot)">
@if (previewOf(slot)) {
<img [src]="previewOf(slot)" [alt]="slot === 'front' ? 'Front of document' : 'Back of document'" />
} @else if (isReplaced(slot)) {
<!-- Picked, but not an image (a PDF, say) — named rather than shown. -->
<div class="kdu-side__file">
<mat-icon>insert_drive_file</mat-icon>
<span>{{ pickedName(slot) }}</span>
</div>
} @else {
<div class="kdu-side__none">Not on file</div>
}
</div>
<div class="kdu-side__actions">
<button type="button" mat-stroked-button class="kdu-side__btn" (click)="sideInput.click()">
{{ previewOf(slot) || isReplaced(slot) ? 'Choose new scan' : 'Upload scan' }}
</button>
@if (isReplaced(slot)) {
<button
type="button"
mat-icon-button
class="kdu-side__undo"
matTooltip="Keep the scan already on file"
(click)="clearPick(slot)"
>
<mat-icon>undo</mat-icon>
</button>
}
</div>
<input
#sideInput
type="file"
hidden
accept="image/*,application/pdf"
(change)="onFilePicked($event, slot)"
/>
</div>
}
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" class="kdu-actions">
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button
mat-flat-button
color="primary"
type="button"
[disabled]="form.invalid || !hasChanges"
[matTooltip]="!hasChanges ? 'Change a detail or add a scan first' : ''"
(click)="submit()"
>
Save Document
</button>
</mat-dialog-actions>
@@ -1,172 +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/.
*/
.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;
}
@@ -1,215 +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/.
*/
/** 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<KycDocumentUpdateDialogComponent>>(MatDialogRef);
data = inject<KycDocumentUpdateData>(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]!);
}
}
}
}
@@ -299,38 +299,32 @@
</mat-icon>
<a matLine>{{ 'labels.menus.Notifications' | translate }}</a>
</mat-list-item>
<!-- Frequent Postings / Create Journal Entry — hidden while disabled, see
accounting/disabled-features.ts -->
@if (!disabledFeatures.frequentPostings) {
<mat-list-item
[routerLink]="['/accounting/journal-entries/frequent-postings']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.Frequent Postings' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
*mifosxHasPermission="'SIDEBAR_FREQUENT_POSTINGS'"
>
<mat-icon matListIcon>
<fa-icon icon="sync" size="sm"></fa-icon>
</mat-icon>
<a matLine #frequentPostings>{{ 'labels.menus.Frequent Postings' | translate }}</a>
</mat-list-item>
}
@if (!disabledFeatures.createJournalEntries) {
<mat-list-item
[routerLink]="['/accounting/journal-entries/create']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.Create Journal Entry' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
*mifosxHasPermission="'SIDEBAR_JOURNAL_ENTRY'"
>
<mat-icon matListIcon>
<fa-icon icon="plus" size="sm"></fa-icon>
</mat-icon>
<a matLine #createJournalEntry>{{ 'labels.menus.Create Journal Entry' | translate }}</a>
</mat-list-item>
}
<mat-list-item
[routerLink]="['/accounting/journal-entries/frequent-postings']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.Frequent Postings' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
*mifosxHasPermission="'SIDEBAR_FREQUENT_POSTINGS'"
>
<mat-icon matListIcon>
<fa-icon icon="sync" size="sm"></fa-icon>
</mat-icon>
<a matLine #frequentPostings>{{ 'labels.menus.Frequent Postings' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/accounting/journal-entries/create']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.Create Journal Entry' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
*mifosxHasPermission="'SIDEBAR_JOURNAL_ENTRY'"
>
<mat-icon matListIcon>
<fa-icon icon="plus" size="sm"></fa-icon>
</mat-icon>
<a matLine #createJournalEntry>{{ 'labels.menus.Create Journal Entry' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/accounting/chart-of-accounts']"
[matTooltipPosition]="tooltipPosition"
@@ -33,7 +33,6 @@ import { MatIcon } from '@angular/material/icon';
import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { remittanceConfig } from '../../../remittances/remittance.config';
import { DISABLED_ACCOUNTING_FEATURES } from '../../../accounting/disabled-features';
import { environment } from '../../../../environments/environment';
import { catchError, finalize, of, take } from 'rxjs';
@@ -82,8 +81,6 @@ export class SidenavComponent implements OnInit, AfterViewInit {
frequentActivities: any[] = frequentActivities;
/** Whether remittance feature is enabled */
mifosRemittanceEnabled = remittanceConfig.isRemittanceEnabled;
/** Accounting features switched off for this installation — see accounting/disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
/** Whether Superset Analytics is enabled */
supersetEnabled = environment.supersetEnabled;
-29
View File
@@ -35,32 +35,3 @@ export function toIsoDate(value: any): string {
}
return String(value).slice(0, 10);
}
/**
* A Fineract timestamp as a real Date, so Angular's `date` pipe can format it.
*
* LocalDateTime arrives as [y, M, d, H, m, s] — an array the pipe renders as nothing at all, which
* is how a "Raised On" column came to sit empty next to an alert that plainly had been raised.
* Month is 1-based on the wire and 0-based in Date, hence the shift.
*/
export function toApiDate(value: any): Date | null {
if (!value) {
return null;
}
if (Array.isArray(value)) {
const [
year,
month,
day,
hour = 0,
minute = 0,
second = 0
] = value;
if (!year || !month || !day) {
return null;
}
return new Date(year, month - 1, day, hour, minute, second);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
-16
View File
@@ -65,22 +65,6 @@ export interface CtrTransaction {
evidenceDocType?: string;
evidenceReference?: string;
evidencedOn?: string;
/**
* Account and customer facts carried alongside the transaction, read off the books when the CTR
* line is loaded.
*
* Account_Detail and Account_Person_Relation are derived from these rows, and they used to fill
* these columns with constants — every account opened on 20/05/2018, every account ACTIVE, every
* customer reference '0'. Those are statements about a customer's account in a filing, so they
* come from the record rather than from a placeholder.
*/
accountOpenedOn?: string;
accountClosedOn?: string;
accountStatus?: string;
ckycNumber?: string;
clientExternalId?: string;
individual?: boolean;
}
/** Outcome of the Principal Officer's investigation into an STR case */
+301 -33
View File
@@ -14,8 +14,8 @@
<div>
<h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2>
<div class="fiu-head-s">
{{ bank.name }} &middot; {{ scopeName }} &middot; Reporting Month
<strong>{{ reportingMonthLabel }}</strong> &middot; PMLA 2002 / FINnet 2.0
{{ bank.name }} &middot; {{ scopeName }} &middot; Reporting Month <strong>{{ bank.reportingMonth }}</strong>
&middot; PMLA 2002 / FINnet 2.0
</div>
</div>
</div>
@@ -33,13 +33,12 @@
<span class="fiu-meta-v">{{ scopeName }}</span>
}
</div>
<!-- Named only when the person at the screen actually holds the role. A filing screen that
names an officer who is not the one working it misstates who signed the return. -->
@if (principalOfficerName) {
<div>
<span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ principalOfficerName }}</span>
</div>
}
<div>
<span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ bank.principalOfficer }}</span>
</div>
<div>
<span class="fiu-meta-l">REBANK BUID</span><span class="fiu-meta-v mono">{{ bank.reBuid }}</span>
</div>
</div>
</div>
@@ -165,9 +164,7 @@
<div class="dc-meta">
Threshold: <strong>&#8377;{{ bank.cashThreshold | number }}</strong> per single txn / aggregate per month
</div>
<!-- The 15th of the month after the one being reported, worked out rather than fixed at
15 Feb 2026 — a due date that never moves stops being a due date. -->
<div class="dc-meta">Due: <strong>{{ ctrDueDateLabel }}</strong></div>
<div class="dc-meta">Due: <strong>15 Feb 2026</strong></div>
<button class="dc-btn" (click)="showTab('ctr')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
</div>
<div class="dc-card">
@@ -209,29 +206,11 @@
with the same bundle and are worked on the Customer Cards tab.
</p>
</div>
<button class="fiu-btn fiu-btn-xls" [disabled]="!ctrTxns.length" (click)="exportAll()">
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<fa-icon icon="file-csv"></fa-icon> Download full bundle
</button>
</div>
<!-- The grids are built from the reports the bank generated. Until one exists there is
nothing to file, and saying so is better than a screen of plausible-looking rows
nobody can submit. -->
@if (ctrLinesLoading) {
<div class="fiu-note">Loading the generated CTR lines…</div>
} @else if (ctrLinesError) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}
</div>
} @else if (!ctrTxns.length) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
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.
</div>
}
<!-- Scope: these controls decide which transactions the six files carry. -->
<div class="fiu-filters">
<div class="fiu-filter">
@@ -471,7 +450,7 @@
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ asDate(alert.raisedOn) | date: 'medium' }}</td>
<td>{{ alert.raisedOn | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
@@ -544,7 +523,7 @@
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ asDate(alert.raisedOn) | date: 'medium' }}</td>
<td>{{ alert.raisedOn | date: 'medium' }}</td>
<td>{{ alert.reviewedByUsername || '—' }}</td>
<td>
<span class="muted">{{ alert.reviewNotes || '—' }}</span>
@@ -765,6 +744,295 @@
<fa-icon icon="file-alt"></fa-icon> Download STR XML
</button>
</div>
<div class="fiu-filters">
<div class="fiu-filter">
<label>Status</label>
<select class="fiu-inp" [(ngModel)]="strFilterStatus">
<option value="">All</option>
<option value="draft">Draft</option>
<option value="po-review">PO Review</option>
<option value="awaiting-filing">Awaiting Filing</option>
<option value="filed">Filed</option>
<option value="rejected">Closed — Clean</option>
</select>
</div>
<div class="fiu-filter">
<label>Risk Category</label>
<select class="fiu-inp" [(ngModel)]="strFilterRisk">
<option value="">All</option>
<option value="structuring">Structuring</option>
<option value="dormant-revival">Dormant Revival</option>
<option value="high-risk-country">High-Risk Country</option>
<option value="unusual-pattern">Unusual Pattern</option>
<option value="pep-related">PEP Related</option>
<option value="sanctions-hit">Sanctions Hit</option>
</select>
</div>
<button class="fiu-btn fiu-btn-g" (click)="resetStrFilters()">Reset</button>
</div>
<div class="fiu-str-list">
@for (s of filteredStr; track s.reportRefNo) {
<div
class="str-card"
[id]="'str-card-' + s.reportRefNo"
[class.str-card--filed]="s.status === 'filed'"
[class.str-card--highlight]="highlightedStrRef === s.reportRefNo"
>
<div class="str-card-h">
<div class="str-card-id">
<span class="str-ref">STR-{{ s.reportRefNo }}</span>
<span class="status-pill" [class]="'st-' + s.status">{{ statusLabel(s.status) }}</span>
<span class="risk-pill" [class]="'rk-' + s.riskCategory">{{ riskLabel(s.riskCategory) }}</span>
</div>
<div class="str-card-amt">
<span class="muted">{{ s.depositOrWithdrawal }}</span>
<strong>&#8377;{{ s.txnAmount | number: '1.0-0' }}</strong>
</div>
</div>
<div class="str-card-body">
<div class="str-meta">
<div>
<label>Customer</label><span>{{ s.customerName }}</span>
</div>
<div>
<label>Account</label><span class="mono">{{ s.accountNumber }}</span>
</div>
<div>
<label>Txn Date</label><span>{{ s.txnDate }} {{ s.txnTime }}</span>
</div>
<div>
<label>Detected</label><span>{{ s.detectedOn }}</span>
</div>
<div>
<label>Flagged By</label><span>{{ s.flaggedBy }}</span>
</div>
<div>
<label>Instrument</label><span>{{ s.instrumentType }} {{ s.instrumentId }}</span>
</div>
<div>
<label>Branch</label><span class="mono">{{ s.branchCode }} {{ branchNameFor(s.branchCode) }}</span>
</div>
</div>
<div class="str-narrative">
<label>Narrative</label>
<p>{{ s.narrative }}</p>
</div>
@if (s.poRemarks) {
<div class="str-po-remarks">
<label><fa-icon icon="check-circle"></fa-icon> PO Remarks</label>
<p>{{ s.poRemarks }}</p>
</div>
}
@if (s.poFinding) {
<div class="str-finding" [class.str-finding--suspicious]="s.poFinding === 'SUSPICIOUS'">
<label>
<fa-icon [icon]="s.poFinding === 'CLEAN' ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
Principal Officer Finding
</label>
<p>{{ poFindingLabel(s.poFinding) }}</p>
</div>
}
<!-- Concluded suspicious but not yet submitted to FIU-IND -->
@if (s.status === 'awaiting-filing') {
<div class="str-filing" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>
<fa-icon icon="exclamation-triangle"></fa-icon> Awaiting submission to FIU-IND — due within 7 days
of {{ s.detectedOn }}
</label>
<p class="filing-hint">
Generate the FINnet STR XML, upload it on the FIU-IND portal under
{{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here.
</p>
<div class="filing-row">
<button class="fiu-btn" (click)="exportSingleStrXml(s)">
<fa-icon icon="file-alt"></fa-icon> Generate STR XML
</button>
<input
class="fiu-inp"
type="text"
placeholder="FIU-IND acknowledgement no."
[(ngModel)]="localFileRefs[s.reportRefNo]"
/>
<button class="fiu-btn fiu-btn-xml" (click)="fileLocalStr(s)">Mark STR Filed</button>
</div>
</div>
}
@if (s.status === 'filed' && s.fiuReferenceNo) {
<div class="str-filed-ack">
<fa-icon icon="check-circle"></fa-icon>
<span>
Filed with FIU-IND on <strong>{{ s.filedOn }}</strong> &middot; acknowledgement
<strong class="mono">{{ s.fiuReferenceNo }}</strong>
</span>
</div>
}
@if (!s.poFinding && investigatingStr?.reportRefNo !== s.reportRefNo) {
<div class="str-po-action" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>Principal Officer investigation — is the source of funds legitimate?</label>
<button class="fiu-btn fiu-btn-xml" (click)="investigateLocalStr(s)">
<fa-icon icon="search"></fa-icon> Investigate this case
</button>
</div>
}
<!-- Investigation workspace for this case -->
@if (investigatingStr?.reportRefNo === s.reportRefNo) {
<div class="str-investigation" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="inv-head">
<span>Principal Officer investigation — STR-{{ s.reportRefNo }}</span>
<button class="fiu-link-btn fiu-link-btn--muted" (click)="closeLocalStrInvestigation()">
Cancel
</button>
</div>
<!-- Step 1 — call for evidence -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">1</span> Documentary evidence called for</div>
<div class="inv-grid">
<div class="form-row">
<label for="invDocType">Document requested from the customer</label>
<select id="invDocType" class="fiu-inp" [(ngModel)]="evidenceDocType">
<option value="">Select document…</option>
@for (d of evidenceDocTypes; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</div>
<div class="form-row">
<label for="invReceived">Was it produced?</label>
<select id="invReceived" class="fiu-inp" [(ngModel)]="evidenceReceived">
<option value="">Select…</option>
<option value="YES">Yes — complete</option>
<option value="PARTIAL">Partially — incomplete</option>
<option value="NO">No — not produced</option>
</select>
</div>
<div class="form-row">
<label for="invRef">Document / reference number</label>
<input id="invRef" class="fiu-inp" type="text" [(ngModel)]="evidenceReference" />
</div>
</div>
</div>
<!-- Step 2 — customer's account of the funds -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">2</span> Customer’s explanation of the source</div>
<textarea
rows="2"
[(ngModel)]="customerExplanation"
placeholder="e.g. Proceeds of a flat sold in June; sale deed produced and matched to the deposit amount and date."
></textarea>
</div>
<!-- Step 3 — verification checklist -->
<div class="inv-step">
<div class="inv-step-t">
<span class="inv-num">3</span> Verification checklist
<span class="checklist-progress" [class.done]="allAnswered">
{{ answeredCount }} / {{ strChecklist.length }} answered
</span>
</div>
<table class="checklist-tbl">
<thead>
<tr>
<th>Verification point</th>
<th class="ta-c col-yes">Genuine</th>
<th class="ta-c col-no">Not satisfied</th>
</tr>
</thead>
<tbody>
@for (item of strChecklist; track item.key) {
<tr
[class.row-yes]="strFindings[item.key] === true"
[class.row-no]="strFindings[item.key] === false"
[class.row-pending]="strFindings[item.key] === null"
>
<td>
<div class="chk-label">{{ item.label }}</div>
<div class="chk-hint">{{ item.hint }}</div>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--yes"
[class.on]="strFindings[item.key] === true"
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
(click)="setFinding(item.key, true)"
>
<fa-icon icon="check-circle"></fa-icon>
</button>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--no"
[class.on]="strFindings[item.key] === false"
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
(click)="setFinding(item.key, false)"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Derived outcome -->
<div
class="verdict"
[class.verdict--clear]="canConcludeLocal && derivedConclusion === 'NOT_SUSPICIOUS'"
[class.verdict--suspicious]="canConcludeLocal && derivedConclusion === 'SUSPICIOUS'"
>
@if (!canConcludeLocal) {
<fa-icon icon="info-circle"></fa-icon>
<span>
Complete all three steps to determine the outcome.
@if (!evidenceRecorded) {
<span> Evidence not yet recorded.</span>
}
@if (!allAnswered) {
<span> {{ strChecklist.length - answeredCount }} verification point(s) pending.</span>
}
</span>
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon>
<span>
<strong>Source of funds established.</strong> Case closes as clean — the transaction stays in
the CTR report and no STR is filed with FIU-IND.
</span>
} @else {
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>Source of funds not established.</strong> {{ failedFindings.length }} point(s)
unsatisfied — an STR will be filed with FIU-IND in addition to the CTR.
</span>
}
</div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!canConcludeLocal" (click)="concludeLocalStr()">
{{
canConcludeLocal
? derivedConclusion === 'SUSPICIOUS'
? 'Conclude — file STR with FIU-IND'
: 'Conclude — close as clean (CTR only)'
: 'Conclude'
}}
</button>
</div>
}
</div>
</div>
} @empty {
<div class="fiu-empty-card">No STR records match the filters.</div>
}
</div>
</div>
}
-11
View File
@@ -1086,17 +1086,6 @@
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 */
+32 -253
View File
@@ -13,14 +13,17 @@ 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,
@@ -124,7 +127,7 @@ export const FIU_OBLIGATIONS: FiuObligation[] = [
]
})
export class FiuComponent implements OnInit {
/** Bump when the seeded STR set changes so cached rows are re-seeded. */
/** Bump when CTR_TXNS / STR_TXNS change so cached rows are re-seeded. */
private static readonly SEED_VERSION = '2026-01-cash-threshold';
private amlService = inject(AmlService);
@@ -137,33 +140,11 @@ export class FiuComponent implements OnInit {
readonly obligations = FIU_OBLIGATIONS;
bank = FIU_BANK;
/**
* 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[] = [];
ctrTxns: CtrTransaction[] = CTR_TXNS;
tc1Txns: CardTransaction[] = TC1_TXNS;
strTxns: StrTransaction[] = STR_TXNS;
/**
* 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[] = [];
kc1: KycIndividual[] = KC1_INDIVIDUALS;
kc2: KycEntity[] = KC2_ENTITIES;
// -------------------- Head Office vs Branch scope --------------------
// CTR/STR filing itself is a bank-wide Principal-Officer function (one filing for the whole
@@ -174,12 +155,6 @@ 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 = '';
@@ -397,21 +372,6 @@ 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 {
@@ -424,17 +384,22 @@ 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);
@@ -477,7 +442,7 @@ export class FiuComponent implements OnInit {
txn.evidenceDocType = this.ctrEvidenceDocType;
txn.evidenceReference = this.ctrEvidenceRef.trim();
txn.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
this.storeCtrCompliance();
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
this.evidencingTxn = null;
this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`);
}
@@ -488,7 +453,7 @@ export class FiuComponent implements OnInit {
*/
referCtrTxnToStr(txn: CtrTransaction): void {
txn.complianceStatus = 'REFERRED';
this.storeCtrCompliance();
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1;
const newStrCase: StrTransaction = {
@@ -556,7 +521,7 @@ export class FiuComponent implements OnInit {
origin.evidenceDocType = this.evidenceDocType;
origin.evidenceReference = this.evidenceReference.trim();
origin.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
this.storeCtrCompliance();
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
}
this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`);
} else {
@@ -633,129 +598,6 @@ 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<string, any> = 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<string, any> = {};
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`);
@@ -812,9 +654,6 @@ 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')
});
@@ -1177,9 +1016,7 @@ export class FiuComponent implements OnInit {
t.txnBranchCode,
t.depositOrWithdrawal,
t.txnId.toString(),
// 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.txnDate.replace(/-/g, '/'),
t.txnTime,
t.txnAmount.toString(),
t.instrumentType,
@@ -1417,19 +1254,6 @@ 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 = [
@@ -1466,15 +1290,12 @@ 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,
// 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 || '',
'20/05/2018',
'',
'ACTIVE',
'',
debits.length.toString(),
debits.reduce((s, x) => s + x.txnAmount, 0).toString(),
@@ -1512,16 +1333,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',
// 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',
k ? k.ucic : '0',
'Individual',
t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : ''
]);
}
@@ -1667,56 +1488,14 @@ 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.amlStrCases || []).filter(
(c: any) => c.conclusion === 'SUSPICIOUS' || c.status === 'CONCLUDED_SUSPICIOUS' || c.status === 'FILED'
);
const suspects = this.strSuspects;
if (!suspects.length) {
this.toast('No case has been concluded suspicious yet — nothing to file');
this.toast('No suspect cases to export — nothing matches the current filters');
return;
}
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 version="1.0" encoding="UTF-8"?>');
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`);
xml.push(` <REBank name="${this.escapeXml(this.bank.name)}" />`);
if (this.principalOfficerName) {
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);
}
for (const c of cases) {
xml.push(` <Suspect ref="${c.id}" status="${this.escapeXml(c.status || '')}">`);
xml.push(` <Customer id="${c.clientId}">${this.escapeXml(c.clientDisplayName || '')}</Customer>`);
xml.push(` <Conclusion>${this.escapeXml(c.conclusion || '')}</Conclusion>`);
if (c.investigationNotes) {
xml.push(` <Narrative>${this.escapeXml(c.investigationNotes)}</Narrative>`);
}
if (c.sourceOfFundsNotes) {
xml.push(` <SourceOfFunds>${this.escapeXml(c.sourceOfFundsNotes)}</SourceOfFunds>`);
}
if (c.fiuReferenceNo) {
xml.push(` <Acknowledgement ref="${this.escapeXml(c.fiuReferenceNo)}" filedOn="${c.filedOn ?? ''}" />`);
}
xml.push(' </Suspect>');
}
xml.push('</STR>');
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);
this.writeStrXml(suspects, `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`);
this.toast(`STR XML exported (${suspects.length} suspects)`);
}
/** Serialises a set of suspect cases as FINnet 2.0 STR XML and downloads it. */
@@ -25,68 +25,10 @@ import { Form15Kind, Form15ghRecord } from './form-15gh/form-15gh.component';
import { HypothecationRecord } from './hypothecation/hypothecation.component';
import { NocRecord } from './noc/noc.component';
/**
* 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 BANK_NAME = 'Nexora Co-operative Bank Ltd.';
const BANK_ADDRESS = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
const BANK_REG = 'RBI Lic. No. NX-2018/MH/482';
const BANK_CONTACT = 'info@nexorabank.in · 022 6789 0000';
const C_TEXT: [number, number, number] = [
15,
@@ -199,24 +141,14 @@ function paintLetterhead(
c.doc.setFont('times', 'bold');
c.doc.setFontSize(19);
setText(c, c.accent);
c.doc.text(bankIdentity.name, c.pageW / 2, y, { align: 'center' });
c.doc.text(BANK_NAME, c.pageW / 2, y, { align: 'center' });
y += 5.5;
c.doc.setFont('helvetica', 'normal');
c.doc.setFontSize(9);
setText(c, C_MUTED);
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' });
}
c.doc.text(BANK_ADDRESS, c.pageW / 2, y, { align: 'center' });
y += 4;
c.doc.text(`${BANK_REG} · ${BANK_CONTACT}`, c.pageW / 2, y, { align: 'center' });
y += 5;
setDraw(c, C_BORDER);
c.doc.setLineWidth(0.3);
@@ -418,10 +350,11 @@ 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(bankIdentity.sealPrimary, cx, cy - 1, { align: 'center' });
c.doc.text('NEXORA', cx, cy - 1, { align: 'center' });
c.doc.setFontSize(5);
setText(c, C_MUTED);
c.doc.text(bankIdentity.sealSecondary, cx, cy + 4, { align: 'center' });
c.doc.text('CO-OPERATIVE', cx, cy + 3, { align: 'center' });
c.doc.text('BANK', cx, cy + 5.5, { align: 'center' });
return y + 28;
}
@@ -443,7 +376,7 @@ function paintFooter(c: PdfCtx, y: number): void {
c.doc.setFontSize(8);
setText(c, C_MUTED);
c.doc.text(
`Issued by ${bankIdentity.name} on ${formatLongDate(today())}. Computer generated — does not require physical signature for record purposes.`,
`Issued by ${BANK_NAME} on ${formatLongDate(today())}. Computer generated — does not require physical signature for record purposes.`,
c.pageW / 2,
y,
{ align: 'center' }
@@ -676,15 +609,13 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
doc.setFont('times', 'bold');
doc.setFontSize(22);
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
doc.text(bankIdentity.name.toUpperCase(), pageW / 2, y, { align: 'center' });
doc.text('NEXORA CO-OPERATIVE BANK LTD.', 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 += 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' });
y += 4;
doc.setDrawColor(242, 237, 213); // light gold separator
@@ -754,7 +685,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 ${bankIdentity.name}, 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 Nexora Co-operative Bank Ltd., 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' });
@@ -832,7 +763,7 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
// Row 1
drawTableCell(25, y, tableColW, tableRowH, 'Branch', true);
drawTableCell(25 + tableColW, y, tableColW, tableRowH, r.branch || 'Head Office', false);
drawTableCell(25 + tableColW, y, tableColW, tableRowH, r.branch || 'Mumbai Main Branch', false);
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Member Since', true);
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, formatLongDate(r.joiningDate), false);
y += tableRowH;
@@ -1237,7 +1168,7 @@ export function buildHypothecationPdf(r: HypothecationRecord): jsPDF {
y = paintPara(
c,
y,
`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.`
`This is to certify that the asset described below stands hypothecated to ${BANK_NAME} as security for the captioned loan facility extended to ${r.customerName}, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged.`
);
const rows: [string, string, string, string][] = [
@@ -684,9 +684,9 @@
<div class="cpd-seal">
<div class="cpd-seal-ring">
<div class="cpd-seal-inner">
<div class="cpd-seal-bank">{{ bankSealPrimary }}</div>
<div class="cpd-seal-bank">NEXORA</div>
<div class="cpd-seal-line"></div>
<div class="cpd-seal-sub">{{ bankSealSecondary }}</div>
<div class="cpd-seal-sub">CO-OPERATIVE BANK</div>
</div>
</div>
</div>
@@ -575,19 +575,8 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* ───────── 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. */
/* ───────── Print — only the page, no toolbar / dialog chrome ───────── */
@media print {
body > :not(.cdk-overlay-container) {
display: none !important;
}
:host {
background: #fff;
}
@@ -596,29 +585,11 @@
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 {
@@ -628,7 +599,6 @@
width: 100%;
box-shadow: none;
border-top: 4px solid var(--cpd-blue-d);
overflow: visible;
&.cpd-page--share {
border-top-color: var(--cpd-amber);
@@ -6,13 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from 'app/organization/organization.service';
import { FyInterestBreakup, LoanInterestRecord } from '../loan-interest/loan-interest.component';
import { ShareCertificateRecord } from '../share-certificate/share-certificate.component';
@@ -28,8 +27,7 @@ import {
downloadHypothecationPdf,
downloadLoanInterestPdf,
downloadNocPdf,
downloadShareCertificatePdf,
setBankIdentity
downloadShareCertificatePdf
} from '../certificate-pdf';
export interface InterestPreviewPayload {
@@ -72,52 +70,14 @@ export interface CertificatePreviewDialogData {
MatTooltip
]
})
export class CertificatePreviewDialogComponent implements OnInit {
export class CertificatePreviewDialogComponent {
private dialogRef = inject(MatDialogRef<CertificatePreviewDialogComponent>);
private organizationService = inject(OrganizationService);
data: CertificatePreviewDialogData = inject(MAT_DIALOG_DATA);
/** 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: () => {}
});
}
readonly bankName = 'Nexora Co-operative Bank Ltd.';
readonly bankAddress = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
readonly bankRegNo = 'RBI Lic. No. NX-2018/MH/482';
readonly bankContact = 'info@nexorabank.in · 022 6789 0000';
get isInterest(): boolean {
return this.data.kind === 'interest';
@@ -17,9 +17,8 @@ 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, setBankIdentity } from '../../certificate-pdf';
import { downloadShareCertificatePdf } from '../../certificate-pdf';
import { DscSignService } from '../../../dsc-sign-dialog/dsc-sign.service';
import type { ShareClass, ShareCertificateRecord } from '../share-certificate.component';
@@ -48,7 +47,6 @@ 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;
@@ -72,14 +70,6 @@ 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 {
@@ -19,9 +19,8 @@ import {
CertificatePreviewDialogComponent,
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadShareCertificatePdf, setBankIdentity } from '../certificate-pdf';
import { downloadShareCertificatePdf } 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';
@@ -63,7 +62,6 @@ 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';
@@ -82,13 +80,6 @@ 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 ──
@@ -275,12 +275,9 @@
<a [routerLink]="['/bank-calendar']" class="ip-reflink"
><i class="fas fa-calendar-check"></i> Holiday Calendar</a
>
<!-- Closing Entries — hidden while disabled, see accounting/disabled-features.ts -->
@if (!disabledFeatures.closingEntries) {
<a [routerLink]="['/accounting/closing-entries']" class="ip-reflink"
><i class="fas fa-calendar-day"></i> Financial Year / Closing Entries</a
>
}
<a [routerLink]="['/accounting/closing-entries']" class="ip-reflink"
><i class="fas fa-calendar-day"></i> Financial Year / Closing Entries</a
>
<a [routerLink]="['/branch-ops/day-register']" class="ip-reflink"
><i class="fas fa-clock"></i> Business Date (Day Register)</a
>
@@ -12,7 +12,6 @@ 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' },
@@ -64,8 +63,6 @@ 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;
@@ -104,8 +104,7 @@ 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 || environment.fineractPlatformTenantId
'Fineract-Platform-TenantId': tenantForFilter || this.selectedTenant || (this.tenants[0]?.identifier ?? 'default')
};
}
@@ -127,9 +126,7 @@ export class PlatformMenuAccessComponent {
this.rememberKey();
this.loadingTenants = true;
try {
const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), {
headers: this.headers(environment.fineractPlatformTenantId)
});
const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), { headers: this.headers('default') });
if (!res.ok) {
throw new Error(
res.status === 403 || res.status === 401 ? 'Invalid or inactive API key.' : `Server error (${res.status}).`
@@ -110,7 +110,8 @@
[shareProductsTemplate]="shareProductsTemplate"
[accountingRuleData]="accountingRuleData"
[shareProduct]="shareProduct"
[showActions]="false"
[taskPermission]="'CREATE_SHAREPRODUCT'"
(submitEvent)="submit()"
></mifosx-share-product-preview-step>
</div>
</mat-tab>
@@ -250,13 +250,11 @@
</div>
</div>
@if (showActions) {
<div class="shpp-actions">
<button mat-raised-button [routerLink]="['../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" (click)="submitEvent.emit()" *mifosxHasPermission="taskPermission">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
}
<div class="shpp-actions">
<button mat-raised-button [routerLink]="['../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" (click)="submitEvent.emit()" *mifosxHasPermission="taskPermission">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
@@ -52,10 +52,6 @@ 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[] = [
@@ -51,6 +51,15 @@
<span>Could not load the review queue.</span>
</div>
<!-- Named on screen, because sample rows must never be mistaken for engine output on an AML queue -->
<div *ngIf="usingDemoData && rows.data.length > 0" class="ra-demo">
<mat-icon>science</mat-icon>
<span>
Showing <strong>sample data</strong> — the AML engine returned no recurring alerts for this threshold and window.
These rows are for demonstration only and are not reportable.
</span>
</div>
<table mat-table [dataSource]="rows" class="ra-table mat-elevation-z1" multiTemplateDataRows>
<ng-container matColumnDef="customer">
<th mat-header-cell *matHeaderCellDef>Customer</th>
@@ -79,6 +79,27 @@
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;
@@ -25,6 +25,7 @@ import { AmlService } from '../../../aml/aml.service';
import { AlertService } from '../../../core/alert/alert.service';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
import { 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.
@@ -94,6 +95,8 @@ 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;
@@ -108,20 +111,20 @@ export class AmlRecurringAlertsComponent implements OnInit {
this.amlService.getRecurringAlerts(this.minAlerts, this.windowDays, this.onlyNeedingReview).subscribe({
next: (data) => {
this.loading = false;
this.rows.data = this.mapRows(data || []);
const rows = data || [];
this.usingDemoData = rows.length === 0;
this.rows.data = this.mapRows(this.usingDemoData ? this.demoRows() : rows);
},
error: () => {
this.loading = false;
this.loadFailed = true;
// 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 = [];
this.usingDemoData = true;
this.rows.data = this.mapRows(this.demoRows());
}
});
}
/** Parses the date fields the template renders. */
/** Parses the date fields the template renders. Shared by engine rows and the sample set. */
private mapRows(rows: any[]): any[] {
return rows.map((r: any) => ({
...r,
@@ -132,6 +135,36 @@ 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;
}
@@ -0,0 +1,248 @@
/**
* 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.'
}
]
}
];
}
+5 -12
View File
@@ -7,7 +7,6 @@
*/
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.
@@ -391,23 +390,17 @@ export interface LiveAmlAlert {
ruleDescription: string;
amount: number;
currencyCode: string;
/** Fineract sends LocalDateTime as [y,m,d,h,mi,s], not a string. */
raisedOn: number[] | string;
raisedOn: string;
status: string;
reviewedByUsername: string | null;
reviewedOn: number[] | string | null;
reviewedOn: string | null;
reviewNotes: string | null;
disposition: string | null;
}
/**
* 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);
/** 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);
}
/**
@@ -150,62 +150,33 @@
} @else {
<!-- SUMMARY -->
<div class="rr-kpis">
<!-- Each tile reads as a question, so each one answers it by narrowing 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 below. -->
<button type="button" class="rr-kpi" (click)="focusAllCustomers()" title="Show every customer in the period">
<div class="rr-kpi">
<div class="rr-kpi-n">{{ summary.customers }}</div>
<div class="rr-kpi-l">Customers for review</div>
<div class="rr-kpi-sub">
{{ summary.byRisk.LOW }} Low · {{ summary.byRisk.MEDIUM }} Medium · {{ summary.byRisk.HIGH }} High
</div>
</button>
<button
type="button"
class="rr-kpi rr-kpi--blue"
[class.rr-kpi--on]="openOnly"
(click)="focusOpenAlerts()"
title="Show only the cases still awaiting a decision"
>
</div>
<div class="rr-kpi rr-kpi--blue">
<div class="rr-kpi-n">{{ summary.alerts }}</div>
<div class="rr-kpi-l">AML alerts triggered</div>
<div class="rr-kpi-sub">on {{ summary.txns }} transactions · {{ summary.openAlerts }} awaiting a decision</div>
</button>
<button
type="button"
</div>
<div
class="rr-kpi rr-kpi--amber"
[class.rr-kpi--dim]="summary.upgradeCandidates + summary.downgradeCandidates === 0"
[class.rr-kpi--on]="direction !== ''"
(click)="focusRatingsToMove()"
[title]="
summary.upgradeCandidates + summary.downgradeCandidates === 0
? 'No rating needs moving in this period'
: 'Show the customers whose rating should move'
"
>
<div class="rr-kpi-n">{{ summary.upgradeCandidates + summary.downgradeCandidates }}</div>
<div class="rr-kpi-l">Ratings to be moved</div>
<div class="rr-kpi-sub">
{{ summary.upgradeCandidates }} upward · {{ summary.downgradeCandidates }} downward
</div>
</button>
<button
type="button"
class="rr-kpi"
[class.rr-kpi--red]="summary.escalatedCustomers > 0"
[class.rr-kpi--dim]="summary.escalatedCustomers === 0"
[class.rr-kpi--on]="status === 'ESCALATED_STR'"
(click)="focusEscalated()"
[title]="
summary.escalatedCustomers === 0
? 'No case has gone to the Principal Officer in this period'
: 'Show only the cases with the Principal Officer'
"
>
</div>
<div class="rr-kpi" [class.rr-kpi--red]="summary.escalatedCustomers > 0">
<div class="rr-kpi-n">{{ summary.escalatedCustomers }}</div>
<div class="rr-kpi-l">Escalated to STR</div>
<div class="rr-kpi-sub">₹{{ summary.totalAmount | number: '1.0-0' }} under review</div>
</button>
</div>
</div>
<!-- RULE MIX — doubles as a one-click filter -->
@@ -249,7 +220,7 @@
row does not have to repeat it in a Move To and a Position column. -->
@for (g of customerGroups; track g.key) {
<div class="rr-group" [class]="'rr-group--' + g.tone">
<button type="button" class="rr-group-head" (click)="toggleGroup(g.key)">
<button class="rr-group-head" (click)="toggleGroup(g.key)">
<fa-icon [icon]="collapsedGroups[g.key] ? 'caret-right' : 'caret-down'"></fa-icon>
<span class="rr-group-t">{{ g.title }}</span>
<span class="rr-group-n">{{ g.rows.length }}</span>
@@ -267,7 +238,6 @@
<th>Account Number</th>
<th>Branch</th>
<th>Rating on file</th>
<th>Review says</th>
<th class="num">Alerts</th>
<th>AML Rules Triggered</th>
<th class="num">Amount under review</th>
@@ -298,20 +268,6 @@
<td>
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
</td>
<!-- The verdict itself, on the row. The section heading says it once for the
whole group, but a reviewer reading a single row - or an exported sheet -
could not tell whether the rating was to be raised, lowered or left. The
reason sits under it rather than under the button, because it explains
this conclusion and not the act of opening the KYC screen. -->
<td class="rr-move-cell">
<span class="rr-move" [class]="'rr-move--' + c.direction">
<fa-icon [icon]="directionIcon(c)"></fa-icon>
{{ directionText(c) }}
</span>
@if (c.directionReason) {
<div class="rr-verdict-why">{{ c.directionReason }}</div>
}
</td>
<td class="num">{{ c.alertCount }}</td>
<td class="rr-rules-cell">
@for (label of c.ruleLabels; track label) {
@@ -320,11 +276,10 @@
</td>
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
<td class="mono">{{ c.lastAlertDate }}</td>
<!-- Only the control. The verdict and its reason moved to their own column,
so this no longer wraps to two lines under the button. -->
<td class="rr-action-cell">
<!-- The verdict is the section this row sits in, so the cell only carries the
button that acts on it, and the reason behind it. -->
<td class="rr-verdict-cell">
<button
type="button"
class="rr-kyc-link"
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
(click)="openKyc(c, $event)"
@@ -332,12 +287,15 @@
>
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
</button>
@if (c.directionReason) {
<div class="rr-verdict-why">{{ c.directionReason }}</div>
}
</td>
</tr>
@if (expanded === c.clientId) {
<tr class="rr-detail">
<td colspan="12">
<td colspan="11">
<!-- One block per alert: what the rule is, then the entries it fired on. -->
@for (a of c.alerts; track a.alertId) {
<div class="rr-case">
@@ -286,61 +286,12 @@
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);
@@ -356,19 +307,6 @@
&--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);
}
}
@@ -564,12 +502,9 @@
}
td {
padding: 11px 10px;
padding: 8px 10px;
border-bottom: 1px solid var(--r-g100);
/* 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;
vertical-align: top;
}
.num {
@@ -603,9 +538,6 @@
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;
}
}
@@ -857,20 +789,6 @@
}
/* 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;
@@ -899,10 +817,9 @@
}
.rr-verdict-why {
font-size: 11px;
line-height: 1.45;
font-size: 10.5px;
color: var(--r-g500);
margin-top: 4px;
margin-top: 2px;
white-space: normal;
}
@@ -224,35 +224,21 @@ 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) =>
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: '' }))
this.clients.getClientKyc(String(id), true).pipe(
map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })),
catchError(() => of({ id, risk: null as RiskLevel | null }))
)
)
).subscribe((results) => {
const byClient = new Map(results.map((r) => [r.id, r]));
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 || ''
};
});
const byClient = new Map(
results.map((r) => [
r.id,
r.risk
])
);
this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null }));
this.compose();
this.loading = false;
});
@@ -403,62 +389,8 @@ export class RiskReviewReportComponent implements OnInit {
return groups.filter((g) => g.rows.length > 0);
}
/**
* Collapsed sections, by group key. Every group starts open.
*
* 'No change needed' used to start closed on the reasoning that there was nothing to act on. On a
* report that often holds one group, that meant opening the page to a heading and a count and no
* rows — the customer was there, but reading the report took a click to discover it. The toggle
* stays for folding a long section away.
*/
collapsedGroups: Record<string, boolean> = {};
/**
* 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 = {};
}
/** Collapsed sections, by group key. 'No change needed' starts closed — nothing to act on. */
collapsedGroups: Record<string, boolean> = { same: true };
toggleGroup(key: string): void {
this.collapsedGroups[key] = !this.collapsedGroups[key];
@@ -581,47 +513,6 @@ 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';
@@ -798,7 +689,7 @@ export class RiskReviewReportComponent implements OnInit {
String(c.clientId),
c.customerName,
c.accountNo || '—',
this.branchLabelOf(c.branchCode),
c.branchCode ? glBranchName(c.branchCode) : '—',
this.riskText(c.currentRisk),
c.alertCount,
c.ruleLabels.join('; '),
@@ -858,7 +749,7 @@ export class RiskReviewReportComponent implements OnInit {
String(a.clientId),
a.customerName,
a.accountNo || '—',
this.branchLabelOf(a.branchCode),
a.branchCode ? glBranchName(a.branchCode) : '—',
this.riskText(a.currentRisk),
this.txnDetail(a),
a.rule.label,
@@ -924,7 +815,7 @@ export class RiskReviewReportComponent implements OnInit {
String(t.clientId),
t.customerName,
t.accountNo || '—',
this.branchLabelOf(t.branchCode),
t.branchCode ? glBranchName(t.branchCode) : '—',
this.riskText(t.currentRisk),
channelLabel(t.channel),
t.drCr,
+1 -8
View File
@@ -218,14 +218,7 @@ function recommendRating(
};
}
// 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.`
};
return { risk, reason: '' };
}
/** Applies every filter and joins each alert to its rule. */
@@ -91,10 +91,16 @@
[savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []"
[submitting]="submitting"
(submitEvent)="submit()"
(previousEvent)="prev()"
>
</mifosx-savings-account-preview-step>
}
<div class="csa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
</div>
</div>
</mat-tab>
</mat-tab-group>
@@ -585,30 +585,25 @@
</div>
<!--
Previous, Cancel & Submit all in one row. Previous emits back to the parent wizard rather than
using matStepperPrevious, since this component renders inside a mat-tab-group rather than a
mat-stepper and has no CdkStepper ancestor to bind to.
Submit & Cancel — Previous lives in the parent wizard's tab action bar
now that this component renders inside a mat-tab-group rather than a
mat-stepper. Re-introducing matStepperPrevious here would require a
CdkStepper ancestor and would crash the view on init.
-->
@if (!hideButtons) {
<div class="preview-submit-row">
<button mat-stroked-button (click)="previousEvent.emit()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
<button mat-stroked-button [routerLink]="['../..']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
class="preview-submit-row__primary"
[disabled]="submitting"
(click)="submitEvent.emit()"
>
<fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Submit' | translate }}
</button>
<div class="preview-submit-row__right">
<button mat-stroked-button [routerLink]="['../..']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
class="preview-submit-row__primary"
[disabled]="submitting"
(click)="submitEvent.emit()"
>
<fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</div>
}
@@ -13,21 +13,17 @@
padding: 0.5rem 0;
}
/* Previous, Cancel & Submit row at the bottom of the preview. */
/* Submit + Cancel row at the bottom of the preview. Previous lives in the
parent wizard's tab action bar. */
.preview-submit-row {
display: flex;
justify-content: space-between;
justify-content: flex-end;
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;
@@ -116,9 +116,6 @@ 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) {
@@ -21,10 +21,8 @@
</div>
<div class="certificate-header">
<div class="bank-name">{{ bankName }}</div>
@if (bankLocation) {
<div class="bank-sub">{{ bankLocation }}</div>
}
<div class="bank-name">Nexora Co-operative Bank Ltd.</div>
<div class="bank-sub">Mumbai, Maharashtra, India</div>
<div class="cert-title">SHARE CERTIFICATE</div>
<div class="cert-subtitle">Issued under the Common Seal of the Bank</div>
</div>
@@ -53,8 +51,8 @@
<strong class="highlight-text">{{
sharesAccountData.currentMarketPrice | currency: sharesAccountData.currency.code : 'symbol-narrow'
}}</strong>
each, fully paid up, in <strong class="highlight-text">{{ bankName }}</strong
>, subject to the memorandum, articles of association, and bye-laws of the bank.
each, fully paid up, in <strong class="highlight-text">Nexora Co-operative Bank Ltd.</strong>, subject to
the memorandum, articles of association, and bye-laws of the bank.
</p>
<div class="issued-to-details">
@@ -77,7 +75,7 @@
<tbody>
<tr>
<td class="lbl-col">Branch</td>
<td class="val-col">{{ clientData?.officeName || 'Head Office' }}</td>
<td class="val-col">{{ clientData?.officeName || 'Mumbai Main Branch' }}</td>
<td class="lbl-col">Member Since</td>
<td class="val-col">
{{
@@ -140,8 +140,6 @@
}
.meta-value {
font-family: Arial, Helvetica, sans-serif;
font-variant-numeric: tabular-nums;
font-size: 15px;
font-weight: bold;
color: #322f2b;
@@ -228,19 +226,12 @@
.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%;
}
@@ -251,7 +242,6 @@
}
.lbl-col-highlight {
font-family: Georgia, 'Times New Roman', Times, serif;
font-weight: bold;
color: #0b3c5d;
font-size: 14px;
@@ -261,8 +251,6 @@
}
.val-col-highlight {
font-family: Arial, Helvetica, sans-serif;
font-variant-numeric: tabular-nums;
font-size: 16px;
font-weight: bold;
color: #b8860b; /* Dark Goldenrod */
@@ -282,7 +270,6 @@
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;
@@ -290,14 +277,9 @@
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;
}
@@ -16,7 +16,6 @@ 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';
@@ -45,7 +44,6 @@ export class ShareCertificateDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<ShareCertificateDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
clientsService = inject(ClientsService);
private organizationService = inject(OrganizationService);
sharesAccountData: any;
clientId: string;
@@ -55,11 +53,6 @@ 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 = '';
@@ -162,24 +155,6 @@ 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: () => {}
});
}
/**
@@ -149,13 +149,6 @@ 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');
}
}
}
@@ -240,83 +240,45 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
this.approveBatch(request);
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.`;
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.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);
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 });
}
});
});
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 });
}
});
}
openRejectDialog(request: any): void {
@@ -340,123 +302,6 @@ 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: `
<h2 mat-dialog-title class="approve-title">
{{ data.isBatch ? 'Approve DD Slip' : 'Approve DD Issuance' }}
</h2>
<mat-dialog-content>
<div class="approve-summary">
<div class="approve-row">
<span class="approve-label">{{ data.isBatch ? 'Drafts' : 'DD Number' }}</span>
<strong class="approve-value">{{ data.isBatch ? data.lines.length + ' drafts' : data.ddNumber }}</strong>
</div>
<div class="approve-row" *ngIf="!data.isBatch">
<span class="approve-label">In favour of</span>
<strong class="approve-value">{{ data.beneficiaryName || '—' }}</strong>
</div>
<div class="approve-row" *ngIf="data.clientName">
<span class="approve-label">Purchaser</span>
<strong class="approve-value">{{ data.clientName }}</strong>
</div>
<div class="approve-row approve-row--total">
<span class="approve-label">{{ data.isBatch ? 'Combined face value' : 'Face value' }}</span>
<strong class="approve-amount">₹{{ data.totalFaceValue | number: '1.2-2' }}</strong>
</div>
</div>
<!-- A slip is one debit for every draft on it, which is the part an approver is most likely
to get wrong if it is not said plainly. -->
<ul class="approve-lines" *ngIf="data.isBatch">
<li *ngFor="let l of data.lines">{{ l.beneficiaryName }} — ₹{{ l.faceValue | number: '1.2-2' }}</li>
</ul>
<p class="approve-effect">
{{ data.effect }}
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="primary" type="button" (click)="dialogRef.close(true)">
{{ data.isBatch ? 'Approve Slip' : 'Approve &amp; Issue' }}
</button>
</mat-dialog-actions>
`,
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<ApproveIssueRequestDialogComponent>);
data = inject(MAT_DIALOG_DATA);
}
// ── REJECT DIALOG COMPONENT ──
@Component({
selector: 'mifosx-reject-issue-request-dialog',
@@ -209,31 +209,6 @@
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingReprintRequestId">
Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }})
</span>
<!-- The leaflet is ruined and cannot be handed over. Offered beside Request Reprint
because the two look alike from the counter: reprint when the paper survived, revoke
when it did not. -->
<button
mat-stroked-button
color="warn"
size="small"
*ngIf="element.status === 'ISSUED' && !element.pendingRevokeRequestId"
(click)="openRevokeDialog(element)"
>
Revoke
</button>
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingRevokeRequestId">
Revoke Pending Approval (Req #{{ element.pendingRevokeRequestId }})
</span>
<!-- Both ends of a replacement, so the pad reconciles: a revoked leaflet says where its
draft went, and the blank that took it says what it stands in for. -->
<span class="pending-badge" *ngIf="element.revokedReplacedByDdNumber">
Revoked → replaced by DD {{ element.revokedReplacedByDdNumber }}
</span>
<span class="pending-badge" *ngIf="element.revokedReplacementOfDdNumber">
Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }}
</span>
</div>
</td>
</ng-container>
+1 -135
View File
@@ -142,12 +142,6 @@ 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;
@@ -218,10 +212,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
'ISSUED',
'CANCELLED',
'STOPPED',
'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'
'CLEARED'
];
selectedStatus = 'ALL';
searchText = '';
@@ -453,23 +444,6 @@ 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 ──
@@ -1681,114 +1655,6 @@ 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: `
<h2 mat-dialog-title>Revoke Spoiled DD (DD No: {{ data.leaflet.ddNumber }})</h2>
<mat-dialog-content>
<p class="hint-text">
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 <strong>REVOKED</strong> and the draft is re-issued on the next available
blank.
</p>
<p class="hint-text">
No money moves. {{ data.leaflet.clientName || data.leaflet.walkinPurchaserName || 'The purchaser' }} has already
paid for this draft and is still owed the same one.
</p>
<form [formGroup]="revokeForm" class="dialog-form">
<mat-form-field appearance="outline" class="full-width">
<mat-label>What went wrong with this leaflet?</mat-label>
<textarea matInput formControlName="reason" rows="3"></textarea>
<mat-error *ngIf="revokeForm.get('reason')?.hasError('required')">Reason is required</mat-error>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="onCancel()">Cancel</button>
<button mat-flat-button color="warn" [disabled]="revokeForm.invalid || loading" (click)="onSubmit()">
Submit for Approval
</button>
</mat-dialog-actions>
`,
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<RevokeDdDialogComponent>);
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',
@@ -1,130 +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/.
-->
<div class="approvals-container">
<div class="header-row">
<div class="title-area">
<h2>DD Revoke Approvals</h2>
<p>
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.
</p>
</div>
</div>
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search by DD No. or customer</mat-label>
<input matInput [value]="searchText" (input)="onSearchChange($any($event.target).value)" placeholder="e.g. 3005" />
</mat-form-field>
<div class="status-filter-row">
<button
mat-stroked-button
*ngFor="let status of statusOptions"
[color]="selectedStatus === status ? 'primary' : undefined"
[class.filter-active]="selectedStatus === status"
(click)="filterByStatus(status)"
>
{{ status === 'ALL' ? 'All' : status }}
<span class="filter-count">({{ statusCount(status) }})</span>
</button>
</div>
<mat-card>
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>Req. ID</th>
<td mat-cell *matCellDef="let element">{{ element.id }}</td>
</ng-container>
<ng-container matColumnDef="ddNumber">
<th mat-header-cell *matHeaderCellDef>Spoiled DD</th>
<td mat-cell *matCellDef="let element">
<strong>{{ element.ddNumber }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Purchaser</th>
<td mat-cell *matCellDef="let element">{{ element.clientName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="beneficiaryName">
<th mat-header-cell *matHeaderCellDef>Beneficiary</th>
<td mat-cell *matCellDef="let element">{{ element.beneficiaryName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="faceValue">
<th mat-header-cell *matHeaderCellDef>Face Value</th>
<td mat-cell *matCellDef="let element">{{ element.faceValue | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="reason">
<th mat-header-cell *matHeaderCellDef>Reason</th>
<td mat-cell *matCellDef="let element">{{ element.reason }}</td>
</ng-container>
<!-- Empty until approval: no blank is consumed while the request is still pending. -->
<ng-container matColumnDef="replacementDdNumber">
<th mat-header-cell *matHeaderCellDef>Replaced By</th>
<td mat-cell *matCellDef="let element">
<span class="replacement-no" *ngIf="element.replacementDdNumber">{{ element.replacementDdNumber }}</span>
<span class="muted" *ngIf="!element.replacementDdNumber">—</span>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="status-badge" [ngClass]="element.status.toLowerCase()">
{{ element.status }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="makerUsername">
<th mat-header-cell *matHeaderCellDef>Requested By</th>
<td mat-cell *matCellDef="let element">{{ element.makerUsername }}</td>
</ng-container>
<ng-container matColumnDef="createdDate">
<th mat-header-cell *matHeaderCellDef>Requested On</th>
<td mat-cell *matCellDef="let element">{{ element.createdDate | date: 'dd MMM yyyy, HH:mm' }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let element">
<div class="action-btn-group" *ngIf="element.status === 'PENDING'">
<button mat-flat-button color="warn" (click)="approve(element)">Approve</button>
<button mat-stroked-button color="warn" (click)="openRejectDialog(element)">Reject</button>
</div>
<span *ngIf="element.status === 'REJECTED'" style="color: #64748b; font-size: 12px">{{
element.rejectionReason
}}</span>
<div *ngIf="element.status === 'APPROVED'" style="color: #64748b; font-size: 12px; line-height: 1.6">
<div>by {{ element.checkerUsername }}, {{ element.checkerDate | date: 'dd MMM, HH:mm' }}</div>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<div
*ngIf="dataSource.filteredData.length === 0"
class="no-records-row"
style="text-align: center; padding: 32px; color: #64748b"
>
No DD revoke requests match the selected filter.
</div>
<mat-paginator [pageSizeOptions]="[25, 50, 100]" [pageSize]="25" showFirstLastButtons></mat-paginator>
</mat-card>
</div>
@@ -1,469 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, 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<any>([]);
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: `
<h2 mat-dialog-title>Approve DD Revoke</h2>
<mat-dialog-content>
<div class="rv-summary">
<div class="rv-row">
<span class="rv-label">Spoiled DD</span>
<strong class="rv-value">{{ data.request.ddNumber }}</strong>
</div>
<div class="rv-row" *ngIf="data.request.clientName">
<span class="rv-label">Purchaser</span>
<strong class="rv-value">{{ data.request.clientName }}</strong>
</div>
<div class="rv-row" *ngIf="data.request.beneficiaryName">
<span class="rv-label">In favour of</span>
<strong class="rv-value">{{ data.request.beneficiaryName }}</strong>
</div>
<div class="rv-row rv-row--total">
<span class="rv-label">Face value</span>
<strong class="rv-amount">₹{{ data.request.faceValue | number: '1.2-2' }}</strong>
</div>
</div>
<p class="rv-reason"><span class="rv-label">Reason given</span><br />{{ data.request.reason }}</p>
<p class="rv-effect">
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.
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="warn" type="button" (click)="dialogRef.close(true)">Revoke &amp; Re-issue</button>
</mat-dialog-actions>
`,
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<ApproveRevokeRequestDialogComponent>);
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: `
<h2 mat-dialog-title>Reject DD Revoke (DD No: {{ data.request.ddNumber }})</h2>
<mat-dialog-content>
<p class="hint-text">
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.
</p>
<form [formGroup]="rejectForm" class="dialog-form">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Rejection Reason</mat-label>
<textarea matInput formControlName="rejectionReason" rows="3"></textarea>
<mat-error *ngIf="rejectForm.get('rejectionReason')?.hasError('required')">A reason is required</mat-error>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button
mat-flat-button
color="warn"
type="button"
[disabled]="rejectForm.invalid || loading"
(click)="onSubmit()"
>
Reject
</button>
</mat-dialog-actions>
`,
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<RejectRevokeRequestDialogComponent>);
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 });
}
});
}
}
@@ -206,9 +206,7 @@ export class GlSettingsDialogComponent implements OnInit {
loadGlAccountsAndMapping(): void {
this.loading = true;
// 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<any[]>('/glaccounts?manualEntriesAllowed=true&disabled=false&usage=1').subscribe({
this.http.get<any[]>('/glaccounts?manualEntriesAllowed=true&disabled=false').subscribe({
next: (accounts) => {
this.glAccounts = accounts || [];
this.sponsorDDService.getGlMappings().subscribe({
@@ -13,7 +13,6 @@ 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([
@@ -45,11 +44,6 @@ 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' }
}
]
}
-21
View File
@@ -218,27 +218,6 @@ export class SponsorDDService {
return this.http.post<any>(`/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<any> {
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/revoke-request`, { reason });
}
getAllRevokeRequests(status?: string): Observable<any[]> {
const params = status ? { status } : {};
return this.http.get<any[]>('/sponsordd/revoke-requests', { params });
}
approveRevokeRequest(id: number): Observable<any> {
return this.http.post<any>(`/sponsordd/revoke-requests/${id}/approve`, {});
}
rejectRevokeRequest(id: number, rejectionReason: string): Observable<any> {
return this.http.post<any>(`/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
@@ -18,6 +18,7 @@ import {
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { humanizeKey, sameValue, toIsoDate } from 'app/tasks/command-diff.util';
interface DiffRow {
label: string;
@@ -106,9 +107,9 @@ export class ProductEditDiffDialogComponent implements OnInit {
this.rows = Object.keys(proposed)
.filter((key) => !ProductEditDiffDialogComponent.IGNORED_KEYS.has(key))
.map((key) => ({ key, originalValue: this.originalValueFor(key, original) }))
.filter(({ key, originalValue }) => !this.sameValue(originalValue, proposed[key]))
.filter(({ key, originalValue }) => !sameValue(originalValue, proposed[key]))
.map(({ key, originalValue }) => ({
label: this.humanizeKey(key),
label: humanizeKey(key),
original: this.displayValue(originalValue, key, original),
proposed: this.displayValue(proposed[key], key, original)
}))
@@ -173,58 +174,11 @@ export class ProductEditDiffDialogComponent implements OnInit {
* carries just the id, so a raw comparison reports every such field as changed. Comparing on the id
* (and coercing numeric strings) keeps the list to fields whose value genuinely moved.
*/
private sameValue(a: any, b: any): boolean {
return JSON.stringify(this.normalize(a)) === JSON.stringify(this.normalize(b));
}
private normalize(value: any): any {
if (value === null || value === undefined || value === '') {
return null;
}
// Dates come back from the product as [yyyy, m, d] but are submitted as "yyyy-MM-dd"; without
// folding both to one form every date field reads as changed on every edit.
const asDate = this.toIsoDate(value);
if (asDate !== null) {
return asDate;
}
if (Array.isArray(value)) {
// An empty array and an absent value mean the same thing here.
return value.length === 0 ? null : value.map((item) => this.normalize(item));
}
if (typeof value === 'object') {
if (value.id !== undefined) {
return this.normalize(value.id);
}
return Object.keys(value).length === 0 ? null : JSON.stringify(value);
}
if (typeof value === 'boolean') {
return value;
}
const asNumber = Number(value);
return Number.isNaN(asNumber) ? value : asNumber;
}
/** Recognises both date shapes the API uses and returns "yyyy-MM-dd", or null if not a date. */
private toIsoDate(value: any): string | null {
if (Array.isArray(value) && value.length === 3 && value.every((part) => typeof part === 'number')) {
const [
year,
month,
day
] = value;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
return value;
}
return null;
}
private displayValue(value: any, fieldKey: string, template: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
const asDate = this.toIsoDate(value);
const asDate = toIsoDate(value);
if (asDate !== null) {
return asDate;
}
@@ -248,14 +202,4 @@ export class ProductEditDiffDialogComponent implements OnInit {
// A bare id that names an option -- show the option's label, not the number.
return this.resolveOptionLabel(fieldKey, value, template) ?? String(value);
}
/** camelCase / PascalCase field name to a spaced, capitalised label. */
private humanizeKey(key: string): string {
const spaced = key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.trim();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
}
@@ -226,12 +226,6 @@
<fa-icon icon="print" class="ac-tab__ic"></fa-icon>
<span>DD Reprint Requests</span>
</a>
<!-- Beside the reprint queue because the two arrive together from the counter, but they are
different decisions: a reprint re-prints the same leaflet, a revoke spends a fresh one. -->
<a class="ac-tab" routerLink="./dd-revoke-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
<span>DD Revoke Requests</span>
</a>
</nav>
<div class="ac-tab-panel">
+332
View File
@@ -0,0 +1,332 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Shared comparison helpers for maker-checker review screens.
*
* Extracted verbatim from ProductEditDiffDialogComponent, which had already solved the awkward part:
* a naive comparison of a submitted command against the stored entity reports almost every field as
* changed, because the two sides encode the same value differently. The normalisation below is what
* keeps a diff down to the fields that genuinely moved:
*
* - dates arrive as `[yyyy, m, d]` from the API but as `"yyyy-MM-dd"` in the payload
* - option/enum fields come back as `{ id, value }` but are submitted as a bare id
* - `null`, `undefined`, `''`, `[]` and `{}` all mean "not set"
* - numeric strings and numbers are the same value
*
* Product-specific concerns (GL account mappings, `<field>Options` lookups, currency aliases) stay in
* the product dialog - they have no meaning for a role or a client.
*/
/**
* Keys that are never a setting a checker reviews, in two groups.
*
* The first is request plumbing - how the command was encoded, plus the identifiers naming the record
* it applies to, both of which the screen already shows.
*
* The second is values derived from another field rather than entered. An age is computed from
* `dateOfBirth` at the moment it is read, so a command submitted last month always disagrees with the
* entity read today - a difference caused by the calendar, not by the maker. The field the maker can
* actually change (`dateOfBirth`) is still compared, so a real edit is never hidden by this.
*/
export const COMMAND_NOISE_KEYS: ReadonlySet<string> = new Set([
// request plumbing
'locale',
'dateFormat',
'monthDayFormat',
'id',
'productId',
'savingsProductId',
// derived from dateOfBirth
'ageYears',
'ageMonths',
'ageDays',
'isMinor',
'isSeniorCitizen'
]);
const MONTH_NAMES = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december'
];
/**
* Recognises every date shape these two sides use and returns "yyyy-MM-dd", or null if not a date.
*
* Three encodings are in play for the same day:
* - `[yyyy, m, d]` what the read APIs return
* - `"yyyy-MM-dd"` what some forms submit
* - `"29 June 2026"` what Fineract commands carry, per the payload's own `dateFormat`
*
* Missing the third one makes every dated field read as changed on every request, which is exactly
* the noise this whole comparison exists to remove.
*/
export function toIsoDate(value: any): string | null {
if (Array.isArray(value) && value.length === 3 && value.every((part) => typeof part === 'number')) {
const [
year,
month,
day
] = value;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
if (typeof value !== 'string') {
return null;
}
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return value;
}
const spelled = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(value.trim());
if (spelled) {
const day = Number(spelled[1]);
const monthName = spelled[2].toLowerCase();
const monthIndex = MONTH_NAMES.findIndex((name) => name === monthName || name.slice(0, 3) === monthName);
if (monthIndex !== -1) {
return `${spelled[3]}-${String(monthIndex + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
}
return null;
}
/**
* Folds a value into the one form both sides of a comparison can be measured in. See the class
* comment above for why each case exists.
*/
export function normalizeValue(value: any): any {
if (value === null || value === undefined || value === '') {
return null;
}
const asDate = toIsoDate(value);
if (asDate !== null) {
return asDate;
}
if (Array.isArray(value)) {
return value.length === 0 ? null : value.map((item) => normalizeValue(item));
}
if (typeof value === 'object') {
if (value.id !== undefined) {
return normalizeValue(value.id);
}
return Object.keys(value).length === 0 ? null : JSON.stringify(value);
}
if (typeof value === 'boolean') {
return value;
}
const asNumber = Number(value);
return Number.isNaN(asNumber) ? value : asNumber;
}
/**
* True when two values mean the same thing.
*
* The structural comparison handles most cases, but the two sides of a maker-checker review come
* from different APIs that expand the same option differently: `/audits/{id}` returns an enum as its
* label ("Daily") while the entity's own read API returns `{ id, code, value }`. Those normalise to
* different things yet are the same setting, so a second pass asks the question that actually
* matters to a checker - would these two render identically on screen? If they would, there is
* nothing to report.
*/
export function sameValue(a: any, b: any): boolean {
if (JSON.stringify(normalizeValue(a)) === JSON.stringify(normalizeValue(b))) {
return true;
}
return displayScalar(a) === displayScalar(b);
}
/** camelCase / PascalCase / snake_case field name to a spaced, capitalised label. */
export function humanizeKey(key: string): string {
const spaced = String(key ?? '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.trim();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
/**
* Renders one value for display, without any entity-specific option lookup. Arrays and objects are
* summarised rather than dumped - a review row states *that* a collection changed; the detail belongs
* in the entity's own screen.
*/
export function displayScalar(value: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
const asDate = toIsoDate(value);
if (asDate !== null) {
return asDate;
}
if (Array.isArray(value)) {
return value.length === 0 ? '—' : `${value.length} item${value.length === 1 ? '' : 's'}`;
}
if (typeof value === 'object') {
if (Object.keys(value).length === 0) {
return '—';
}
if (value.name && value.glCode) {
return `${value.name} (${value.glCode})`;
}
return value.value ?? value.name ?? value.code ?? (value.id !== undefined ? String(value.id) : '—');
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
return String(value);
}
/**
* Sections a CREATE payload is bucketed into, so a checker reads a submitted product the way it was
* entered rather than as one flat run of fields. Matching is by key name, tried in order, so the
* grouping works for any entity - a role or a GL account simply lands everything in "Details".
*/
const CREATE_SECTIONS: { title: string; matches: RegExp }[] = [
{ title: 'Accounting', matches: /account(ing)?|glaccount|writeOff|receivable|payable|suspense|escheat|fund/i },
{ title: 'Charges & Fees', matches: /charge|fee|penalt|tax/i },
{
title: 'Interest & Terms',
matches: /interest|term|period|lockin|compound|annualis|maturity|deposit(Amount|Min|Max)/i
},
{ title: 'Balances & Limits', matches: /balance|overdraft|limit|amount|minimum|maximum|multiplesOf|decimal/i },
{ title: 'Facilities & Features', matches: /facility|facilities|nominee|nomination|alert|banking|card|withdrawal/i }
];
const CREATE_DEFAULT_SECTION = 'General';
/** One named group of payload keys for a CREATE review. */
export interface CreateSection {
title: string;
keys: string[];
}
/**
* Buckets a CREATE payload's keys into readable sections. Keys the sections do not recognise fall
* into "Details", which is also where everything lands for entities that have no product-like shape.
* Empty sections are dropped, and section order is stable so the same entity always reads the same.
*/
export function groupCreateFields(payload: Record<string, any>, ignoredKeys?: ReadonlySet<string>): CreateSection[] {
const ignored = ignoredKeys ?? COMMAND_NOISE_KEYS;
const buckets = new Map<string, string[]>();
buckets.set(CREATE_DEFAULT_SECTION, []);
CREATE_SECTIONS.forEach((section) => buckets.set(section.title, []));
Object.keys(payload ?? {})
.filter((key) => !ignored.has(key))
.forEach((key) => {
const section = CREATE_SECTIONS.find((candidate) => candidate.matches.test(key));
buckets.get(section ? section.title : CREATE_DEFAULT_SECTION).push(key);
});
const ordered = [
CREATE_DEFAULT_SECTION,
...CREATE_SECTIONS.map((section) => section.title)
];
return ordered
.map((title) => ({ title, keys: buckets.get(title) ?? [] }))
.filter((section) => section.keys.length > 0);
}
/**
* Reads the current value for a payload key, allowing for the two naming conventions the read APIs
* use that the command payload does not:
*
* - lifecycle dates live under `timeline` (`timeline.submittedOnDate`)
* - a payload `<field>Id` is usually the expanded `<field>` object on the entity
*
* Returns `undefined` when the key cannot be located at all, which the caller reports honestly
* rather than claiming the field was newly added.
*/
export function currentValueFor(current: any, key: string): any {
if (current && key in current) {
return current[key];
}
if (current?.timeline && key in current.timeline) {
return current.timeline[key];
}
if (key.endsWith('Id')) {
const base = key.slice(0, -2);
if (current && base in current) {
return current[base];
}
}
return undefined;
}
/**
* How a single field moved between the stored entity and the submitted command.
*
* `SUBMITTED` is not a change: it marks a field the command carries but which could not be located
* on the entity, so no before-value exists to compare against. Calling those ADDED would tell the
* checker something untrue.
*/
export type ChangeKind = 'ADDED' | 'REMOVED' | 'MODIFIED' | 'SUBMITTED';
export interface CommandChange {
label: string;
kind: ChangeKind;
oldValue: string;
newValue: string;
}
/**
* Compares a submitted command payload against the entity as it stands today, returning only the
* fields that actually moved.
*
* Only keys present in the payload are considered: a command says what the maker submitted, so a
* field absent from it was not part of the request and must not be reported as removed.
*/
export function diffCommandAgainstEntity(
submitted: Record<string, any>,
current: Record<string, any>,
options?: { ignoredKeys?: ReadonlySet<string>; currentValueFor?: (key: string) => any }
): CommandChange[] {
const ignored = options?.ignoredKeys ?? COMMAND_NOISE_KEYS;
const readCurrent = options?.currentValueFor ?? ((key: string) => currentValueFor(current, key));
const knownOnEntity = (key: string) =>
!!current && (key in current || (!!current.timeline && key in current.timeline) || `${key}Id` in current);
const rank: Record<ChangeKind, number> = { MODIFIED: 0, ADDED: 1, REMOVED: 2, SUBMITTED: 3 };
return (
Object.keys(submitted ?? {})
.filter((key) => !ignored.has(key))
.map((key) => ({ key, before: readCurrent(key), after: submitted[key] }))
.filter(({ before, after }) => !sameValue(before, after))
.map(({ key, before, after }) => {
const hadBefore = normalizeValue(before) !== null;
const hasAfter = normalizeValue(after) !== null;
// No before-value *and* no such field on the entity means there is nothing to compare, which is
// different from a field that exists and was empty.
const kind: ChangeKind = !hadBefore
? knownOnEntity(key) || !!options?.currentValueFor
? 'ADDED'
: 'SUBMITTED'
: !hasAfter
? 'REMOVED'
: 'MODIFIED';
return {
label: humanizeKey(key),
kind,
oldValue: displayScalar(before),
newValue: displayScalar(after)
};
})
// Real changes first, then the fields that could only be reported as submitted.
.sort((a, b) => rank[a.kind] - rank[b.kind] || a.label.localeCompare(b.label))
);
}
+205
View File
@@ -0,0 +1,205 @@
/**
* 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 { Observable } from 'rxjs';
import { ProductsService } from 'app/products/products.service';
import { ClientsService } from 'app/clients/clients.service';
import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { SystemService } from 'app/system/system.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { UsersService } from 'app/users/users.service';
/**
* The services a maker-checker review screen needs in order to read an entity's current state.
* Passed in rather than injected here so this stays a plain lookup table with no Angular wiring.
*/
export interface EntityFetchServices {
products: ProductsService;
clients: ClientsService;
savings: SavingsService;
fixedDeposits: FixedDepositsService;
recurringDeposits: RecurringDepositsService;
system: SystemService;
accounting: AccountingService;
users: UsersService;
}
/**
* Maps a maker-checker `entityName` to the existing API that returns that entity as it stands today,
* so an UPDATE can be shown as OLD -> NEW and a DELETE can show what is about to be removed.
*
* Every entry reuses an API the application already calls elsewhere - nothing new was added on the
* backend. Products are fetched with `template=true` because that response also carries the option
* lists (`accountingRuleOptions`, `interestCompoundingPeriodTypeOptions`, ...) the payload's bare ids
* refer to.
*
* An entity absent from this table simply has no current-state lookup; callers fall back to rendering
* the submitted payload, which is what every entity did before this table existed.
*/
const ENTITY_FETCHERS: Record<string, (id: string, s: EntityFetchServices) => Observable<any>> = {
// ---- Products ----------------------------------------------------------------
SAVINGSPRODUCT: (id, s) => s.products.getSavingProduct(id, true),
CURRENTPRODUCT: (id, s) => s.products.getCurrentProduct(id, true),
SHAREPRODUCT: (id, s) => s.products.getShareProduct(id, true),
LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true),
FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id),
RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true),
CHARGE: (id, s) => s.products.getCharge(id, true),
// ---- Accounts ----------------------------------------------------------------
SAVINGSACCOUNT: (id, s) => s.savings.getSavingsAccountData(id),
// Deposit accounts must be read through their own endpoints: /savingsaccounts omits the deposit
// terms (depositAmount, depositPeriod, charts, ...), so comparing against it reports every one of
// those as newly added.
FIXEDDEPOSITACCOUNT: (id, s) => s.fixedDeposits.getFixedDepositsAccountData(id),
RECURRINGDEPOSITACCOUNT: (id, s) => s.recurringDeposits.getRecurringDepositsAccountAndTemplate(id),
// ---- People and administration ----------------------------------------------
CLIENT: (id, s) => s.clients.getClientData(id, true),
ROLE: (id, s) => s.system.getRole(id),
USER: (id, s) => s.users.getUser(id),
GLACCOUNT: (id, s) => s.accounting.getGlAccount(id, true)
};
/** Whether this screen can read the entity's current state, and therefore produce a real diff. */
export function canFetchEntity(entityName: string | undefined | null): boolean {
return !!ENTITY_FETCHERS[(entityName ?? '').toUpperCase()];
}
/**
* Returns the entity as it stands today, or null when there is no mapping for it or no resource to
* read - in which case the caller keeps showing the submitted payload rather than failing.
*/
export function fetchCurrentEntity(
entityName: string | undefined | null,
resourceId: string | number | undefined | null,
services: EntityFetchServices
): Observable<any> | null {
const fetcher = ENTITY_FETCHERS[(entityName ?? '').toUpperCase()];
if (!fetcher || resourceId === null || resourceId === undefined || resourceId === '') {
return null;
}
return fetcher(String(resourceId), services);
}
/**
* The handful of fields worth showing for a DELETE, whose payload is empty - the checker needs to
* recognise *which* record is about to go, not read its full configuration. Keys are tried in order
* and the first present one wins; anything not listed falls back to a generic set.
*/
const DELETE_SUMMARY_KEYS: Record<string, string[]> = {
SAVINGSPRODUCT: [
'name',
'shortName',
'description',
'currency'
],
CURRENTPRODUCT: [
'name',
'shortName',
'description',
'currency'
],
SHAREPRODUCT: [
'name',
'shortName',
'description',
'currency'
],
LOANPRODUCT: [
'name',
'shortName',
'description',
'currency'
],
FIXEDDEPOSITPRODUCT: [
'name',
'shortName',
'description',
'currency'
],
RECURRINGDEPOSITPRODUCT: [
'name',
'shortName',
'description',
'currency'
],
CHARGE: [
'name',
'currency',
'amount',
'chargeAppliesTo',
'chargeTimeType'
],
CLIENT: [
'displayName',
'accountNo',
'officeName',
'status',
'mobileNo',
'emailAddress'
],
SAVINGSACCOUNT: [
'accountNo',
'clientName',
'savingsProductName',
'status',
'currency'
],
FIXEDDEPOSITACCOUNT: [
'accountNo',
'clientName',
'savingsProductName',
'status',
'currency'
],
RECURRINGDEPOSITACCOUNT: [
'accountNo',
'clientName',
'savingsProductName',
'status',
'currency'
],
ROLE: [
'name',
'description',
'disabled'
],
USER: [
'username',
'firstname',
'lastname',
'email',
'officeName'
],
GLACCOUNT: [
'name',
'glCode',
'type',
'usage',
'description'
]
};
const GENERIC_SUMMARY_KEYS = [
'name',
'displayName',
'accountNo',
'shortName',
'username',
'description',
'status'
];
/** Field names worth surfacing for the entity about to be deleted. */
export function deleteSummaryKeys(entityName: string | undefined | null): string[] {
return DELETE_SUMMARY_KEYS[(entityName ?? '').toUpperCase()] ?? GENERIC_SUMMARY_KEYS;
}
-8
View File
@@ -235,14 +235,6 @@ 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' }
}
]
},
@@ -39,9 +39,6 @@
<button type="button" class="vci-btn vci-btn--approve" (click)="approveChecker()">
<fa-icon icon="check"></fa-icon><span>{{ 'labels.buttons.Approve' | translate }}</span>
</button>
<button type="button" class="vci-btn vci-btn--delete" (click)="deleteChecker()">
<fa-icon icon="trash"></fa-icon><span>{{ 'labels.buttons.Delete' | translate }}</span>
</button>
<button type="button" class="vci-btn vci-btn--reject" (click)="rejectChecker()">
<fa-icon icon="times"></fa-icon><span>{{ 'labels.buttons.Reject' | translate }}</span>
</button>
@@ -74,7 +71,9 @@
</dd>
</div>
<div class="vci-kv">
<dt>{{ 'labels.inputs.Entity' | translate }}</dt>
<!-- Not `labels.inputs.Entity`: this project maps that key to "Non-Individual" for the client
legal-form screens, which reads as nonsense against a value like GLACCOUNT. -->
<dt>{{ 'labels.inputs.Entity Type' | translate }}</dt>
<dd>{{ checkerInboxDetail.entityName }}</dd>
</div>
@if (checkerInboxDetail.resourceId) {
@@ -108,9 +107,61 @@
</dl>
</div>
@if (displayJSONData) {
@if (comparing) {
<div class="vci-loading">
<fa-icon icon="spinner" class="vci-loading__ic"></fa-icon>
<span>Comparing against current values…</span>
</div>
}
<!-- UPDATE: only the fields this command actually moves, as OLD -> NEW. -->
@if (showChanges) {
<div class="vci-section">
<div class="vci-section__title">{{ 'labels.heading.Command' | translate }}</div>
<div class="vci-section__title">Changes Requested ({{ changes.length }})</div>
@if (changes.length === 0) {
<p class="vci-nochange">This request does not change any value.</p>
} @else {
<div class="vci-changes">
@for (change of changes; track $index) {
<div class="vci-change">
<div class="vci-change__head">
<span class="vci-change__label">{{ change.label }}</span>
<span class="vci-change__kind vci-change__kind--{{ change.kind.toLowerCase() }}">{{
change.kind
}}</span>
</div>
<div class="vci-change__values">
<span class="vci-change__old">{{ change.oldValue }}</span>
<fa-icon icon="arrow-right" class="vci-change__arrow"></fa-icon>
<span class="vci-change__new">{{ change.newValue }}</span>
</div>
</div>
}
</div>
}
</div>
}
<!-- CREATE: submitted data grouped into readable sections. -->
@if (!showChanges && createSections.length > 0) {
@for (section of createSections; track section.title) {
<div class="vci-section">
<div class="vci-section__title">{{ section.title }}</div>
<dl class="vci-grid">
@for (field of section.fields; track $index) {
<ng-container *ngTemplateOutlet="commandField; context: { $implicit: field }"></ng-container>
}
</dl>
</div>
}
}
<!-- DELETE summary, role-permission changes, and the fallback payload all render here. -->
@if (!showChanges && createSections.length === 0 && displayJSONData) {
<div class="vci-section">
<div class="vci-section__title">
{{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }}
</div>
<dl class="vci-grid">
@for (field of commandFields; track $index) {
<ng-container *ngTemplateOutlet="commandField; context: { $implicit: field }"></ng-container>

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