Compare commits

...
16 Commits
Author SHA1 Message Date
satyamsingh_8888 6eaf9b959f Add Harbor login to deploy step so cross-node pull auth propagates to dev2
continuous-integration/drone/push Build is passing
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
2026-09-14 18:41:34 +05:30
satyamsingh_8888 b184a0909c Deploy cbsnexora UI to dev2 node (environment==dev2 constraint)
continuous-integration/drone/push Build was killed
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-09-14 18:22:00 +05:30
nitesh f278eec99a Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
continuous-integration/drone/push Build is passing
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-09-14 17:06:06 +05:30
nitesh b03efef84e fix(letter-mgmt): make share certificate bank identity dynamic and fix broken printing
- Certificate PDFs, the preview dialog, and both share-certificate list views
  (demo + live) now pull bank name/address/reg no/seal text from the
  Institution Profile instead of a hardcoded "Nexora" identity.
- Certificate preview dialog print CSS was clipped to the dialog's on-screen
  scroll viewport (maxHeight config); switched to position:fixed + unclipped
  overlay ancestors so the full certificate prints, not just a border sliver.
- Global print stylesheet now also hides the app shell's real 2-row nav menu
  (.main-header/.kpi-ticker, not routed through <mifosx-toolbar>) and frees
  the CDK sidenav/tab-body scroll clipping, so printing any page no longer
  captures the nav chrome or gets cut off to one screen's height.
2026-09-14 16:58:18 +05:30
satyamsingh_8888 c0f96e401f Cache npm downloads via BuildKit mount, drop cache-clear (faster npm ci)
continuous-integration/drone/push Build is passing
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-09-14 16:16:54 +05:30
satyamsingh_8888 b0d72d16f3 Skip Cypress/Puppeteer/Playwright binary downloads in build (unstick npm ci)
continuous-integration/drone/push Build is running
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-09-14 16:05:28 +05:30
lincpayadmin 170a8a3f97 Update .drone.yml
continuous-integration/drone/push Build was killed
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-09-14 09:55:43 +00:00
satyamsingh_8888 ffcf9f4d41 Raise Node heap to 8g for Angular production build
continuous-integration/drone/push Build is running
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-09-14 13:51:42 +05:30
satyamsingh_8888 f4e596f641 Restrict Drone trigger to push events on dev (covers PR merges)
continuous-integration/drone/push Build was killed
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
2026-09-14 13:50:11 +05:30
lincpayadmin 8db857dbfe Update .drone.yml
continuous-integration/drone/push Build was killed
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-09-14 08:12:28 +00:00
satyamsingh_8888 c6e9ec049e Add Drone CI pipeline and static-only nginx serving for dev deploy
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
2026-09-14 13:36:38 +05:30
nitesh ee5a430b17 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
# Conflicts:
#	src/environments/.env.ts
2026-09-14 12:04:24 +05:30
niteshandClaude Sonnet 5 45a337915e fix(clients): block Alternate Mobile No from matching Mobile No
Same number in both fields defeats the point of having a second contact
route, and re-running DIP on an identical number is pointless. Added a
cross-field validator on alternateMobileNo, re-checked whenever mobileNo
changes, with a matching error message and the Verify-with-DIP button
disabled while the clash exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 11:49:36 +05:30
niteshandClaude Sonnet 5 31e99b1363 fix(clients): rebuild CKYC document upload as a popup with full proof details
The CKYC tab's document cards now open a dedicated dialog (with client-side
preview) instead of expanding inline, collecting document type, number, and
issue/expiry dates the same way client creation does. Fixes several bugs
found along the way:

- Uploading a document type that's valid for both identity and address
  (Aadhaar, Passport, etc.) no longer 404s or 500s — the "no such endpoint"
  and case-sensitive ClientIdentifierStatus.valueOf("Active") crashes are
  both resolved.
- Edit Client's Identity-proof and Address-proof steps no longer show each
  other's document when both are dual-purpose OVD types, and no longer show
  stale/mismatched front-back scans after a document type change.
- Address-proof's own document preview never showed a document at all when
  no m_client_kyc record existed yet.

Document number and issue/expiry dates are persisted on the identifier
(documentKey + a shared "Issue date: ..., Expiry date: ..." description
convention) and read back by both the CKYC tab and Edit Client so the same
details appear everywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 11:39:14 +05:30
niteshandClaude Sonnet 5 ab58d7f74c fix(cashier): tag GL-entry denomination to the vault, show both voucher legs
- Transaction Entry's manual GL-entry screen was recording its denomination
  breakdown against whichever GL account the teller selected, not the vault
  account whose physical notes actually moved -- resolve and use the Cash
  Vault GL's own code/name dynamically instead
- Closing Cash Report's GL Entries table showed only the one account its
  denomination row knew about; now shows both the debit and credit account
  of each voucher (backend now returns debitParty/creditParty), matching
  what My Vouchers already shows for the same entries

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:19:50 +05:30
niteshandClaude Sonnet 5 7b77166b19 fix: tenant-config fixes, cashier/share/savings UI bugs, and menu-access hardcoding
- Add Balance Sheet report and disabled-features accounting config
- Point menu-access tenant header and env.js/environment.ts tenant identifier
  at env config instead of hardcoded 'default'/'lincpay' literals
- Surface the real backend error (not a generic "no reference id") when an
  OTP send fails in contact-otp-block
- Fix branch-day "₹null" display bug (null vs undefined) and add a guard
  blocking a branch's first-ever day open before cash denomination migration
- Dynamically size the denomination count input and block re-opening an
  already-open business day from Migrate Cash Denomination
- Lock the denomination-managed cash GL row in Migrate Opening Balances
  (read-only, with a link back to Migrate Cash Denomination)
- Remove duplicate Cancel/Submit action bars on the Share Product and
  Savings Account creation preview steps; align Previous into the same row
- Hide "Apply Collection" on a share account once its advance collection
  is already settled (further purchases go through Apply Additional Shares)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 15:19:21 +05:30
71 changed files with 3915 additions and 710 deletions
+302
View File
@@ -0,0 +1,302 @@
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
+27 -7
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
###############
### STAGE 1: Build app
###############
@@ -9,10 +10,13 @@ ARG NPM_REGISTRY_URL=https://registry.npmjs.org/
ARG BUILD_ENVIRONMENT_OPTIONS="--configuration production"
ARG PUPPETEER_DOWNLOAD_HOST_ARG=https://storage.googleapis.com
ARG PUPPETEER_CHROMIUM_REVISION_ARG=1011831
ARG PUPPETEER_SKIP_DOWNLOAD_ARG
# Build ko Chromium nahi chahiye — skip (warna npm ci Chromium download par atak jata hai).
ARG PUPPETEER_SKIP_DOWNLOAD_ARG=true
# Set the environment variable to increase Node.js memory limit
ENV NODE_OPTIONS="--max-old-space-size=4096"
# (Mifos web-app ka production build memory-heavy hai; upstream build:prod
# 16384 use karta hai. 4096 par 'ng build' OOM ho jata hai.)
ENV NODE_OPTIONS="--max-old-space-size=8192"
RUN apk add --no-cache git
@@ -25,14 +29,22 @@ ENV PUPPETEER_DOWNLOAD_HOST=$PUPPETEER_DOWNLOAD_HOST_ARG
ENV PUPPETEER_CHROMIUM_REVISION=$PUPPETEER_CHROMIUM_REVISION_ARG
ENV PUPPETEER_SKIP_DOWNLOAD=$PUPPETEER_SKIP_DOWNLOAD_ARG
# Test/e2e tooling ke bhaari binary downloads skip karo — build inhe use nahi
# karta, aur inke bina npm ci turant complete ho jata hai.
# Cypress -> ~250MB binary (CI me sabse zyada atakta hai)
# Playwright-> browser binaries
ENV CYPRESS_INSTALL_BINARY=0
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
COPY ./ /usr/src/app/
RUN npm cache clear --force
RUN npm config set fetch-retry-maxtimeout 120000 \
&& npm config set registry $NPM_REGISTRY_URL --location=global
RUN npm config set fetch-retry-maxtimeout 120000
RUN npm config set registry $NPM_REGISTRY_URL --location=global
RUN npm ci
# BuildKit cache mount: npm ka download cache builds ke beech persist hota hai,
# isliye har build par saare packages dobara download nahi hote (bahut fast).
# 'npm cache clear --force' hata diya — wo har build par cache wipe kar deta tha.
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
RUN sh -c "ng build --output-path=/dist $BUILD_ENVIRONMENT_OPTIONS"
@@ -41,6 +53,14 @@ RUN sh -c "ng build --output-path=/dist $BUILD_ENVIRONMENT_OPTIONS"
###############
FROM $NGINX_IMAGE
# envsubst (gettext) alpine-slim me nahi hota — runtime env.js generation ke
# liye zaroori hai, isliye explicitly install kar rahe hain.
RUN apk add --no-cache gettext
# Static-only nginx config (SPA fallback, no API proxy). Stock default.conf
# ko replace karta hai taaki deep-link refresh par 404 na aaye.
COPY nginx.spa.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /dist/browser /usr/share/nginx/html
EXPOSE 80
+45
View File
@@ -0,0 +1,45 @@
# Static-only nginx config for the CBSNexora Angular UI.
# NOTE: No API reverse-proxy here by design — the browser calls the Fineract
# backend directly via FINERACT_API_URL (env.js). nginx sirf static files
# serve karta hai + Angular SPA routing handle karta hai.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ---- Compression ----
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css application/javascript application/json
application/xml image/svg+xml font/woff font/woff2;
# ---- Runtime env: env.js aur index.html kabhi cache na ho ----
# (env.js har container start par envsubst se regenerate hoti hai;
# index.html hashed asset filenames point karti hai)
location = /assets/env.js {
add_header Cache-Control "no-store, no-cache, must-revalidate";
expires -1;
}
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
expires -1;
}
# ---- Hashed static assets: lambe time cache ----
location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public";
try_files $uri =404;
}
# ---- Angular SPA fallback: har route index.html par ----
location / {
try_files $uri $uri/ /index.html;
}
}
@@ -72,6 +72,7 @@ import { ViewJournalEntryTransactionComponent } from 'app/shared/accounting/view
import { JournalEntryTransactionResolver } from './common-resolvers/journal-entry-transaction.resolver';
import { ExternalAssetOwnerJournalEntryResolver } from 'app/loans/common-resolvers/external-asset-owner-journal-entry.resolver';
import { ExternalAssetConfigurationResolver } from './common-resolvers/external-asset-configuration.resolver';
import { accountingFeatureEnabledGuard } from './disabled-features';
/** Accounting Routes */
const routes: Routes = [
@@ -99,7 +100,8 @@ const routes: Routes = [
{
path: 'frequent-postings',
component: FrequentPostingsComponent,
data: { title: 'Frequent Postings', breadcrumb: 'Frequent Postings' },
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Frequent Postings', breadcrumb: 'Frequent Postings', feature: 'frequentPostings' },
resolve: {
offices: OfficesResolver,
accountingRules: AccountingRulesAssociationsResolver,
@@ -110,7 +112,8 @@ const routes: Routes = [
{
path: 'create',
component: CreateJournalEntryComponent,
data: { title: 'Create Journal Entry', breadcrumb: 'Create' },
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Create Journal Entry', breadcrumb: 'Create', feature: 'createJournalEntries' },
resolve: {
offices: OfficesResolver,
currencies: CurrenciesResolver,
@@ -194,6 +197,11 @@ const routes: Routes = [
offices: OfficesResolver
}
},
{
path: 'balance-sheet',
loadComponent: () => import('./balance-sheet/balance-sheet.component').then((c) => c.BalanceSheetComponent),
data: { title: 'Balance Sheet', breadcrumb: 'Balance Sheet' }
},
{
path: 'chart-of-accounts',
data: { title: 'Chart of Accounts', breadcrumb: 'Chart of Accounts' },
@@ -244,7 +252,8 @@ const routes: Routes = [
},
{
path: 'closing-entries',
data: { title: 'Accounting Closures', breadcrumb: 'Closing Entries' },
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Accounting Closures', breadcrumb: 'Closing Entries', feature: 'closingEntries' },
children: [
{
path: '',
@@ -285,7 +294,8 @@ const routes: Routes = [
},
{
path: 'accounting-rules',
data: { title: 'Accounting Rules', breadcrumb: 'Accounting Rules' },
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Accounting Rules', breadcrumb: 'Accounting Rules', feature: 'accountingRules' },
children: [
{
path: '',
@@ -332,11 +342,17 @@ const routes: Routes = [
{
path: 'periodic-accruals',
component: PeriodicAccrualsComponent,
data: { title: 'Periodic Accrual Accounting', breadcrumb: 'Execute Periodic Accrual Accounting' }
canActivate: [accountingFeatureEnabledGuard],
data: {
title: 'Periodic Accrual Accounting',
breadcrumb: 'Execute Periodic Accrual Accounting',
feature: 'accruals'
}
},
{
path: 'provisioning-entries',
data: { title: 'Provisioning Entries', breadcrumb: 'Provisioning Entries' },
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Provisioning Entries', breadcrumb: 'Provisioning Entries', feature: 'provisioningEntries' },
children: [
{
path: '',
+84 -59
View File
@@ -17,35 +17,39 @@
<!-- Cards Grid -->
<div class="acc-grid">
<!-- 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>
<!-- 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>
}
<!-- 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>
<!-- 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>
}
<!-- Search Journal Entries -->
<a class="acc-card" [routerLink]="['journal-entries']" *mifosxHasPermission="'READ_JOURNALENTRY'">
@@ -113,43 +117,64 @@
</div>
</a>
<!-- 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>
<!-- 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>
<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>
<span class="acc-card__title">Balance Sheet</span>
<span class="acc-card__desc">Header/Detail balances as on any date</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>
<!-- 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>
}
<!-- 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>
<!-- 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>
}
<!-- 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>
<!-- 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>
}
</div>
</div>
+9 -2
View File
@@ -18,6 +18,7 @@ import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DISABLED_ACCOUNTING_FEATURES } from './disabled-features';
/**
* Accounting component.
@@ -63,6 +64,9 @@ export class AccountingComponent implements AfterViewInit {
// Initialize an array of 10 boolean values, all set to false
arrowBooleans: boolean[] = new Array(10).fill(false);
/** Which accounting tiles are switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
/**
* Popover function
* @param template TemplateRef<any>.
@@ -104,12 +108,15 @@ export class AccountingComponent implements AfterViewInit {
);
});
}
if (this.configurationWizardService.showClosingEntries) {
// Closing Entries and Create Journal Entries can be switched off (see disabled-features.ts), in
// which case their tiles never render and these refs stay undefined — the wizard simply has no
// step to point at, rather than throwing on a missing element.
if (this.configurationWizardService.showClosingEntries && this.closingEntries) {
setTimeout(() => {
this.showPopover(this.templateClosingEntries, this.closingEntries.nativeElement, 'bottom', true);
});
}
if (this.configurationWizardService.showCreateJournalEntries) {
if (this.configurationWizardService.showCreateJournalEntries && this.createJournalEntries) {
setTimeout(() => {
this.showPopover(this.templateCreateJournalEntries, this.createJournalEntries.nativeElement, 'bottom', true);
});
+16
View File
@@ -116,6 +116,22 @@ export class AccountingService {
return this.http.get(`/glaccounts`, { params: httpParams });
}
/**
* As-on-date balance sheet, grouped by Header/Detail hierarchy — see BalanceSheetApiResource.
* @param asOnDate yyyy-MM-dd. Defaults to today on the backend when omitted.
* @param officeId Optional — scopes every balance to one office instead of the whole organization.
*/
getBalanceSheet(asOnDate?: string, officeId?: number): Observable<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.
@@ -0,0 +1,363 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/*
* Builds the Consolidated Balance Sheet PDF, laid out like the bank's printed report: liabilities
* on the left, assets on the right, each top-level group a named block with its accounts listed
* under it and a subtotal in the TOTAL column.
*
* Drawn directly with jsPDF rather than printed from the DOM: window.print() carries the whole app
* shell (sidenav, toolbar, ticker) into the output and gives no control over where a page breaks,
* which is what made the printed sheet unreadable. Here each column is paginated on its own, the
* way the printed report does it — the left side can run onto page 2 while the right side is still
* on page 1 — and every page repeats the heading.
*/
import { jsPDF } from 'jspdf';
export interface BalanceSheetPdfNode {
name: string;
glCode: string;
usage: 'HEADER' | 'DETAIL';
balance: number;
children: BalanceSheetPdfNode[];
}
export interface BalanceSheetPdfSide {
label: string;
total: number;
groups: { classification: string; total: number; nodes: BalanceSheetPdfNode[] }[];
}
export interface BalanceSheetPdfInput {
bankName: string;
officeLabel: string;
/** yyyy-MM-dd */
asOnDate: string;
creditSide: BalanceSheetPdfSide;
debitSide: BalanceSheetPdfSide;
}
// ── Page geometry, in mm on A4 portrait ─────────────────────────────────────
const PAGE_W = 210;
const PAGE_H = 297;
const MARGIN = 8;
const COL_W = (PAGE_W - MARGIN * 2) / 2;
const COL_X = [
MARGIN,
MARGIN + COL_W
];
/** Right edges of the BALANCE and TOTAL columns, relative to a column's left edge. */
const BALANCE_R = COL_W - 26;
const TOTAL_R = COL_W - 2;
/** Name text wraps within this width so it never runs under the BALANCE column. */
const NAME_W = COL_W - 30;
const BODY_TOP = 36;
/** Leaves room under the body for the sign-off row and the print-date footer. */
const BODY_BOTTOM = PAGE_H - 21;
const LINE_H = 3.1;
const INK: [number, number, number] = [
20,
20,
20
];
const MUTED: [number, number, number] = [
90,
90,
90
];
type Line =
| { kind: 'block'; text: string }
| { kind: 'subhead'; text: string; depth: number }
| { kind: 'detail'; lines: string[]; amount: string; depth: number }
| { kind: 'subsubtotal'; amount: string }
| { kind: 'subtotal'; amount: string }
| { kind: 'standalone'; lines: string[]; amount: string }
| { kind: 'grand'; amount: string }
| { kind: 'gap' };
function money(value: number): string {
return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0);
}
function ddmmyyyy(iso: string): string {
if (!iso) {
return '';
}
const [
y,
m,
d
] = iso.split('-');
return `${d}/${m}/${y}`;
}
function heightOf(line: Line): number {
switch (line.kind) {
case 'block':
// Heading plus the short dashed rule beneath it.
return LINE_H + 1.8;
case 'detail':
case 'standalone':
return LINE_H * line.lines.length;
case 'gap':
return 2.4;
case 'grand':
// Rule above, the figure, and the double rule beneath it.
return LINE_H + 6;
default:
return LINE_H;
}
}
/**
* Flattens one side into the exact sequence of printed lines. Done up front so pagination is a
* matter of measuring heights, with no recursion happening mid-page.
*/
function flatten(doc: jsPDF, side: BalanceSheetPdfSide): Line[] {
const out: Line[] = [];
// splitTextToSize measures with whatever font is currently set, so it has to match the font the
// detail lines are actually drawn in — otherwise names wrap at the wrong width.
doc.setFont('helvetica', 'normal').setFontSize(6.8);
const pushNode = (node: BalanceSheetPdfNode, depth: number): void => {
if (node.usage === 'DETAIL') {
const indent = depth * 2;
const wrapped: string[] = doc.splitTextToSize(node.name, NAME_W - indent);
out.push({ kind: 'detail', lines: wrapped, amount: money(node.balance), depth });
return;
}
out.push({ kind: 'subhead', text: node.name, depth });
node.children.forEach((child) => pushNode(child, depth + 1));
out.push({ kind: 'subsubtotal', amount: money(node.balance) });
};
for (const group of side.groups) {
for (const root of group.nodes) {
if (root.usage === 'DETAIL') {
// A Detail account sitting under no group at all: a group of one, printed on a single line
// carrying both its balance and its total, rather than a heading and a figure on separate
// rows with the BALANCE column left empty.
out.push({
kind: 'standalone',
lines: doc.splitTextToSize(root.name.toUpperCase(), NAME_W),
amount: money(root.balance)
});
out.push({ kind: 'gap' });
continue;
}
out.push({ kind: 'block', text: root.name });
root.children.forEach((child) => pushNode(child, 0));
out.push({ kind: 'subtotal', amount: money(root.balance) });
out.push({ kind: 'gap' });
}
}
// Carried as a line of its own so pagination reserves room for it, rather than squeezing it
// against the bottom margin under the last block.
out.push({ kind: 'grand', amount: money(side.total) });
return out;
}
function dottedLine(doc: jsPDF, x1: number, y: number, x2: number): void {
doc.setLineDashPattern(
[
0.4,
0.6
],
0
);
doc.setLineWidth(0.15);
doc.line(x1, y, x2, y);
doc.setLineDashPattern([], 0);
}
function drawPageFrame(doc: jsPDF, input: BalanceSheetPdfInput, page: number, pageCount: number): void {
doc.setTextColor(...INK);
doc.setFont('helvetica', 'bold').setFontSize(9.5);
doc.text(`${input.bankName.toUpperCase()} , HEAD OFFICE`, PAGE_W / 2, 11, { align: 'center' });
doc.setFontSize(8);
doc.text(`CONSOLIDATED BALANCE SHEET (SUB GROUP WISE) AS ON : ${ddmmyyyy(input.asOnDate)}`, PAGE_W / 2, 16, {
align: 'center'
});
doc.setFont('helvetica', 'normal').setFontSize(7);
doc.text(`Page ${page} of ${pageCount}`, PAGE_W - MARGIN, 16, { align: 'right' });
doc.text(`FOR BRANCHES : ${input.officeLabel}`, PAGE_W / 2, 20, { align: 'center' });
dottedLine(doc, MARGIN, 23, PAGE_W - MARGIN);
// Side titles and column headings, drawn per column.
const titles = [
'LIABILITIES ( CREDIT )',
'ASSETS ( DEBIT )'
];
for (let c = 0; c < 2; c++) {
const x = COL_X[c];
doc.setFont('helvetica', 'bold').setFontSize(7.5);
doc.text(titles[c], x + 1, 27.5);
doc.setFontSize(6.5);
doc.text('ACCOUNT NAME', x + 1, 32);
doc.text('BALANCE', x + BALANCE_R, 32, { align: 'right' });
doc.text('TOTAL', x + TOTAL_R, 32, { align: 'right' });
dottedLine(doc, x + 1, 33.4, x + COL_W - 2);
}
// The divider between the two sides.
doc.setLineWidth(0.15);
doc.setDrawColor(150, 150, 150);
doc.line(COL_X[1], 24, COL_X[1], BODY_BOTTOM + 2);
doc.setDrawColor(0, 0, 0);
// Footer.
dottedLine(doc, MARGIN, BODY_BOTTOM + 6, PAGE_W - MARGIN);
doc
.setFont('helvetica', 'normal')
.setFontSize(6.5)
.setTextColor(...MUTED);
const now = new Date();
const stamp =
`${`${now.getDate()}`.padStart(2, '0')}/${`${now.getMonth() + 1}`.padStart(2, '0')}/${now.getFullYear()} ` +
`${`${now.getHours()}`.padStart(2, '0')}:${`${now.getMinutes()}`.padStart(2, '0')}:${`${now.getSeconds()}`.padStart(2, '0')}`;
doc.text(`Print Date : ${stamp}`, MARGIN, BODY_BOTTOM + 10);
doc.text('Consolidated Balance Sheet (Sub Group Wise)', PAGE_W - MARGIN, BODY_BOTTOM + 10, { align: 'right' });
doc.setTextColor(...INK);
}
/** Draws one line into a column at y, and returns the y the next line starts at. */
function drawLine(doc: jsPDF, line: Line, x: number, y: number): number {
switch (line.kind) {
case 'block': {
doc.setFont('helvetica', 'bold').setFontSize(7);
doc.text(line.text.toUpperCase(), x + 1, y);
dottedLine(doc, x + 1, y + 1.1, x + 34);
return y + heightOf(line);
}
case 'subhead': {
doc.setFont('helvetica', 'bold').setFontSize(6.8);
doc.text(line.text, x + 1 + line.depth * 2, y);
return y + heightOf(line);
}
case 'detail': {
doc.setFont('helvetica', 'normal').setFontSize(6.8);
const indent = x + 2 + line.depth * 2;
line.lines.forEach((text, i) => doc.text(text, indent, y + i * LINE_H));
// The amount sits on the first line of a wrapped name, as the printed report has it.
doc.text(line.amount, x + BALANCE_R, y, { align: 'right' });
return y + heightOf(line);
}
case 'subsubtotal': {
doc.setFont('helvetica', 'bold').setFontSize(6.8);
dottedLine(doc, x + BALANCE_R - 22, y - 2.2, x + BALANCE_R);
doc.text(line.amount, x + BALANCE_R, y, { align: 'right' });
return y + heightOf(line);
}
case 'subtotal': {
doc.setFont('helvetica', 'bold').setFontSize(7);
doc.text(line.amount, x + TOTAL_R, y, { align: 'right' });
return y + heightOf(line);
}
case 'standalone': {
doc.setFont('helvetica', 'bold').setFontSize(7);
line.lines.forEach((text, i) => doc.text(text, x + 1, y + i * LINE_H));
doc.text(line.amount, x + BALANCE_R, y, { align: 'right' });
doc.text(line.amount, x + TOTAL_R, y, { align: 'right' });
return y + heightOf(line);
}
case 'grand': {
const top = y + 1.5;
doc.setLineWidth(0.3);
doc.line(x + 1, top, x + COL_W - 2, top);
doc.setFont('helvetica', 'bold').setFontSize(7.5);
doc.text(line.amount, x + TOTAL_R, top + 3.6, { align: 'right' });
doc.setLineWidth(0.2);
doc.line(x + 1, top + 5, x + COL_W - 2, top + 5);
doc.line(x + 1, top + 5.7, x + COL_W - 2, top + 5.7);
return y + heightOf(line);
}
default:
return y + heightOf(line);
}
}
/** Splits one side's lines into per-page chunks that fit between BODY_TOP and BODY_BOTTOM. */
function paginate(lines: Line[]): Line[][] {
const pages: Line[][] = [];
let current: Line[] = [];
let y = BODY_TOP;
for (const line of lines) {
const h = heightOf(line);
if (y + h > BODY_BOTTOM) {
pages.push(current);
current = [];
y = BODY_TOP;
}
current.push(line);
y += h;
}
// Keep the trailing chunk only if it has something on it, but always return at least one page so
// a side with no balances still gets its heading and a zero total.
if (current.length > 0 || pages.length === 0) {
pages.push(current);
}
return pages;
}
export function buildBalanceSheetPdf(input: BalanceSheetPdfInput): jsPDF {
const doc = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4', compress: true });
const creditPages = paginate(flatten(doc, input.creditSide));
const debitPages = paginate(flatten(doc, input.debitSide));
// Both sides print side by side, so the sheet runs as long as the longer of the two, plus a
// final page for the grand totals when they would not fit under the last block.
const pageCount = Math.max(creditPages.length, debitPages.length);
for (let p = 0; p < pageCount; p++) {
if (p > 0) {
doc.addPage();
}
drawPageFrame(doc, input, p + 1, pageCount);
const sides = [
creditPages[p] ?? [],
debitPages[p] ?? []
];
for (let c = 0; c < 2; c++) {
const x = COL_X[c];
let y = BODY_TOP;
for (const line of sides[c]) {
y = drawLine(doc, line, x, y);
}
}
// Sign-off row, on the closing page only.
if (p === pageCount - 1) {
doc
.setFont('helvetica', 'normal')
.setFontSize(6.5)
.setTextColor(...MUTED);
const roles = [
'CLERK',
'ACCOUNTANT / OFFICER',
'ASST. GEN MANAGER',
'GENERAL MANAGER',
'CHAIRMAN'
];
const step = (PAGE_W - MARGIN * 2) / roles.length;
roles.forEach((role, i) => doc.text(role, MARGIN + step * i + step / 2, BODY_BOTTOM + 2, { align: 'center' }));
doc.setTextColor(...INK);
}
}
return doc;
}
@@ -0,0 +1,182 @@
<!--
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>
@@ -0,0 +1,372 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$ink: #111827;
$ink-soft: #374151;
$ink-faint: #9ca3af;
$rule: #cbd5e1;
$border: #e2e8f0;
$accent: #1d4ed8;
$ok: #15803d;
$ok-soft: #f0fdf4;
$danger: #b91c1c;
$danger-soft: #fef2f2;
// Amount columns share one width so BALANCE and TOTAL line up down the whole sheet.
$amt-col: 128px;
.bs-page {
padding: 20px 24px 40px;
background: #f1f5f9;
min-height: 100%;
}
// ── Controls (screen only) ────────────────────────────────────────────────
.bs-controls {
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 18px;
&__spacer {
flex: 1 1 auto;
}
}
.bs-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $ink-soft;
input,
select {
height: 36px;
padding: 0 10px;
border: 1px solid $border;
border-radius: 6px;
background: #fff;
font-size: 13px;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
color: $ink;
}
}
.bs-check {
display: flex;
align-items: center;
gap: 6px;
height: 36px;
font-size: 12.5px;
font-weight: 600;
color: $ink-soft;
cursor: pointer;
user-select: none;
input {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: $accent;
}
}
.bs-btn {
height: 36px;
padding: 0 16px;
border: 1px solid $border;
border-radius: 6px;
background: #fff;
color: $ink-soft;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
&--primary {
background: $ink;
border-color: $ink;
color: #fff;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.bs-tally {
padding: 6px 12px;
border-radius: 999px;
background: $ok-soft;
color: $ok;
font-size: 12px;
font-weight: 700;
&--off {
background: $danger-soft;
color: $danger;
}
}
.bs-alert {
padding: 10px 14px;
border-radius: 6px;
background: $danger-soft;
color: $danger;
border: 1px solid #fecaca;
font-size: 13px;
margin-bottom: 14px;
}
.bs-loading {
padding: 48px 0;
text-align: center;
color: $ink-faint;
font-size: 13.5px;
}
// ── The printed sheet ─────────────────────────────────────────────────────
.bs-sheet {
background: #fff;
border: 1px solid $border;
padding: 22px 26px 18px;
// The ledger reads as a printed report, so the whole sheet is set in a mono face with
// figures on tabular numerals — columns stay aligned however long the names run.
font-family: 'DejaVu Sans Mono', 'Liberation Mono', Consolas, 'Courier New', monospace;
font-variant-numeric: tabular-nums;
color: $ink;
&__head {
text-align: center;
border-bottom: 1px dotted $rule;
padding-bottom: 10px;
margin-bottom: 12px;
h2 {
margin: 0;
font-size: 13.5px;
font-weight: 700;
letter-spacing: 0.04em;
}
p {
margin: 4px 0 0;
font-size: 11.5px;
color: $ink-soft;
}
}
&__branches {
letter-spacing: 0.05em;
}
&__body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
&__foot {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 26px;
padding-top: 10px;
border-top: 1px dotted $rule;
font-size: 10px;
letter-spacing: 0.05em;
color: $ink-soft;
}
}
.bs-col {
padding: 0 16px;
min-width: 0;
&:first-child {
border-right: 1px dotted $rule;
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
&__title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
padding-bottom: 6px;
}
}
// ── Rows: one grid so name / BALANCE / TOTAL align across every block ──────
.bs-row {
display: grid;
grid-template-columns: minmax(0, 1fr) $amt-col $amt-col;
gap: 8px;
align-items: baseline;
font-size: 11.5px;
line-height: 1.55;
> span:nth-child(2),
> span:nth-child(3) {
text-align: right;
white-space: nowrap;
}
&--colhead {
font-weight: 700;
font-size: 10.5px;
letter-spacing: 0.05em;
border-bottom: 1px dotted $rule;
padding-bottom: 4px;
margin-bottom: 6px;
color: $ink-soft;
}
&--line {
color: $ink-soft;
}
// A nested Header inside a block: its own named sub-group.
&--subhead {
font-weight: 700;
color: $ink;
padding-top: 3px;
}
// Sub-group subtotal sits in the BALANCE column, so it never collides with the
// block's own figure in TOTAL.
&--subsubtotal {
border-top: 1px dotted $rule;
font-weight: 700;
color: $accent;
}
// Top-level block subtotal, in the TOTAL column.
&--subtotal {
font-weight: 700;
color: $ink;
padding-top: 2px;
}
// A Header-less Detail account: its own one-line group.
&--standalone {
font-weight: 700;
color: $ink;
.bs-name {
text-transform: uppercase;
letter-spacing: 0.03em;
}
}
&--grand {
margin-top: 14px;
padding-top: 8px;
border-top: 1px solid $ink;
border-bottom: 3px double $ink;
font-weight: 700;
font-size: 12.5px;
}
}
.bs-name {
overflow-wrap: anywhere;
&--muted {
color: $ink-faint;
font-style: italic;
}
}
.bs-amt {
font-variant-numeric: tabular-nums;
}
.bs-block {
margin-bottom: 14px;
&--single {
margin-bottom: 10px;
}
&__name {
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
// The short dashed rule the printed sheet puts under every group heading.
&__rule {
width: 130px;
border-top: 1px dotted $ink-faint;
margin: 2px 0 4px;
}
}
.bs-empty {
padding: 14px 0;
font-size: 11.5px;
color: $ink-faint;
}
// ── Narrow screens: stack the two sides instead of squeezing the columns ───
@media (width <= 1100px) {
.bs-sheet__body {
grid-template-columns: 1fr;
}
.bs-col {
padding: 0;
&:first-child {
border-right: none;
border-bottom: 1px dotted $rule;
padding-bottom: 16px;
margin-bottom: 16px;
}
}
}
// ── Print: only the sheet, at report proportions ───────────────────────────
@media print {
.bs-controls,
.bs-alert,
.bs-loading {
display: none !important;
}
.bs-page {
padding: 0;
background: #fff;
}
.bs-sheet {
border: none;
padding: 0;
font-size: 10px;
}
.bs-sheet__body {
grid-template-columns: 1fr 1fr;
}
.bs-block {
break-inside: avoid;
}
}
@@ -0,0 +1,218 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AccountingService } from '../accounting.service';
import { OrganizationService } from '../../organization/organization.service';
import { buildBalanceSheetPdf } from './balance-sheet-pdf';
interface BalanceSheetNode {
id: number;
name: string;
glCode: string;
usage: 'HEADER' | 'DETAIL';
balance: number;
children: BalanceSheetNode[];
}
interface BalanceSheetGroup {
classification: string;
total: number;
nodes: BalanceSheetNode[];
}
interface BalanceSheetSide {
label: string;
total: number;
groups: BalanceSheetGroup[];
}
/**
* Consolidated Balance Sheet (sub-group wise), laid out the way the bank's own printed balance
* sheet reads: liabilities on the left, assets on the right, each top-level Header account a named
* block whose Detail accounts are listed under it and subtotalled in the TOTAL column.
*
* Balances come from BalanceSheetApiResource, which reads the live ledger - a Detail account's own
* postings, and a Header's as the recursive sum of its children (a Header never carries a posting
* of its own). A Detail account that sits under no Header at all is shown as its own one-line block
* rather than being dropped.
*/
@Component({
selector: 'mifosx-balance-sheet',
standalone: true,
imports: [
CommonModule,
FormsModule
],
templateUrl: './balance-sheet.component.html',
styleUrl: './balance-sheet.component.scss'
})
export class BalanceSheetComponent implements OnInit {
private accountingService = inject(AccountingService);
private organizationService = inject(OrganizationService);
/** Printed at the head of the PDF. Falls back to a neutral title if the profile is not set up. */
bankName = 'Bank';
asOnDate: string = new Date().toISOString().slice(0, 10);
officeId: number | null = null;
offices: { id: number; name: string }[] = [];
loading = false;
error = '';
asOnDateLoaded = '';
/** What the server returned, kept whole so the zero-balance toggle never needs a refetch. */
private rawDebitSide: BalanceSheetSide | null = null;
private rawCreditSide: BalanceSheetSide | null = null;
debitSide: BalanceSheetSide | null = null;
creditSide: BalanceSheetSide | null = null;
/**
* A working chart of accounts carries far more accounts than a bank actually operates, and a
* printed balance sheet lists only the ones carrying a balance — leaving every nil line in makes
* the sheet unreadable. On by default for that reason; switch it off to audit the full chart.
*/
hideZeroBalances = true;
/** Both sides are derived from the same double-entry ledger, so they can only disagree if a
* posting is missing a leg — worth surfacing rather than hiding. */
get tallied(): boolean {
if (!this.debitSide || !this.creditSide) {
return true;
}
return Math.abs(this.debitSide.total - this.creditSide.total) < 0.01;
}
get difference(): number {
if (!this.debitSide || !this.creditSide) {
return 0;
}
return Math.abs(this.debitSide.total - this.creditSide.total);
}
get officeLabel(): string {
if (this.officeId == null) {
return 'ALL BRANCHES';
}
return (this.offices.find((o) => o.id === this.officeId)?.name ?? '').toUpperCase();
}
ngOnInit(): void {
this.accountingService.getOffices().subscribe({
next: (offices) => (this.offices = offices || []),
error: () => (this.offices = [])
});
this.organizationService.getInstitutionProfile().subscribe({
next: (profile: any) => {
this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName;
},
// A missing institution profile must not stop the sheet from loading — the PDF just carries
// the fallback title.
error: () => undefined
});
this.load();
}
load(): void {
this.loading = true;
this.error = '';
this.accountingService.getBalanceSheet(this.asOnDate, this.officeId ?? undefined).subscribe({
next: (res: any) => {
this.rawDebitSide = res.debitSide;
this.rawCreditSide = res.creditSide;
this.asOnDateLoaded = res.asOnDate;
this.applyZeroFilter();
this.loading = false;
},
error: () => {
this.error = 'Could not load the balance sheet for this date.';
this.loading = false;
}
});
}
/**
* Rebuilds the displayed sides from what the server sent, dropping nil lines when asked. The
* totals are deliberately left as the server computed them — hiding a nil line must never change
* a figure, and a group total is unaffected by removing children that summed to nothing.
*/
applyZeroFilter(): void {
this.debitSide = this.filterSide(this.rawDebitSide);
this.creditSide = this.filterSide(this.rawCreditSide);
}
private filterSide(side: BalanceSheetSide | null): BalanceSheetSide | null {
if (!side) {
return null;
}
if (!this.hideZeroBalances) {
return side;
}
const groups = side.groups
.map((group) => ({
...group,
nodes: group.nodes
.map((node) => this.pruneZeros(node))
.filter((node): node is BalanceSheetNode => node !== null)
}))
.filter((group) => group.nodes.length > 0);
return { ...side, groups };
}
/** Drops a node whose balance is nil, and any Header left with nothing under it. */
private pruneZeros(node: BalanceSheetNode): BalanceSheetNode | null {
if (Math.abs(node.balance) < 0.005) {
return null;
}
const children = node.children
.map((child) => this.pruneZeros(child))
.filter((child): child is BalanceSheetNode => child !== null);
return { ...node, children };
}
/** Indian grouping with two decimals, matching the printed report (2,76,92,608.00). */
fmt(value: number): string {
return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0);
}
/** dd/MM/yyyy, as the printed report dates it. */
displayDate(iso: string): string {
if (!iso) {
return '';
}
const [
y,
m,
d
] = iso.split('-');
return `${d}/${m}/${y}`;
}
/**
* Builds the report as a real PDF rather than printing the page: window.print() takes the whole
* app shell with it and breaks pages wherever they happen to fall, which is what made the earlier
* output unusable.
*/
downloadPdf(): void {
if (!this.debitSide || !this.creditSide) {
return;
}
const doc = buildBalanceSheetPdf({
bankName: this.bankName,
officeLabel: this.officeLabel,
asOnDate: this.asOnDateLoaded || this.asOnDate,
creditSide: this.creditSide,
debitSide: this.debitSide
});
doc.save(`balance-sheet-${this.asOnDateLoaded || this.asOnDate}.pdf`);
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
/**
* Accounting features switched off for this installation.
*
* Nothing is deleted — the components, routes and services all stay in place, and flipping a flag
* back to `false` brings the feature back with its tile and its route. The matching API block lives
* in DisabledFeatureApiFilter on the backend, so a hidden feature cannot be reached by calling the
* endpoint directly either.
*/
export const DISABLED_ACCOUNTING_FEATURES = {
createJournalEntries: true,
frequentPostings: true,
closingEntries: true,
accruals: true,
provisioningEntries: true,
accountingRules: true
};
/**
* Blocks a route belonging to a disabled feature, for anyone who reaches it by typing the URL
* rather than through a tile. `data.feature` names the flag to check.
*/
export const accountingFeatureEnabledGuard: CanActivateFn = (route) => {
const router = inject(Router);
const feature = route.data?.['feature'] as keyof typeof DISABLED_ACCOUNTING_FEATURES | undefined;
if (!feature || !DISABLED_ACCOUNTING_FEATURES[feature]) {
return true;
}
router.navigate(['/accounting'], { replaceUrl: true });
return false;
};
@@ -49,119 +49,144 @@
}
@if (glStatus && !glError) {
<!-- 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) {
@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">
Already holds
<strong>₹{{ glTarget.toLocaleString('en-IN') }}</strong>
— count the drawer to exactly this amount.
This branch has already opened today's business day — counting the drawer again here would only be rejected
on submit. Deposits, withdrawals and closing today's day happen from Day Register instead.
</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>
<button type="button" mat-stroked-button (click)="goToBranchDay()">Go to Day Register</button>
</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>
} @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 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">
<!-- Step 3: the actual count. -->
<mat-card class="mcd-card mcd-card--flush">
<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>
<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>
}
@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>
<!-- 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 (!successMsg) {
<div class="mcd-actions">
<button mat-raised-button color="primary" [disabled]="!canSubmit" (click)="submit()">
{{ submitting ? 'Submitting…' : 'Migrate & Open Business Day' }}
</button>
</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>
}
}
}
</div>
@@ -115,6 +115,10 @@
color: #64748b;
line-height: 1.4;
}
button {
margin-top: 10px;
}
}
.mcd-grid {
@@ -171,7 +175,11 @@
}
&__input {
width: 48px;
/* Width is set inline per row (see countInputWidthCh()), growing with the digit count typed —
min-width here only covers the fallback before Angular has bound anything. */
min-width: 48px;
max-width: 96px;
transition: width 0.1s ease-out;
text-align: center;
font-variant-numeric: tabular-nums;
border: 1px solid #e2e8f0;
@@ -66,6 +66,14 @@ export class MigrateCashDenominationComponent implements OnInit {
glStatus: any = null;
glError = '';
/** Set once per office change: whether TODAY's business day is already open for this office. This
* screen is a one-time "day 1" tool (see the class doc) - once a day has actually been opened,
* reopening the same one is exactly what BranchDayRegisterApiResource#migrateCashDenomination
* itself refuses (its own openDayInternal call rejects a day that is already open), so this is
* checked here too, before the user counts an entire drawer only to have the submit rejected. */
todayAlreadyOpen = false;
todayOpenInfo: any = null;
notes: Denom[] = [];
coins: Denom[] = [];
counts: Record<number, number> = {};
@@ -118,6 +126,8 @@ export class MigrateCashDenominationComponent implements OnInit {
this.result = null;
this.successMsg = '';
this.errorMsg = '';
this.todayAlreadyOpen = false;
this.todayOpenInfo = null;
if (!this.officeId) return;
this.glLoading = true;
@@ -131,6 +141,15 @@ export class MigrateCashDenominationComponent implements OnInit {
this.glError = res.message || 'No cash GL account is configured for this tenant yet.';
}
});
this.service
.branchDayRegister(this.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
const openDay = res?.openDay;
this.todayAlreadyOpen = !!openDay && openDay.businessDate === res?.today;
this.todayOpenInfo = this.todayAlreadyOpen ? openDay : null;
});
}
countFor(d: Denom): number {
@@ -149,6 +168,12 @@ export class MigrateCashDenominationComponent implements OnInit {
this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
}
/** Widens the count box as the typed count grows more digits, so a large count (hundreds,
* thousands) is never clipped or hard to read inside a field sized for one or two digits. */
countInputWidthCh(d: Denom): number {
return Math.max(3, String(this.countFor(d)).length + 2);
}
get countedTotal(): number {
return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0);
}
@@ -162,7 +162,18 @@
<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 }}</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__amt">
<mat-form-field class="mob-grid__input">
<input matInput type="number" min="0" formControlName="debit" placeholder="0.00" />
@@ -219,6 +219,33 @@
word-break: break-word;
}
/* Cash at Vault / Cash at Teller — locked, see MigrateOpeningBalancesComponent.isDenominationManaged. */
&__lock {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
padding: 1px 8px 1px 6px;
border-radius: 999px;
background: #fff7ed;
color: #9a3412;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
vertical-align: middle;
mat-icon {
font-size: 13px;
width: 13px;
height: 13px;
}
a {
color: inherit;
text-decoration: underline;
}
}
&__amt {
text-align: right;
}
@@ -17,6 +17,7 @@ import {
} from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
/** Custom Services */
import { AccountingService } from '../accounting.service';
@@ -43,7 +44,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
...STANDALONE_SHARED_IMPORTS,
GlAccountDisplayComponent,
CurrencyPipe,
MatIconModule
MatIconModule,
MatTooltipModule
]
})
export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
@@ -74,6 +76,13 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
debitsSum = 0;
/** Sum total of credits. */
creditsSum = 0;
/** GL account ids mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) — the
* account(s) Migrate Cash Denomination owns. Their opening balance is shown here for visibility
* but locked against editing: redefining it as a bare number here, instead of through a counted
* denomination breakdown, would let the GL figure drift away from what is physically in the till.
* The backend enforces the same rule independently (see defineOpeningBalance's
* validateNoDenominationManagedCashAccounts) — this is a convenience, not the source of truth. */
denominationManagedGlAccountIds: number[] = [];
/* Reference of search form */
@ViewChild('searchFormRef') searchFormRef: ElementRef<any>;
@@ -95,6 +104,22 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
this.officeData = data.offices;
this.currencyData = data.currencies.selectedCurrencyOptions;
});
// Office-independent: which GL account(s) this tenant has mapped to Cash at Vault / Cash at
// Teller, so their row can be locked the moment the office's accounts are drawn.
this.accountingService.getFinancialActivityAccounts().subscribe((mappings: any[]) => {
this.denominationManagedGlAccountIds = (mappings || [])
.filter((mapping) => mapping.financialActivityData?.id === 101 || mapping.financialActivityData?.id === 102)
.map((mapping) => mapping.glAccountData?.id)
.filter((id) => id != null);
// Retrieve may already have run before this (fast) request came back — lock any row it
// missed rather than leaving it editable for the rest of this visit.
this.glAccountEntries?.controls.forEach((control) => {
const glAccountId = control.get('glAccountId')?.value;
if (this.isDenominationManaged(glAccountId) && control.enabled) {
control.disable();
}
});
});
}
/**
@@ -136,14 +161,31 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
* @returns {FormGroup} GL Account entry form.
*/
createGLAccountEntryForm(glAccount: any): UntypedFormGroup {
return this.formBuilder.group(
// A never-touched account arrives as a placeholder row (amount/entryType both null); one that
// already carries an opening balance arrives with its real, committed figure — show that
// instead of leaving the field looking blank.
const existingDebit = glAccount.entryType?.value === 'DEBIT' ? glAccount.amount : null;
const existingCredit = glAccount.entryType?.value === 'CREDIT' ? glAccount.amount : null;
const group = this.formBuilder.group(
{
glAccountId: [glAccount.glAccountId],
debit: [null],
credit: [null]
debit: [existingDebit],
credit: [existingCredit]
},
{ validator: onlyOneOfTheFieldsIsRequiredValidator }
);
if (this.isDenominationManaged(glAccount.glAccountId)) {
// Disabling excludes this row from the form's own .value, so it is never resubmitted here —
// the account keeps whatever Migrate Cash Denomination last set it to.
group.disable();
}
return group;
}
/** Whether this GL account is Cash at Vault / Cash at Teller — locked here, see
* denominationManagedGlAccountIds. */
isDenominationManaged(glAccountId: number): boolean {
return this.denominationManagedGlAccountIds.includes(glAccountId);
}
/**
@@ -176,17 +218,30 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
this.openingBalancesData = openingBalancesData;
entry.valueChanges.subscribe(() => {
this.debitsSum = 0;
this.creditsSum = 0;
entry.controls.forEach((value) => {
this.debitsSum += value.value.debit;
this.creditsSum += value.value.credit;
});
});
entry.valueChanges.subscribe(() => this.recomputeSums());
// Runs once immediately too: valueChanges only fires on a later edit, and the locked cash
// row's own prefilled figure (set once, above, never through a further value change) would
// otherwise never be reflected in the banner at all.
this.recomputeSums();
});
}
/** Total Debit / Total Credit shown in the balance banner. A locked (denomination-managed) row is
* excluded here exactly as it is excluded from the form's own .value used at submit time — this
* screen only needs to balance the accounts it is itself about to (re)define, not the one
* Migrate Cash Denomination already settled and this form can never touch. */
private recomputeSums(): void {
this.debitsSum = 0;
this.creditsSum = 0;
this.glAccountEntries.controls.forEach((control) => {
if (control.disabled) {
return;
}
this.debitsSum += control.value.debit || 0;
this.creditsSum += control.value.credit || 0;
});
}
/**
* Submits the opening balances form and defines opening balances,
* if successful redirects to view created transaction.
@@ -53,10 +53,13 @@
<fa-icon icon="sync" [class.fa-spin]="ledgerLoading"></fa-icon>
Refresh
</button>
<button class="gl-hero-btn gl-hero-btn--primary" [routerLink]="['/accounting/journal-entries/create']">
<fa-icon icon="plus"></fa-icon>
New Entry
</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>
}
</div>
</div>
</div>
@@ -14,6 +14,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
import { merge } from 'rxjs';
import { tap, startWith, map, distinctUntilChanged, debounceTime } from 'rxjs/operators';
import { AccountingService } from '../accounting.service';
import { DISABLED_ACCOUNTING_FEATURES } from '../disabled-features';
import { SettingsService } from 'app/settings/settings.service';
import { JournalEntriesDataSource } from './journal-entry.datasource';
import { Dates } from 'app/core/utils/dates';
@@ -90,6 +91,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
maxDate = new Date();
/** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
// ── Filter controls ───────────────────────────────────────────────────────
officeName = new UntypedFormControl();
@@ -49,6 +49,18 @@
</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">
@@ -703,10 +715,10 @@
<span class="db-muted">· {{ timeOnly(d.closedAt) }}</span>
}
</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 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>
@if (d.cashDifference == null) {
@if (!hasValue(d.cashDifference)) {
—
} @else if (d.cashDifference === 0) {
<span class="db-badge db-badge--ok">Tallied</span>
@@ -30,6 +30,36 @@
}
}
/* Blocks Open Day until the branch's first-ever open has gone through Migrate Cash Denomination —
see BranchDayComponent.checkFirstOpenCashMigration(). Same shape as .ve-note, in the error palette
the rest of this screen already uses for a missed close (.bd-missed). */
.bd-locked {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12px;
line-height: 1.5;
mat-icon {
flex: none;
font-size: 18px;
width: 18px;
height: 18px;
}
a {
color: inherit;
font-weight: 600;
text-decoration: underline;
}
}
.bd-success {
padding: 10px 14px;
margin-bottom: 14px;
@@ -10,7 +10,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
import { CashierService } from '../cashier.service';
@@ -61,7 +61,8 @@ interface BranchDay {
imports: [
CommonModule,
FormsModule,
MatIconModule
MatIconModule,
RouterLink
]
})
export class BranchDayComponent implements OnInit {
@@ -85,6 +86,12 @@ export class BranchDayComponent implements OnInit {
loaded = false;
errorMsg = '';
successMsg = '';
/** True only for a branch that has never opened a business day and whose Cash at Vault / Cash at
* Teller GL account has never had anything posted to it — Open Day here would let day-to-day cash
* tracking begin while the ledger silently stays wherever it already was. The backend enforces
* this independently too (see BranchDayRegisterApiResource#requireCashMigrationBeforeFirstEverOpen);
* this flag only steers the user to the right screen before they hit that rejection. */
firstOpenNeedsCashMigration = false;
// Open-day form
showOpenForm = false;
@@ -202,6 +209,7 @@ export class BranchDayComponent implements OnInit {
this.today = res?.today ?? '';
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
this.checkFirstOpenCashMigration();
});
this.service
@@ -212,9 +220,42 @@ export class BranchDayComponent implements OnInit {
});
}
/**
* Only relevant the moment this branch has never opened a business day before — every later
* open/close on this screen is untouched, so there is nothing to re-check once history exists.
*/
private checkFirstOpenCashMigration(): void {
if (this.days.length > 0 || this.openDay) {
this.firstOpenNeedsCashMigration = false;
return;
}
this.service.cashGlStatus().subscribe({
next: (status: any) => {
// configured === false means this tenant has no Cash at Vault / Cash at Teller account
// mapped at all — nothing for this check to reconcile against, so it does not block.
this.firstOpenNeedsCashMigration = status?.configured !== false && !status?.hasBalance;
},
error: () => {
this.firstOpenNeedsCashMigration = false;
}
});
}
/** True when the branch has no open day at all, so today can be opened. */
get canOpenToday(): boolean {
return this.canOperate && !this.openDay;
return this.canOperate && !this.openDay && !this.firstOpenNeedsCashMigration;
}
/**
* A still-open day's closing/book figures are dropped from the API response entirely (the
* backend's Gson instance omits a null map value rather than sending it as JSON null), so the
* field arrives as undefined, not null. Checking only `!== null` against that — which a stricter
* pass of this file made the mistake of doing — leaves undefined on the "has a value" branch,
* which then renders `'₹' + null` (the number pipe's own null output, string-concatenated) as the
* literal text "₹null". This checks for either, the way an open day actually shows up.
*/
hasValue(value: number | null | undefined): boolean {
return value !== null && value !== undefined;
}
startOpen(): void {
@@ -303,8 +303,8 @@
<tr>
<th class="gl-th--voucher">Voucher No</th>
<th class="gl-th--date">Date</th>
<th>A/C No</th>
<th>A/C Name</th>
<th>Debit</th>
<th>Credit</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,8 +326,25 @@
<div class="gl-time">{{ r.txnTime }}</div>
}
</td>
<td class="gl-acno">{{ r.acNo }}</td>
<td class="gl-acname">{{ r.acName }}</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-pt">
@if (r.paymentType) {
<span class="gl-pt-badge">{{ r.paymentType }}</span>
@@ -1638,6 +1638,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private lastLoadedAcNo = '';
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
cashVaultGlId: number | null = null;
/** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
* entry has to be filed against THIS account, not whichever account the teller typed in, since
* that account is never the one physical notes actually moved through (see submitGlJournalEntry).
* Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
* its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
cashVaultGlCode: string | null = null;
cashVaultGlName: string | null = null;
// Joint holder expanded details (keyed by index)
expandedHolder: number | null = null;
@@ -3243,6 +3250,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
byName(/vault/i) ?? byName(/cash/i) ?? items.find((a: any) => a.glAccountData?.type?.value === 'ASSET');
if (found?.glAccountData?.id) {
this.cashVaultGlId = found.glAccountData.id;
this.cashVaultGlCode = found.glAccountData.glCode ?? null;
this.cashVaultGlName = found.glAccountData.name ?? found.financialActivityData?.name ?? null;
}
});
}
@@ -3311,7 +3320,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.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}${cfg.extraListQueryParams ? `&${cfg.extraListQueryParams}` : ''}`
)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
@@ -3400,6 +3409,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
matchField: string;
detailParams: string;
supportsPagination: boolean;
/** Extra fixed query params (e.g. "usage=1") appended to every list-search request for this
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
* for a transaction; a Header can never receive a posting, so it should never be offered. */
extraListQueryParams?: string;
} {
const t = acTyp?.toUpperCase();
if (t === 'LA') {
@@ -3453,7 +3466,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
listQueryParam: 'searchParam',
matchField: 'glCode',
detailParams: '?fetchRunningBalance=true',
supportsPagination: false
supportsPagination: false,
// Detail-only: a Header account is a pure grouping node and can never receive a
// transaction, so it must never be a selectable match here in the first place.
extraListQueryParams: 'usage=1'
};
}
if (t === 'CA' || t === 'CB') {
@@ -5772,11 +5788,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
count: this.countFor(d),
subtotal: this.subtotalFor(d)
}));
// Filed against the Cash Vault GL, not the account the teller selected on screen: that
// selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and
// never the one whose physical notes actually changed hands. Tagging the breakdown to it
// instead of the vault is why this cash never reached the cashier/vault position.
this.http
.post('/cashier/gl-denomination', {
txnId: res.transactionId ?? res.resourceId,
accountNo: this.accountDetails?.glCode ?? row.acNo,
accountName: this.accountDetails?.name ?? '',
accountNo: this.cashVaultGlCode ?? row.acNo,
accountName: this.cashVaultGlName ?? 'Cash at Vault',
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
txnDate: txnDateIso,
paymentTypeName: selectedPt?.name ?? 'Cash',
@@ -969,6 +969,11 @@
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>
@@ -979,7 +984,11 @@
color="primary"
type="button"
class="verify-btn"
[disabled]="readOnly || createClientForm.get('alternateMobileNo')?.value?.length !== 10"
[disabled]="
readOnly ||
createClientForm.get('alternateMobileNo')?.value?.length !== 10 ||
createClientForm.get('alternateMobileNo')?.hasError('sameAsMobile')
"
(click)="verifyAlternateDIP()"
>
<fa-icon icon="shield-alt" class="m-r-10"></fa-icon>
@@ -25,7 +25,10 @@ import {
UntypedFormGroup,
Validators,
UntypedFormControl,
ReactiveFormsModule
ReactiveFormsModule,
ValidatorFn,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { Subject, Subscription, merge } from 'rxjs';
import { filter, switchMap, takeUntil } from 'rxjs/operators';
@@ -922,6 +925,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
}
/** Alternate Mobile No must differ from Mobile No — the same number saved in both fields is
* meaningless (there's no second contact route) and the DIP checks would just repeat. */
private notSameAsMobileNoValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
const mobileNo = control.parent?.get('mobileNo')?.value;
return value && mobileNo && value === mobileNo ? { sameAsMobile: true } : null;
};
}
/**
* Creates the client form.
*/
@@ -961,7 +974,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
'',
[
Validators.pattern(/^[6-9]\d{9}$/),
Validators.maxLength(10)
Validators.maxLength(10),
this.notSameAsMobileNoValidator()
]
],
emailAddress: [
@@ -1334,6 +1348,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// invalid) and its own reset never had a chance to run.
this.mobileOtpVerified = false;
this.mobileNewOtpId = null;
// Alternate Mobile No's "not same as Mobile No" validator reads mobileNo's current
// value, so re-run it here too — otherwise fixing mobileNo away from a clash leaves
// the alternate field's error stuck until the user also edits that field.
this.createClientForm.get('alternateMobileNo')?.updateValueAndValidity({ emitEvent: false });
});
this.createClientForm
@@ -221,6 +221,22 @@
<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">
@@ -238,14 +254,18 @@
@if (existingAddressProofDocuments.length > 0 && !addrFiles.front && readOnly) {
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
<div class="addr-dropzone__existing">
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
@if (existingAddrFrontSlotPreview) {
<img
[src]="existingAddrFrontSlotPreview"
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">
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
{{ existingAddrFrontSlotName }}
</span>
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
<mat-icon>visibility</mat-icon>
@@ -278,14 +298,18 @@
<!-- Edit mode WITH existing doc — let the user view it OR
click anywhere to replace. -->
<div class="addr-dropzone__existing">
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
@if (existingAddrFrontSlotPreview) {
<img
[src]="existingAddrFrontSlotPreview"
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">
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
{{ existingAddrFrontSlotName }}
</span>
<div class="addr-dropzone__existing-actions">
<button
@@ -327,14 +351,18 @@
@if (existingAddressProofDocuments.length > 0 && !addrFiles.back && readOnly) {
<div class="addr-dropzone addr-dropzone--has addr-dropzone--locked">
<div class="addr-dropzone__existing">
@if (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
@if (existingAddrBackSlotPreview) {
<img
[src]="existingAddrBackSlotPreview"
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">
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
{{ existingAddrBackSlotName }}
</span>
<button type="button" class="addr-dropzone__view" (click)="viewExistingAddressProof()">
<mat-icon>visibility</mat-icon>
@@ -367,14 +395,18 @@
<!-- 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 (addrMergedPreview) {
<img [src]="addrMergedPreview" alt="Saved address proof" class="addr-dropzone__thumb" />
@if (existingAddrBackSlotPreview) {
<img
[src]="existingAddrBackSlotPreview"
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">
{{ existingAddressProofDocuments[0].fileName || existingAddressProofDocuments[0].name }}
{{ existingAddrBackSlotName }}
</span>
<div class="addr-dropzone__existing-actions">
<button
@@ -102,7 +102,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
Validators.required
],
documentKey: [''],
issueDate: ['']
issueDate: [''],
expiryDate: ['']
});
currentForm: UntypedFormGroup = this.fb.group({
@@ -222,6 +223,12 @@ 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',
@@ -307,11 +314,10 @@ 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);
if (this.existingKycDocSides.front?.previewUrl) {
this.addrMergedPreview = this.existingKycDocSides.front.previewUrl;
} else if (this.existingKycDocSides.back?.previewUrl) {
this.addrMergedPreview = this.existingKycDocSides.back.previewUrl;
}
// 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;
this.existingAddressProofIdentifierId = this.existingKycIdentifierId;
}
} else if (this.isAddressProofLinkedToKyc) {
@@ -327,6 +333,8 @@ 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;
}
/**
@@ -356,6 +364,8 @@ 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;
@@ -488,6 +498,7 @@ 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 }[] = [];
@@ -500,11 +511,13 @@ 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
};
}
@@ -644,7 +657,14 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
this.tryLinkKycData();
return;
}
const identifier = identifiers.find((i: any) => this.findMatchingProof(i?.documentType?.name));
// 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));
if (!identifier?.id) {
this.existingAddressProofCheckComplete = true;
this.tryLinkKycData();
@@ -664,14 +684,19 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
proofId: matchedProof.id,
documentKey: identifier.documentKey || ''
});
// 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.
// 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.
const desc: string = identifier.description || '';
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
const issueMatch = desc.match(/Issue date:\s*([^,|]+)/i);
const expiryMatch = desc.match(/Expiry 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({
@@ -685,6 +710,29 @@ 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/')
);
@@ -715,6 +763,50 @@ 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,6 +7,7 @@
*/
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,
@@ -1266,8 +1267,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.loadExistingKycIdentifier(data?.kycDocumentName);
},
error: () => {
// No existing KYC — that's fine for new clients
// 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.
this.kycLoaded = true;
this.loadExistingPhoto();
this.loadExistingSignature();
this.loadExistingKycIdentifier(undefined);
}
});
}
@@ -1283,8 +1290,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
*/
private loadExistingKycIdentifier(kycDocumentName: string | undefined): void {
if (!this.clientId) return;
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
next: (identifiers: any[]) => {
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 }) => {
if (!identifiers || identifiers.length === 0) return;
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const isKycType = (i: any) => {
@@ -1297,6 +1309,13 @@ 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
@@ -1311,7 +1330,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return name === kName || name.includes(kName) || kName.includes(name);
})
: null;
const identifier = match || sorted.find(isKycType);
// 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);
if (!identifier?.id) return;
this.existingKycIdentifierId = identifier.id.toString();
@@ -1327,6 +1354,19 @@ 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[]) => {
@@ -204,6 +204,24 @@
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</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>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ADDRESS_PROOF.name }}</span>
@@ -217,78 +235,51 @@
</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)="toggleReplace('ADDRESS_PROOF')"
(click)="openUploadDialog('ADDRESS_PROOF')"
>
<fa-icon [icon]="existing.ADDRESS_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.ADDRESS_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
<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 }}
</button>
</div>
}
@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>
@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>
</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>
@@ -310,6 +301,24 @@
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</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>
}
<div class="ckyc-existing__meta">
<fa-icon icon="id-card"></fa-icon>
<span>{{ existing.PHOTO_PROOF.name }}</span>
@@ -322,78 +331,49 @@
</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)="toggleReplace('PHOTO_PROOF')"
(click)="openUploadDialog('PHOTO_PROOF')"
>
<fa-icon [icon]="existing.PHOTO_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.PHOTO_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
<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 }}
</button>
</div>
}
@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>
@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>
</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>
@@ -422,74 +402,33 @@
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="toggleReplace('CLIENT_IMAGE')"
(click)="openUploadDialog('CLIENT_IMAGE')"
>
<fa-icon [icon]="existing.CLIENT_IMAGE.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.CLIENT_IMAGE.expanded ? 'Cancel replace' : 'Upload new version' }}
<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 }}
</button>
</div>
}
@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>
@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>
</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>
@@ -526,75 +465,33 @@
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="toggleReplace('CLIENT_SIGNATURE')"
(click)="openUploadDialog('CLIENT_SIGNATURE')"
>
<fa-icon [icon]="existing.CLIENT_SIGNATURE.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.CLIENT_SIGNATURE.expanded ? 'Cancel replace' : 'Upload new version' }}
<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 }}
</button>
</div>
}
@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>
@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>
</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,15 +553,21 @@
margin: 0 0 12px;
}
.ckyc-file-row {
.ckyc-missing {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
gap: 8px;
margin-bottom: 12px;
}
.ckyc-file-name-field {
flex: 1;
.ckyc-uploading {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
font-size: 12px;
color: #5f6368;
}
.ckyc-upload-error {
@@ -583,19 +589,6 @@
}
}
.ckyc-upload-response {
margin-top: 12px;
padding: 10px 14px;
background-color: #e8f5e9;
border: 1px solid #a5d6a7;
border-radius: 8px;
font-size: 12px;
p {
margin: 4px 0;
}
}
@media (width <= 900px) {
.ckyc-upload-grid {
grid-template-columns: 1fr;
@@ -610,14 +603,6 @@
.ckyc-upload-grid {
grid-template-columns: 1fr;
}
.ckyc-file-row {
flex-direction: column;
}
.ckyc-file-name-field {
width: 100%;
}
}
/* ===================== Already-available banners ===================== */
@@ -731,6 +716,34 @@
letter-spacing: 0.3px;
}
&__thumbs {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
&__dates {
display: flex;
align-items: center;
gap: 10px;
font-size: 11px;
color: #0f6e56;
flex-wrap: wrap;
span {
display: inline-flex;
align-items: center;
gap: 5px;
}
fa-icon {
color: #1d9e75;
font-size: 11px;
}
}
&__thumb {
width: 100px;
height: 100px;
@@ -9,27 +9,38 @@
/** Angular Imports */
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subject, takeUntil, catchError, of, forkJoin } from 'rxjs';
import { Subject, Observable, takeUntil, catchError, of, forkJoin } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { AlertService } from 'app/core/alert/alert.service';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { Dates } from 'app/core/utils/dates';
import { ProofType, normalizeProofType } from '../../shared/kyc-proof-types';
import { CkycUploadDialogComponent, CkycUploadDialogResult } from './ckyc-upload-dialog/ckyc-upload-dialog.component';
interface ExistingArtefact {
present: boolean;
name?: string;
source?: string;
previewUrl?: string;
previewUrlBack?: string;
documentId?: string;
documentTypeId?: string;
documentKey?: string;
/** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention client-india-address-step
* already writes/reads for address proof; now shared by the photo/identity proof too. */
description?: string;
fileCount?: number;
expanded?: boolean;
}
type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF';
type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
@Component({
selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html',
@@ -46,6 +57,8 @@ export class CkycTabComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private clientsService = inject(ClientsService);
private alertService = inject(AlertService);
private dialog = inject(MatDialog);
private dates = inject(Dates);
private destroy$ = new Subject<void>();
@@ -80,16 +93,41 @@ export class CkycTabComponent implements OnInit, OnDestroy {
}
/** Pre-existing artefacts detected from client data */
existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = {
existing: Record<CkycDocKind, ExistingArtefact> = {
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 */
addressProofFile: File | null = null;
photoProofFile: File | null = null;
clientImageFile: File | null = null;
clientSignatureFile: File | null = null;
@@ -101,9 +139,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
CLIENT_SIGNATURE: false
};
/** Per-document upload response */
uploadResponses: { [key: string]: any } = {};
/** Per-document error messages */
uploadErrors: { [key: string]: string | null } = {
ADDRESS_PROOF: null,
@@ -262,6 +297,8 @@ export class CkycTabComponent implements OnInit, OnDestroy {
}
// 3 & 4. ID proof + Address proof — client identifiers classified via proof-types
this.idProofOptions = (proofTypes?.idProofs || []).map((p: any) => normalizeProofType(p));
this.addressProofOptions = (proofTypes?.addressProofs || []).map((p: any) => normalizeProofType(p));
const idProofIds = new Set<string>((proofTypes?.idProofs || []).map((p: any) => String(p.id)));
const addressProofIds = new Set<string>((proofTypes?.addressProofs || []).map((p: any) => String(p.id)));
@@ -287,21 +324,77 @@ export class CkycTabComponent implements OnInit, OnDestroy {
name: proofName,
documentKey: isSyntheticKey ? '' : rawKey,
documentId: identity?.id,
documentTypeId: docTypeId,
description: identity?.description || '',
fileCount: (identity?.documents || []).length
};
if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) {
this.existing.PHOTO_PROOF = common;
this.selectedProofType.PHOTO_PROOF = this.idProofOptions.find((p) => String(p.id) === docTypeId) || null;
this.loadIdentifierDocPreview('PHOTO_PROOF', identity.id);
} else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) {
this.existing.ADDRESS_PROOF = common;
this.selectedProofType.ADDRESS_PROOF =
this.addressProofOptions.find((p) => String(p.id) === docTypeId) || null;
this.loadIdentifierDocPreview('ADDRESS_PROOF', identity.id);
}
}
});
}
toggleReplace(documentType: string): void {
const entry = (this.existing as any)[documentType];
if (entry) entry.expanded = !entry.expanded;
/**
* The scanned ID/address-proof card only showed its file name and key — never the scan
* itself, unlike the photo and signature cards right beside it. Mirrors those two: fetch
* the identifier's own attached document(s) (m_client_identifier -> m_document, the same
* chain the Identities tab and the create-client stepper read) and download the latest for
* an inline preview. Two-sided proof types (Aadhaar, Passport, Voter ID, Driving Licence)
* store front/back as two separate documents named "<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.
*/
private loadIdentifierDocPreview(kind: ProofKind, identifierId: string): void {
this.clientsService
.getClientIdentificationDocuments(identifierId)
.pipe(
takeUntil(this.destroy$),
catchError(() => of([]))
)
.subscribe((docs: any) => {
const list = Array.isArray(docs) ? docs : [];
if (!list.length) return;
this.existing[kind].fileCount = list.length;
const byNewest = (a: any, b: any) => b.id - a.id;
const label = (d: any) => (d?.name || d?.fileName || '').toString();
const back = [...list].filter((d: any) => /back/i.test(label(d))).sort(byNewest)[0];
const front =
[...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] ||
[...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0];
if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front');
if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back');
});
}
private downloadIdentifierDocPreview(
identifierId: string,
documentId: string,
kind: ProofKind,
slot: 'front' | 'back'
): void {
this.clientsService
.downloadClientIdentificationDocument(identifierId, documentId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
if (slot === 'front') this.existing[kind].previewUrl = url;
else this.existing[kind].previewUrlBack = url;
},
error: () => undefined
});
}
onAccountSelect(accountNo: string): void {
@@ -309,33 +402,8 @@ export class CkycTabComponent implements OnInit, OnDestroy {
this.selectedAccountType = this.selectedAccount?.accountType || '';
}
onFileSelect(event: any, documentType: string): void {
const file = event.target?.files?.[0];
if (!file) return;
switch (documentType) {
case 'ADDRESS_PROOF':
this.addressProofFile = file;
break;
case 'PHOTO_PROOF':
this.photoProofFile = file;
break;
case 'CLIENT_IMAGE':
this.clientImageFile = file;
break;
case 'CLIENT_SIGNATURE':
this.clientSignatureFile = file;
break;
}
this.uploadErrors[documentType] = null;
}
getFile(documentType: string): File | null {
switch (documentType) {
case 'ADDRESS_PROOF':
return this.addressProofFile;
case 'PHOTO_PROOF':
return this.photoProofFile;
case 'CLIENT_IMAGE':
return this.clientImageFile;
case 'CLIENT_SIGNATURE':
@@ -345,38 +413,278 @@ export class CkycTabComponent implements OnInit, OnDestroy {
}
}
private static readonly CARD_TITLES: Record<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;
}
const file = this.getFile(documentType);
if (!file) return;
this.uploading[documentType] = true;
this.uploadErrors[documentType] = null;
const key = documentType as keyof typeof this.existing;
const formData = new FormData();
formData.append('file', file);
formData.append('documentType', documentType);
let request$: Observable<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;
}
this.clientsService
.uploadCkycDocument(this.clientId, formData)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.uploading[documentType] = false;
this.uploadResponses[documentType] = response;
this.alertService.alert({
type: 'success',
message: response?.message || 'Document uploaded successfully.'
});
},
error: (err: any) => {
this.uploading[documentType] = false;
this.uploadErrors[documentType] =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to upload document.';
request$.pipe(takeUntil(this.destroy$)).subscribe({
next: () => {
this.uploading[documentType] = false;
this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' });
this.refreshPreview(documentType);
},
error: (err: any) => {
this.uploading[documentType] = false;
this.uploadErrors[documentType] = this.extractErrorMessage(err);
}
});
}
/**
* Proof cards work like the create-client KYC step: pick a document type first (its `sides`
* decides whether one image or a front+back pair is required), then upload. Reuses the same
* identifier the Identities tab manages — updates its documentTypeId if the picked type
* changed, or creates a fresh identifier when none exists yet, so this card never depends on
* a separate trip to the Identities tab.
*/
private uploadProofDocument(kind: ProofKind): void {
const proof = this.selectedProofType[kind];
if (!proof) {
this.uploadErrors[kind] = 'Select a document type first.';
return;
}
const files = this.docFiles[kind];
if (!files.front || (proof.sides === 2 && !files.back)) {
this.uploadErrors[kind] =
proof.sides === 2
? 'Both front and back images are required for this document type.'
: 'Select a file to upload.';
return;
}
this.uploading[kind] = true;
this.uploadErrors[kind] = null;
const existingId = this.existing[kind].documentId;
const details = this.documentDetails[kind];
const documentKey = details.documentKey || `${proof.name} - ${this.clientId}`;
const description = this.buildDescription(details.issueDate, details.expiryDate);
const identifier$: Observable<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
});
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);
}
});
},
error: (err: any) => {
this.uploading[kind] = false;
this.uploadErrors[kind] = this.extractErrorMessage(err);
}
});
}
private extractErrorMessage(err: any): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to upload document.'
);
}
/** Re-fetches whichever artefact was just replaced, so its preview reflects the new file
* immediately instead of the one this screen loaded on entry. */
private refreshPreview(documentType: string): void {
const staleKey = documentType as keyof typeof this.existing;
if (this.existing[staleKey].previewUrl?.startsWith('blob:')) {
URL.revokeObjectURL(this.existing[staleKey].previewUrl!);
}
if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) {
URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!);
}
switch (documentType) {
case 'CLIENT_IMAGE':
this.clientsService
.getClientProfileImage(this.clientId)
.pipe(takeUntil(this.destroy$))
.subscribe((photo: any) => {
if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo;
});
break;
case 'CLIENT_SIGNATURE':
this.clientsService
.getClientDocuments(this.clientId)
.pipe(takeUntil(this.destroy$))
.subscribe((documents: any) => {
const doc = (documents as any[])
?.filter((d: any) => d?.name === 'clientSignature')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (!doc) return;
this.existing.CLIENT_SIGNATURE.documentId = doc.id;
this.clientsService
.getClientSignatureImage(this.clientId, doc.id)
.pipe(takeUntil(this.destroy$))
.subscribe((blob: any) => {
if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
});
});
break;
case 'PHOTO_PROOF':
case 'ADDRESS_PROOF':
this.loadIdentifierDocPreview(documentType, this.existing[documentType].documentId!);
break;
}
}
ngOnDestroy(): void {
@@ -385,6 +693,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
// Release object URLs
Object.values(this.existing).forEach((e) => {
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
});
}
}
@@ -0,0 +1,167 @@
<!--
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>
@@ -0,0 +1,270 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
max-width: 100%;
}
/* ===================== Header ===================== */
.cud-header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 20px 24px 16px;
border-bottom: 1px solid #eef1f4;
&__badge {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
color: #fff;
background: linear-gradient(135deg, #1a73e8, #4285f4);
box-shadow: 0 3px 10px rgb(26 115 232 / 28%);
}
&__text {
flex: 1;
min-width: 0;
}
}
.cud-title {
margin: 0 !important;
padding: 0;
font-size: 16px;
font-weight: 700;
color: #1a1d21;
line-height: 1.3;
}
.cud-subtitle {
margin: 2px 0 0;
font-size: 12px;
color: #6b7280;
}
.cud-close {
flex-shrink: 0;
width: 32px;
height: 32px;
margin: -4px -4px 0 0;
color: #9aa0a6;
&:hover {
color: #3c4043;
}
}
/* ===================== Body / sections ===================== */
.cud-body.mat-mdc-dialog-content {
padding: 18px 24px !important;
max-height: 70vh;
}
.cud-section {
margin-bottom: 14px;
&--dates {
display: flex;
gap: 14px;
.cud-date-field {
flex: 1;
min-width: 0;
}
}
&__label {
display: flex;
align-items: center;
gap: 7px;
font-size: 11px;
font-weight: 700;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
fa-icon {
color: #1a73e8;
font-size: 10.5px;
}
}
}
.cud-field {
width: 100%;
}
.cud-hint {
font-size: 12px;
color: #6b7280;
margin: 6px 0 0;
line-height: 1.4;
}
.cud-banner {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 10px 14px;
margin-bottom: 14px;
background: #f0fbf6;
border: 1px solid #cfecdd;
border-radius: 10px;
font-size: 12.5px;
color: #085041;
line-height: 1.45;
fa-icon {
margin-top: 2px;
color: #1d9e75;
flex-shrink: 0;
}
}
.cud-divider {
display: flex;
align-items: center;
gap: 10px;
margin: 16px 0 12px;
font-size: 10.5px;
font-weight: 700;
color: #9aa0a6;
text-transform: uppercase;
letter-spacing: 0.08em;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: #eef1f4;
}
}
/* ===================== Front/back scan grid ===================== */
.cud-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
&--two {
grid-template-columns: repeat(2, 1fr);
}
}
.cud-slot {
display: flex;
flex-direction: column;
gap: 6px;
&__label {
font-size: 10.5px;
font-weight: 700;
color: #5f6368;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.cud-dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 130px;
padding: 16px;
border: 2px dashed #cbd5e1;
border-radius: 12px;
cursor: pointer;
text-align: center;
background: #fafbfc;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #1a73e8;
background: #f5f9ff;
box-shadow: 0 2px 10px rgb(26 115 232 / 10%);
}
&--has {
border-style: solid;
border-color: #1d9e75;
background: #f0fbf6;
}
&__icon {
font-size: 22px;
color: #9aa5b1;
}
&--has &__icon {
color: #1d9e75;
}
&__text {
font-size: 12px;
color: #64748b;
margin: 0;
font-weight: 500;
}
&__name {
font-size: 11.5px;
color: #085041;
font-weight: 600;
margin: 0;
word-break: break-all;
padding: 0 8px;
}
&__img {
max-width: 100%;
max-height: 150px;
border-radius: 8px;
object-fit: contain;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
}
/* ===================== Actions ===================== */
.cud-actions {
padding: 12px 24px !important;
margin: 0 !important;
border-top: 1px solid #eef1f4;
fa-icon {
font-size: 12px;
}
}
@media (width <= 560px) {
:host {
width: 100%;
}
.cud-section--dates {
flex-direction: column;
gap: 14px;
}
.cud-grid--two {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,155 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnDestroy, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { Dates } from 'app/core/utils/dates';
import { ProofType } from '../../../shared/kyc-proof-types';
export interface CkycUploadDialogData {
title: string;
isProofKind: boolean;
accept: string;
proofOptions?: ProofType[];
initialProofType?: ProofType | null;
initialDocumentKey?: string;
initialIssueDate?: Date | null;
initialExpiryDate?: Date | null;
}
export interface CkycUploadDialogResult {
proofType?: ProofType | null;
front: File;
back?: File;
/** Document number (Aadhaar/PAN/Passport number, etc.) — same field client creation collects. */
documentKey?: string;
/** 'yyyy-MM-dd', same as the rest of this app's date fields. */
issueDate?: string | null;
expiryDate?: string | null;
}
/**
* A focused popup for replacing/uploading one CKYC artefact at a time — opened from each card on
* the CKYC tab instead of expanding a form inline underneath it. Collects the same details
* client creation does for an identity/address proof (document type, number, issue + expiry
* date) and shows a client-side preview of whatever image is picked before it's ever sent to the
* server; the actual upload still happens back in CkycTabComponent (this dialog only collects
* the type, number, dates and file(s), then hands them back).
*/
@Component({
selector: 'mifosx-ckyc-upload-dialog',
templateUrl: './ckyc-upload-dialog.component.html',
styleUrls: ['./ckyc-upload-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatIconButton,
FaIconComponent
]
})
export class CkycUploadDialogComponent implements OnDestroy {
private dates = inject(Dates);
dialogRef = inject<MatDialogRef<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
};
@@ -840,6 +840,15 @@ export class CreateClientComponent {
this.saveAddressProof(clientId);
}
/** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention the CKYC tab and
* client-india-address-step's own "load existing" parsing both read back. */
private buildProofDescription(issueDate: string | null | undefined, expiryDate: string | null | undefined): string {
const parts: string[] = [];
if (issueDate) parts.push(`Issue date: ${issueDate}`);
if (expiryDate) parts.push(`Expiry date: ${expiryDate}`);
return parts.join(', ');
}
/**
* Save the address-proof picked on the permanent tab as a client identifier
* + upload each selected file as a document attached to that identifier.
@@ -855,9 +864,9 @@ export class CreateClientComponent {
const proofName = payload.proof?.name || 'Address Proof';
const identifierData: any = {
documentTypeId: payload.proofId,
status: 'Active',
status: 'ACTIVE',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
description: this.buildProofDescription(payload.issueDate, payload.expiryDate)
};
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
@@ -904,7 +913,7 @@ export class CreateClientComponent {
}
const identifierData: any = {
documentTypeId,
status: 'Active',
status: 'ACTIVE',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
description: 'KYC document'
};
@@ -164,6 +164,13 @@ export class ContactOtpBlockComponent implements OnChanges, OnDestroy {
request$.subscribe({
next: (res: any) => {
this.sending = false;
// The backend reports failure to actually dispatch the OTP (gateway error, flagged
// number, SMTP failure...) as a 200 response with success:false and a real reason in
// `message` — never a thrown error. Surface that reason instead of treating it as sent.
if (res?.success === false) {
this.errorMessage = res?.message || 'Could not send OTP.';
return;
}
this.otpId = res?.otpId ?? res?.resourceId ?? null;
this.otpSent = true;
this.clearAllDigits();
@@ -1035,6 +1035,15 @@ export class EditClientComponent {
this.saveAddressProof(clientId);
}
/** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention the CKYC tab and
* client-india-address-step's own "load existing" parsing both read back. */
private buildProofDescription(issueDate: string | null | undefined, expiryDate: string | null | undefined): string {
const parts: string[] = [];
if (issueDate) parts.push(`Issue date: ${issueDate}`);
if (expiryDate) parts.push(`Expiry date: ${expiryDate}`);
return parts.join(', ');
}
/**
* Save the address-proof picked on the permanent tab as a client identifier
* + upload each selected file as a document attached to that identifier.
@@ -1050,9 +1059,9 @@ export class EditClientComponent {
const proofName = payload.proof?.name || 'Address Proof';
const identifierData: any = {
documentTypeId: payload.proofId,
status: 'Active',
status: 'ACTIVE',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
description: this.buildProofDescription(payload.issueDate, payload.expiryDate)
};
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
@@ -1105,7 +1114,7 @@ export class EditClientComponent {
const existingId = this.clientKycStep?.existingKycIdentifierId;
if (existingId) {
this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => {
const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'Active' };
const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'ACTIVE' };
if (documentTypeId != null) {
update.documentTypeId = documentTypeId;
}
@@ -1128,7 +1137,7 @@ export class EditClientComponent {
}
const identifierData: any = {
documentTypeId,
status: 'Active',
status: 'ACTIVE',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
description: 'KYC document'
};
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, Inject, OnDestroy, inject } from '@angular/core';
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';
@@ -83,6 +83,7 @@ export interface KycDocumentUpdateResult {
export class KycDocumentUpdateDialogComponent implements OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private dialogRef = inject<MatDialogRef<KycDocumentUpdateDialogComponent>>(MatDialogRef);
data = inject<KycDocumentUpdateData>(MAT_DIALOG_DATA);
form: UntypedFormGroup;
@@ -90,22 +91,25 @@ export class KycDocumentUpdateDialogComponent implements OnDestroy {
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'];
readonly slots: ('front' | 'back')[] = [
'front',
'back'
];
constructor(@Inject(MAT_DIALOG_DATA) public data: KycDocumentUpdateData) {
constructor() {
this.form = this.formBuilder.group({
documentNumber: [
data.documentNumber || '',
this.data.documentNumber || '',
[Validators.required]
],
issueDate: [this.toDate(data.issueDate)],
issueDate: [this.toDate(this.data.issueDate)],
// Left free rather than required: a passport expires, a PAN card does not.
expiryDate: [this.toDate(data.expiryDate)],
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: [
'',
data.requiresRemarks ? [Validators.required] : []
this.data.requiresRemarks ? [Validators.required] : []
]
});
}
@@ -299,32 +299,38 @@
</mat-icon>
<a matLine>{{ 'labels.menus.Notifications' | 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>
<!-- 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/chart-of-accounts']"
[matTooltipPosition]="tooltipPosition"
@@ -33,6 +33,7 @@ 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';
@@ -81,6 +82,8 @@ 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;
@@ -25,10 +25,68 @@ import { Form15Kind, Form15ghRecord } from './form-15gh/form-15gh.component';
import { HypothecationRecord } from './hypothecation/hypothecation.component';
import { NocRecord } from './noc/noc.component';
const BANK_NAME = 'Nexora Co-operative Bank Ltd.';
const BANK_ADDRESS = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
const BANK_REG = 'RBI Lic. No. NX-2018/MH/482';
const BANK_CONTACT = 'info@nexorabank.in · 022 6789 0000';
/**
* The issuing bank's own identity, as set on the Institution Profile (Admin > Organization) — this
* file is shared by every certificate type (loan interest, share, balance, Form 60/15G/15H,
* hypothecation, NOC) and every tenant this app runs for, so nothing here can be a fixed name.
* These defaults only cover the moment before whichever screen opened this module has fetched the
* real profile and called {@link setBankIdentity}; they are never meant to reach a printed PDF.
*/
const bankIdentity = {
name: 'Bank',
address: '',
regNo: '',
contact: '',
/** City/state/country line used by the Share Certificate's own letterhead style. */
location: '',
/** Short label for the circular seal graphic — two lines, e.g. bank's short name + type. */
sealPrimary: 'BANK',
sealSecondary: ''
};
/** Called once the caller has fetched the real Institution Profile, so every certificate built
* after this point — of any type — carries the actual bank's own details. */
export function setBankIdentity(profile: {
bankName?: string;
statementHeaderName?: string;
shortName?: string;
bankType?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
country?: string;
licenseNumber?: string;
contactEmail?: string;
contactPhone?: string;
}): void {
bankIdentity.name = profile.statementHeaderName || profile.bankName || bankIdentity.name;
bankIdentity.address = [
profile.addressLine1,
profile.addressLine2,
profile.city
]
.filter((p) => !!p && String(p).trim())
.join(', ');
bankIdentity.regNo = profile.licenseNumber ? `RBI Lic. No. ${profile.licenseNumber}` : '';
bankIdentity.contact = [
profile.contactEmail,
profile.contactPhone
]
.filter((p) => !!p && String(p).trim())
.join(' · ');
bankIdentity.location = [
profile.city,
profile.state,
profile.country
]
.filter((p) => !!p && String(p).trim())
.join(', ');
bankIdentity.sealPrimary = (profile.shortName || profile.bankName || bankIdentity.name).toUpperCase();
bankIdentity.sealSecondary = profile.bankType
? profile.bankType.replace(/_/g, ' ').toUpperCase() + (/BANK$/i.test(profile.bankType) ? '' : ' BANK')
: '';
}
const C_TEXT: [number, number, number] = [
15,
@@ -141,14 +199,24 @@ function paintLetterhead(
c.doc.setFont('times', 'bold');
c.doc.setFontSize(19);
setText(c, c.accent);
c.doc.text(BANK_NAME, c.pageW / 2, y, { align: 'center' });
c.doc.text(bankIdentity.name, c.pageW / 2, y, { align: 'center' });
y += 5.5;
c.doc.setFont('helvetica', 'normal');
c.doc.setFontSize(9);
setText(c, C_MUTED);
c.doc.text(BANK_ADDRESS, c.pageW / 2, y, { align: 'center' });
y += 4;
c.doc.text(`${BANK_REG} · ${BANK_CONTACT}`, c.pageW / 2, y, { align: 'center' });
if (bankIdentity.address) {
c.doc.text(bankIdentity.address, c.pageW / 2, y, { align: 'center' });
y += 4;
}
const regContactLine = [
bankIdentity.regNo,
bankIdentity.contact
]
.filter((p) => !!p)
.join(' · ');
if (regContactLine) {
c.doc.text(regContactLine, c.pageW / 2, y, { align: 'center' });
}
y += 5;
setDraw(c, C_BORDER);
c.doc.setLineWidth(0.3);
@@ -350,11 +418,10 @@ function paintSignoff(c: PdfCtx, y: number, branch: string): number {
c.doc.setFont('helvetica', 'bold');
c.doc.setFontSize(7);
setText(c, c.accent);
c.doc.text('NEXORA', cx, cy - 1, { align: 'center' });
c.doc.text(bankIdentity.sealPrimary, cx, cy - 1, { align: 'center' });
c.doc.setFontSize(5);
setText(c, C_MUTED);
c.doc.text('CO-OPERATIVE', cx, cy + 3, { align: 'center' });
c.doc.text('BANK', cx, cy + 5.5, { align: 'center' });
c.doc.text(bankIdentity.sealSecondary, cx, cy + 4, { align: 'center' });
return y + 28;
}
@@ -376,7 +443,7 @@ function paintFooter(c: PdfCtx, y: number): void {
c.doc.setFontSize(8);
setText(c, C_MUTED);
c.doc.text(
`Issued by ${BANK_NAME} on ${formatLongDate(today())}. Computer generated — does not require physical signature for record purposes.`,
`Issued by ${bankIdentity.name} on ${formatLongDate(today())}. Computer generated — does not require physical signature for record purposes.`,
c.pageW / 2,
y,
{ align: 'center' }
@@ -609,13 +676,15 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
doc.setFont('times', 'bold');
doc.setFontSize(22);
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
doc.text('NEXORA CO-OPERATIVE BANK LTD.', pageW / 2, y, { align: 'center' });
doc.text(bankIdentity.name.toUpperCase(), pageW / 2, y, { align: 'center' });
y += 5;
doc.setFont('times', 'italic');
doc.setFontSize(10.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text('Mumbai, Maharashtra, India', pageW / 2, y, { align: 'center' });
if (bankIdentity.location) {
y += 5;
doc.setFont('times', 'italic');
doc.setFontSize(10.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text(bankIdentity.location, pageW / 2, y, { align: 'center' });
}
y += 4;
doc.setDrawColor(242, 237, 213); // light gold separator
@@ -685,7 +754,7 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
doc.setFontSize(11);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
const bodyText = `This is to certify that ${r.memberName} is the registered holder of ${r.shareCount.toLocaleString('en-IN')} (${classLabel}) shares of Rs. ${r.faceValue.toLocaleString('en-IN')} each, fully paid up, in Nexora Co-operative Bank Ltd., subject to the memorandum, articles of association, and bye-laws of the bank.`;
const bodyText = `This is to certify that ${r.memberName} is the registered holder of ${r.shareCount.toLocaleString('en-IN')} (${classLabel}) shares of Rs. ${r.faceValue.toLocaleString('en-IN')} each, fully paid up, in ${bankIdentity.name}, subject to the memorandum, articles of association, and bye-laws of the bank.`;
const bodyLines = doc.splitTextToSize(bodyText, pageW - 50);
for (const line of bodyLines) {
doc.text(line, pageW / 2, y, { align: 'center' });
@@ -763,7 +832,7 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
// Row 1
drawTableCell(25, y, tableColW, tableRowH, 'Branch', true);
drawTableCell(25 + tableColW, y, tableColW, tableRowH, r.branch || 'Mumbai Main Branch', false);
drawTableCell(25 + tableColW, y, tableColW, tableRowH, r.branch || 'Head Office', false);
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Member Since', true);
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, formatLongDate(r.joiningDate), false);
y += tableRowH;
@@ -1168,7 +1237,7 @@ export function buildHypothecationPdf(r: HypothecationRecord): jsPDF {
y = paintPara(
c,
y,
`This is to certify that the asset described below stands hypothecated to ${BANK_NAME} as security for the captioned loan facility extended to ${r.customerName}, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged.`
`This is to certify that the asset described below stands hypothecated to ${bankIdentity.name} as security for the captioned loan facility extended to ${r.customerName}, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged.`
);
const rows: [string, string, string, string][] = [
@@ -684,9 +684,9 @@
<div class="cpd-seal">
<div class="cpd-seal-ring">
<div class="cpd-seal-inner">
<div class="cpd-seal-bank">NEXORA</div>
<div class="cpd-seal-bank">{{ bankSealPrimary }}</div>
<div class="cpd-seal-line"></div>
<div class="cpd-seal-sub">CO-OPERATIVE BANK</div>
<div class="cpd-seal-sub">{{ bankSealSecondary }}</div>
</div>
</div>
</div>
@@ -575,8 +575,19 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* ───────── Print — only the page, no toolbar / dialog chrome ───────── */
/* ───────── Print — only the page, no toolbar / dialog chrome ─────────
This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the
overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip.
`position: absolute` still lives inside that clip and gets cut off — only the on-screen
scroll position prints. `position: fixed` escapes it: a fixed element's containing block is
the viewport itself, not the clipped ancestor (the classic "fixed child ignores an
overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a
transform/filter — the CDK overlay chrome doesn't. */
@media print {
body > :not(.cdk-overlay-container) {
display: none !important;
}
:host {
background: #fff;
}
@@ -585,11 +596,29 @@
display: none !important;
}
.cdk-overlay-container,
.cdk-global-overlay-wrapper,
.cdk-overlay-pane,
.mat-mdc-dialog-container,
.mdc-dialog__surface {
position: static !important;
overflow: visible !important;
max-height: none !important;
height: auto !important;
max-width: none !important;
box-shadow: none !important;
padding: 0 !important;
}
.cpd-root {
position: fixed;
left: 0;
top: 0;
width: 100% !important;
height: auto !important;
background: #fff;
max-height: none;
overflow: visible;
height: auto;
}
.cpd-page {
@@ -599,6 +628,7 @@
width: 100%;
box-shadow: none;
border-top: 4px solid var(--cpd-blue-d);
overflow: visible;
&.cpd-page--share {
border-top-color: var(--cpd-amber);
@@ -6,12 +6,13 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from 'app/organization/organization.service';
import { FyInterestBreakup, LoanInterestRecord } from '../loan-interest/loan-interest.component';
import { ShareCertificateRecord } from '../share-certificate/share-certificate.component';
@@ -27,7 +28,8 @@ import {
downloadHypothecationPdf,
downloadLoanInterestPdf,
downloadNocPdf,
downloadShareCertificatePdf
downloadShareCertificatePdf,
setBankIdentity
} from '../certificate-pdf';
export interface InterestPreviewPayload {
@@ -70,14 +72,52 @@ export interface CertificatePreviewDialogData {
MatTooltip
]
})
export class CertificatePreviewDialogComponent {
export class CertificatePreviewDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<CertificatePreviewDialogComponent>);
private organizationService = inject(OrganizationService);
data: CertificatePreviewDialogData = inject(MAT_DIALOG_DATA);
readonly bankName = 'Nexora Co-operative Bank Ltd.';
readonly bankAddress = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
readonly bankRegNo = 'RBI Lic. No. NX-2018/MH/482';
readonly bankContact = 'info@nexorabank.in · 022 6789 0000';
/** The issuing bank's own details, fetched from the Institution Profile — this dialog is shared
* by every certificate type and every tenant, so nothing here is a fixed name. */
bankName = 'Bank';
bankAddress = '';
bankRegNo = '';
bankContact = '';
bankSealPrimary = 'BANK';
bankSealSecondary = '';
ngOnInit(): void {
this.organizationService.getInstitutionProfile().subscribe({
next: (profile: any) => {
this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName;
this.bankAddress = [
profile?.addressLine1,
profile?.addressLine2,
profile?.city
]
.filter((p: any) => !!p && String(p).trim())
.join(', ');
this.bankRegNo = profile?.licenseNumber ? `RBI Lic. No. ${profile.licenseNumber}` : '';
this.bankContact = [
profile?.contactEmail,
profile?.contactPhone
]
.filter((p: any) => !!p && String(p).trim())
.join(' · ');
this.bankSealPrimary = (profile?.shortName || this.bankName).toUpperCase();
this.bankSealSecondary = profile?.bankType
? profile.bankType.replace(/_/g, ' ').toUpperCase() + (/BANK$/i.test(profile.bankType) ? '' : ' BANK')
: '';
// Every certificate PDF this dialog can build (Download button below, or the list page's
// own download icon opened without previewing first) reads the same shared identity, so
// set it here once the real profile is in — never leave a PDF carrying the placeholder.
setBankIdentity(profile || {});
},
// A missing/incomplete profile must not stop the certificate from rendering.
error: () => {}
});
}
get isInterest(): boolean {
return this.data.kind === 'interest';
@@ -17,8 +17,9 @@ import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
import { SharesService } from '../../../../shares/shares.service';
import { ClientsService } from '../../../../clients/clients.service';
import { OrganizationService } from '../../../../organization/organization.service';
import { ShareCertificateDialogComponent } from '../../../../shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component';
import { downloadShareCertificatePdf } from '../../certificate-pdf';
import { downloadShareCertificatePdf, setBankIdentity } from '../../certificate-pdf';
import { DscSignService } from '../../../dsc-sign-dialog/dsc-sign.service';
import type { ShareClass, ShareCertificateRecord } from '../share-certificate.component';
@@ -47,6 +48,7 @@ export class RealShareAccountsViewComponent implements OnInit {
private dialog = inject(MatDialog);
private sharesService = inject(SharesService);
private clientsService = inject(ClientsService);
private organizationService = inject(OrganizationService);
private dscSign = inject(DscSignService);
loading = true;
@@ -70,6 +72,14 @@ export class RealShareAccountsViewComponent implements OnInit {
ngOnInit(): void {
this.loadRealAccounts();
// The Download button below builds the PDF directly (no preview dialog in between), so this
// view must set the shared bank identity itself rather than relying on some other screen
// having opened first.
this.organizationService.getInstitutionProfile().subscribe({
next: (profile: any) => setBankIdentity(profile || {}),
error: () => {}
});
}
private loadRealAccounts(): void {
@@ -19,8 +19,9 @@ import {
CertificatePreviewDialogComponent,
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadShareCertificatePdf } from '../certificate-pdf';
import { downloadShareCertificatePdf, setBankIdentity } from '../certificate-pdf';
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
import { OrganizationService } from '../../../organization/organization.service';
import { RealShareAccountsViewComponent } from './real-share-accounts-view/real-share-accounts-view.component';
export type ShareClass = 'ORDINARY' | 'PREFERENCE';
@@ -62,6 +63,7 @@ export class ShareCertificateComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
private dscSign = inject(DscSignService);
private organizationService = inject(OrganizationService);
/** Toggles between the static sample table and the live m_share_account view. */
viewMode: 'demo' | 'real' = 'demo';
@@ -80,6 +82,13 @@ export class ShareCertificateComponent implements OnInit {
ngOnInit(): void {
this.records = seedShareCertificates();
// Download here builds the PDF directly, without necessarily opening the preview dialog first,
// so this list must set the shared bank identity itself.
this.organizationService.getInstitutionProfile().subscribe({
next: (profile: any) => setBankIdentity(profile || {}),
error: () => {}
});
}
// ── Derived list ──
@@ -275,9 +275,12 @@
<a [routerLink]="['/bank-calendar']" class="ip-reflink"
><i class="fas fa-calendar-check"></i> Holiday Calendar</a
>
<a [routerLink]="['/accounting/closing-entries']" class="ip-reflink"
><i class="fas fa-calendar-day"></i> Financial Year / Closing Entries</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]="['/branch-ops/day-register']" class="ip-reflink"
><i class="fas fa-clock"></i> Business Date (Day Register)</a
>
@@ -12,6 +12,7 @@ import { catchError, of } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { DISABLED_ACCOUNTING_FEATURES } from '../../accounting/disabled-features';
const BANK_TYPES = [
{ value: 'COOPERATIVE', label: 'Co-operative Bank' },
@@ -63,6 +64,8 @@ export class InstitutionProfileComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
/** Accounting features switched off for this installation — see accounting/disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
bankTypes = BANK_TYPES;
ownershipTypes = OWNERSHIP_TYPES;
licenseStatuses = LICENSE_STATUSES;
@@ -104,7 +104,8 @@ export class PlatformMenuAccessComponent {
'X-Api-Key': this.apiKey,
// Any real tenant satisfies the platform's tenant-resolution filter; the target tenant for
// read/write is this header's own value, so it is set to whichever tenant is actually in play.
'Fineract-Platform-TenantId': tenantForFilter || this.selectedTenant || (this.tenants[0]?.identifier ?? 'default')
'Fineract-Platform-TenantId':
tenantForFilter || this.selectedTenant || this.tenants[0]?.identifier || environment.fineractPlatformTenantId
};
}
@@ -126,7 +127,9 @@ export class PlatformMenuAccessComponent {
this.rememberKey();
this.loadingTenants = true;
try {
const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), { headers: this.headers('default') });
const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), {
headers: this.headers(environment.fineractPlatformTenantId)
});
if (!res.ok) {
throw new Error(
res.status === 403 || res.status === 401 ? 'Invalid or inactive API key.' : `Server error (${res.status}).`
@@ -110,8 +110,7 @@
[shareProductsTemplate]="shareProductsTemplate"
[accountingRuleData]="accountingRuleData"
[shareProduct]="shareProduct"
[taskPermission]="'CREATE_SHAREPRODUCT'"
(submitEvent)="submit()"
[showActions]="false"
></mifosx-share-product-preview-step>
</div>
</mat-tab>
@@ -250,11 +250,13 @@
</div>
</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>
@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>
}
@@ -52,6 +52,10 @@ export class ShareProductPreviewStepComponent {
@Input() accountingRuleData: any;
@Input() shareProduct: any;
@Input() taskPermission: string;
/** Set to false when the host page already renders its own Cancel/Submit bar for this step
* (the Create flow's shared bottom action bar) — Edit's stepper has no such bar of its own, so
* this defaults to true and keeps rendering the button there. */
@Input() showActions = true;
@Output() submitEvent = new EventEmitter();
marketPriceDisplayedColumns: string[] = [
@@ -356,6 +356,7 @@
&--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;
@@ -565,6 +566,7 @@
td {
padding: 11px 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;
@@ -601,6 +603,7 @@
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;
}
@@ -854,6 +857,7 @@
}
/* 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 {
@@ -900,7 +904,6 @@
color: var(--r-g500);
margin-top: 4px;
white-space: normal;
white-space: normal;
}
/* Where the rating should land, read straight off the Current Risk column beside it. */
@@ -91,16 +91,10 @@
[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,25 +585,30 @@
</div>
<!--
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.
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.
-->
@if (!hideButtons) {
<div class="preview-submit-row">
<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 mat-stroked-button (click)="previousEvent.emit()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | 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,17 +13,21 @@
padding: 0.5rem 0;
}
/* Submit + Cancel row at the bottom of the preview. Previous lives in the
parent wizard's tab action bar. */
/* Previous, Cancel & Submit row at the bottom of the preview. */
.preview-submit-row {
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid #f1f5f9;
&__right {
display: flex;
align-items: center;
gap: 12px;
}
button {
height: 42px !important;
border-radius: 10px !important;
@@ -116,6 +116,9 @@ export class SavingsAccountPreviewStepComponent implements OnChanges {
/** Form submission event */
@Output() submitEvent = new EventEmitter();
/** Goes back a tab in the parent wizard — kept here so Previous renders in the same row as
* Cancel/Submit instead of a separate action bar underneath. */
@Output() previousEvent = new EventEmitter();
ngOnChanges(): void {
if (this.activeClientMembers?.length > 0) {
@@ -21,8 +21,10 @@
</div>
<div class="certificate-header">
<div class="bank-name">Nexora Co-operative Bank Ltd.</div>
<div class="bank-sub">Mumbai, Maharashtra, India</div>
<div class="bank-name">{{ bankName }}</div>
@if (bankLocation) {
<div class="bank-sub">{{ bankLocation }}</div>
}
<div class="cert-title">SHARE CERTIFICATE</div>
<div class="cert-subtitle">Issued under the Common Seal of the Bank</div>
</div>
@@ -51,8 +53,8 @@
<strong class="highlight-text">{{
sharesAccountData.currentMarketPrice | currency: sharesAccountData.currency.code : 'symbol-narrow'
}}</strong>
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.
each, fully paid up, in <strong class="highlight-text">{{ bankName }}</strong
>, subject to the memorandum, articles of association, and bye-laws of the bank.
</p>
<div class="issued-to-details">
@@ -75,7 +77,7 @@
<tbody>
<tr>
<td class="lbl-col">Branch</td>
<td class="val-col">{{ clientData?.officeName || 'Mumbai Main Branch' }}</td>
<td class="val-col">{{ clientData?.officeName || 'Head Office' }}</td>
<td class="lbl-col">Member Since</td>
<td class="val-col">
{{
@@ -140,6 +140,8 @@
}
.meta-value {
font-family: Arial, Helvetica, sans-serif;
font-variant-numeric: tabular-nums;
font-size: 15px;
font-weight: bold;
color: #322f2b;
@@ -226,12 +228,19 @@
.lbl-col {
background-color: #f7f5ee;
font-family: Georgia, 'Times New Roman', Times, serif;
font-weight: bold;
color: #7b6f63;
width: 25%;
}
// Every figure a member actually needs to read at a glance — share counts, amounts, dates, the
// resolution number — sits in this column. Georgia's numerals kept blurring into each other at
// this size, so values get a plain sans-serif with tabular figures instead; the label column
// keeps the certificate's own serif since it's static text, never a number to misread.
.val-col {
font-family: Arial, Helvetica, sans-serif;
font-variant-numeric: tabular-nums;
color: #322f2b;
width: 25%;
}
@@ -242,6 +251,7 @@
}
.lbl-col-highlight {
font-family: Georgia, 'Times New Roman', Times, serif;
font-weight: bold;
color: #0b3c5d;
font-size: 14px;
@@ -251,6 +261,8 @@
}
.val-col-highlight {
font-family: Arial, Helvetica, sans-serif;
font-variant-numeric: tabular-nums;
font-size: 16px;
font-weight: bold;
color: #b8860b; /* Dark Goldenrod */
@@ -270,6 +282,7 @@
padding: 8px 12px;
border: 1px solid #ede8db;
background-color: #f7f5ee;
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 11px;
font-weight: bold;
color: #7b6f63;
@@ -277,9 +290,14 @@
letter-spacing: 0.6px;
}
// Same reasoning as .val-col above: every cell here is a figure someone has to check against the
// register, so it reads in a plain sans-serif with tabular numerals rather than the certificate's
// decorative serif.
td {
padding: 9px 12px;
border: 1px solid #ede8db;
font-family: Arial, Helvetica, sans-serif;
font-variant-numeric: tabular-nums;
font-size: 13px;
color: #322f2b;
}
@@ -16,6 +16,7 @@ import {
MatDialogClose
} from '@angular/material/dialog';
import { ClientsService } from 'app/clients/clients.service';
import { OrganizationService } from 'app/organization/organization.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
@@ -44,6 +45,7 @@ export class ShareCertificateDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<ShareCertificateDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
clientsService = inject(ClientsService);
private organizationService = inject(OrganizationService);
sharesAccountData: any;
clientId: string;
@@ -53,6 +55,11 @@ export class ShareCertificateDialogComponent implements OnInit {
panNumber: string = 'Not Available';
memberId: string = '';
/** The issuing bank's own name/location, as set on the Institution Profile — never a fixed name,
* since this certificate template is shared by every tenant this app runs for. */
bankName: string = 'Bank';
bankLocation: string = '';
certificateNo: string = '';
folioNo: string = '';
shareNumbersRange: string = '';
@@ -155,6 +162,24 @@ export class ShareCertificateDialogComponent implements OnInit {
},
error: () => {}
});
// Fetch the issuing bank's own name/location from the Institution Profile, set once under
// Admin > Organization for whichever bank this instance runs for — never a fixed name baked
// into the certificate itself.
this.organizationService.getInstitutionProfile().subscribe({
next: (profile: any) => {
this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName;
this.bankLocation = [
profile?.city,
profile?.state,
profile?.country
]
.filter((p) => !!p && String(p).trim())
.join(', ');
},
// A missing/incomplete profile must not stop the certificate from rendering.
error: () => {}
});
}
/**
@@ -149,6 +149,13 @@ export class SharesAccountViewComponent implements OnInit {
// capital that was never issued.
if (!this.isSettled) {
this.buttonConfig.removeOption('Share Certificate');
} else {
// "Apply Collection" settles the account's ORIGINAL application funding -- a one-time
// step. Once collectionApplied is true there is nothing left to settle this way; any
// further share purchases on this account go through "Apply Additional Shares" instead,
// which raises and settles its own tranche. Leaving this button up after settlement invited
// reopening the dialog against an account with no unapplied receipt to find.
this.buttonConfig.removeButton('Apply Collection');
}
}
}
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, AfterViewInit, ViewChild, inject, Inject } from '@angular/core';
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';
@@ -308,8 +308,8 @@ export class DdRevokeApprovalsComponent implements OnInit, AfterViewInit {
<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.
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">
@@ -372,8 +372,7 @@ export class DdRevokeApprovalsComponent implements OnInit, AfterViewInit {
})
export class ApproveRevokeRequestDialogComponent {
dialogRef = inject(MatDialogRef<ApproveRevokeRequestDialogComponent>);
constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {}
data = inject<{ request: any }>(MAT_DIALOG_DATA);
}
// ── REJECT DIALOG ──
@@ -393,8 +392,8 @@ export class ApproveRevokeRequestDialogComponent {
<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.
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">
@@ -406,7 +405,13 @@ export class ApproveRevokeRequestDialogComponent {
</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()">
<button
mat-flat-button
color="warn"
type="button"
[disabled]="rejectForm.invalid || loading"
(click)="onSubmit()"
>
Reject
</button>
</mat-dialog-actions>
@@ -430,13 +435,17 @@ export class RejectRevokeRequestDialogComponent {
private sponsorDDService = inject(SponsorDDService);
private snackBar = inject(MatSnackBar);
dialogRef = inject(MatDialogRef<RejectRevokeRequestDialogComponent>);
data = inject<{ request: any }>(MAT_DIALOG_DATA);
rejectForm: FormGroup;
loading = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {
constructor() {
this.rejectForm = this.fb.group({
rejectionReason: ['', Validators.required]
rejectionReason: [
'',
Validators.required
]
});
}
@@ -206,7 +206,9 @@ export class GlSettingsDialogComponent implements OnInit {
loadGlAccountsAndMapping(): void {
this.loading = true;
this.http.get<any[]>('/glaccounts?manualEntriesAllowed=true&disabled=false').subscribe({
// usage=1 (Detail only): these settings map real Sponsor DD transactions onto a GL account, and
// a Header account can never receive one, so it must never be offered here.
this.http.get<any[]>('/glaccounts?manualEntriesAllowed=true&disabled=false&usage=1').subscribe({
next: (accounts) => {
this.glAccounts = accounts || [];
this.sponsorDDService.getGlMappings().subscribe({
+2 -2
View File
@@ -20,8 +20,8 @@
// Tenants the login screen offers, and which one it starts on. Every identifier here has to exist
// in the tenants table of the tenant-store database, or the platform rejects the request as an
// unknown tenant.
window["env"]["fineractPlatformTenantId"] = 'default';
window["env"]["fineractPlatformTenantIds"] = 'default,abc_bank,socity_bank';
window["env"]["fineractPlatformTenantId"] = 'lincpay';
window["env"]["fineractPlatformTenantIds"] = 'lincpay';
window['env']['tenantLogoUrl'] = '';
window['env']['tenantLogoUrlDark'] = '';
+1 -1
View File
@@ -10,7 +10,7 @@
export default {
'mifos_x': {
'version': '260914',
'hash': '7afa9c7a'
'hash': 'ee5a430b'
},
'allow_switching_backend_instance': true
};
+2 -2
View File
@@ -19,8 +19,8 @@ export const environment = {
version: env.mifos_x.version,
hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier
fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'default',
fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'default',
fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'lincpay',
fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'lincpay',
// For connecting to others servers running elsewhere update the base API URL
baseApiUrls:
loadedEnv['fineractApiUrls'] || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,
+2 -2
View File
@@ -22,8 +22,8 @@ export const environment = {
version: env.mifos_x.version,
hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier
fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'default',
fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'default',
fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'lincpay',
fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'lincpay',
// For connecting to others servers running elsewhere update the base API URL
baseApiUrls:
loadedEnv.fineractApiUrls || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,
+36 -1
View File
@@ -978,9 +978,18 @@ mat-card {
mifosx-breadcrumb,
mifosx-footer,
.sidebar-panel,
.sidebar-handle,
.mat-mdc-tab-header,
.mat-stepper-vertical-line,
.mat-step-header {
.mat-step-header,
/* The real 2-row nav menu (Client Master / Approvals / Cashier … and the row below it) and the
live KPI ticker live directly in shell.component.html as .main-header / .kpi-ticker, not
inside <mifosx-toolbar> — hiding only the toolbar left this menu printing on every page. */
.main-header,
.kpi-ticker,
.cashier-nav,
.gl-backdrop,
.gl-mega-menu {
display: none !important;
}
@@ -988,6 +997,32 @@ mat-card {
margin-left: 0 !important;
}
/* The whole app shell scrolls inside CDK's own clipped viewport —
.mat-drawer-container { overflow: hidden } and .mat-drawer-content { overflow: auto; height: 100% }
— so without freeing these too, print is still capped to one screen's worth of the page
regardless of the tab-body fix below. */
#mifosx-shell-container,
.mat-drawer-container,
.mat-drawer-content,
mat-sidenav-content.sidenav {
position: static !important;
overflow: visible !important;
height: auto !important;
}
/* Angular Material clips a tab's content to a JS-measured on-screen height via
`.mat-mdc-tab-body-wrapper { overflow: hidden }` (for the tab-switch height animation) and
`.mat-mdc-tab-body-content { height: 100%; overflow: auto }` — printing only what fit that
clip left every account/tab page (Shares Account General included) blank apart from a
stray border. Printing needs the full content, not the animated viewport. */
.mat-mdc-tab-body-wrapper,
.mat-mdc-tab-body,
.mat-mdc-tab-body-content {
position: static !important;
overflow: visible !important;
height: auto !important;
}
body,
html {
background: #fff !important;