Compare commits

..
Author SHA1 Message Date
Sonali Likhitkar f499a6b57e product creation changes and preview changes 2026-09-01 10:46:36 +05:30
Sonali LikhitkarandClaude Fable 5 f0b4e98e71 feat(loan-products): extend loan product stepper with legacy account-type fields
Add UI-only fields across the details, settings, charges, terms, and
accounting steps to mirror the legacy Account Type Creation screen
(classification, cheque book, interest, and additional settings).
These are stripped from the create/edit payload via
CUSTOM_UI_ONLY_FIELDS until a backend mechanism (e.g. a linked data
table) exists to persist them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 16:27:36 +05:30
984 changed files with 16461 additions and 144855 deletions
+7
View File
@@ -69,3 +69,10 @@ src/environments/.env.ts
/playwright-report/
/blob-report/
/playwright/.cache/
# JVM crash / replay logs
hs_err_pid*.log
replay_pid*.log
# Microsoft Office temporary lock files
~$*.xlsx
+7 -27
View File
@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1
###############
### STAGE 1: Build app
###############
@@ -10,13 +9,10 @@ ARG NPM_REGISTRY_URL=https://registry.npmjs.org/
ARG BUILD_ENVIRONMENT_OPTIONS="--configuration production"
ARG PUPPETEER_DOWNLOAD_HOST_ARG=https://storage.googleapis.com
ARG PUPPETEER_CHROMIUM_REVISION_ARG=1011831
# Build ko Chromium nahi chahiye — skip (warna npm ci Chromium download par atak jata hai).
ARG PUPPETEER_SKIP_DOWNLOAD_ARG=true
ARG PUPPETEER_SKIP_DOWNLOAD_ARG
# Set the environment variable to increase Node.js memory limit
# (Mifos web-app ka production build memory-heavy hai; upstream build:prod
# 16384 use karta hai. 4096 par 'ng build' OOM ho jata hai.)
ENV NODE_OPTIONS="--max-old-space-size=8192"
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN apk add --no-cache git
@@ -29,22 +25,14 @@ ENV PUPPETEER_DOWNLOAD_HOST=$PUPPETEER_DOWNLOAD_HOST_ARG
ENV PUPPETEER_CHROMIUM_REVISION=$PUPPETEER_CHROMIUM_REVISION_ARG
ENV PUPPETEER_SKIP_DOWNLOAD=$PUPPETEER_SKIP_DOWNLOAD_ARG
# Test/e2e tooling ke bhaari binary downloads skip karo — build inhe use nahi
# karta, aur inke bina npm ci turant complete ho jata hai.
# Cypress -> ~250MB binary (CI me sabse zyada atakta hai)
# Playwright-> browser binaries
ENV CYPRESS_INSTALL_BINARY=0
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
COPY ./ /usr/src/app/
RUN npm config set fetch-retry-maxtimeout 120000 \
&& npm config set registry $NPM_REGISTRY_URL --location=global
RUN npm cache clear --force
# BuildKit cache mount: npm ka download cache builds ke beech persist hota hai,
# isliye har build par saare packages dobara download nahi hote (bahut fast).
# 'npm cache clear --force' hata diya — wo har build par cache wipe kar deta tha.
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
RUN npm config set fetch-retry-maxtimeout 120000
RUN npm config set registry $NPM_REGISTRY_URL --location=global
RUN npm ci
RUN sh -c "ng build --output-path=/dist $BUILD_ENVIRONMENT_OPTIONS"
@@ -53,14 +41,6 @@ RUN sh -c "ng build --output-path=/dist $BUILD_ENVIRONMENT_OPTIONS"
###############
FROM $NGINX_IMAGE
# envsubst (gettext) alpine-slim me nahi hota — runtime env.js generation ke
# liye zaroori hai, isliye explicitly install kar rahe hain.
RUN apk add --no-cache gettext
# Static-only nginx config (SPA fallback, no API proxy). Stock default.conf
# ko replace karta hai taaki deep-link refresh par 404 na aaye.
COPY nginx.spa.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /dist/browser /usr/share/nginx/html
EXPOSE 80
+2 -9
View File
@@ -31,8 +31,6 @@
"@angular/localize/init"
],
"assets": [
"src/favicon.ico",
"src/apple-touch-icon.png",
"src/assets/images/cbsnexora-favicon.svg",
"src/robots.txt",
"src/manifest.json",
@@ -41,11 +39,6 @@
"glob": "**/*",
"input": "node_modules/tinymce",
"output": "assets/tinymce"
},
{
"glob": "lottie.min.js",
"input": "node_modules/lottie-web/build/player",
"output": "assets/js"
}
],
"styles": [
@@ -76,8 +69,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "18mb",
"maximumError": "20mb"
"maximumWarning": "15mb",
"maximumError": "17mb"
},
{
"type": "anyComponentStyle",
-45
View File
@@ -1,45 +0,0 @@
# Static-only nginx config for the CBSNexora Angular UI.
# NOTE: No API reverse-proxy here by design — the browser calls the Fineract
# backend directly via FINERACT_API_URL (env.js). nginx sirf static files
# serve karta hai + Angular SPA routing handle karta hai.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ---- Compression ----
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css application/javascript application/json
application/xml image/svg+xml font/woff font/woff2;
# ---- Runtime env: env.js aur index.html kabhi cache na ho ----
# (env.js har container start par envsubst se regenerate hoti hai;
# index.html hashed asset filenames point karti hai)
location = /assets/env.js {
add_header Cache-Control "no-store, no-cache, must-revalidate";
expires -1;
}
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
expires -1;
}
# ---- Hashed static assets: lambe time cache ----
location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public";
try_files $uri =404;
}
# ---- Angular SPA fallback: har route index.html par ----
location / {
try_files $uri $uri/ /index.html;
}
}
-7
View File
@@ -41,7 +41,6 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
@@ -18381,12 +18380,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+2 -4
View File
@@ -15,7 +15,7 @@
"ng": "ng",
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" ng serve --proxy-config proxy.localhost.conf.js",
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.localhost.conf.js",
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
@@ -36,8 +36,7 @@
"playwright:headed": "playwright test --headed",
"playwright:debug": "playwright test --debug",
"headers:check": "node scripts/check-file-headers.js",
"headers:add": "node scripts/add-file-headers.js",
"mock:dip": "node \"../cbsnexora-backend/scripts/mock-dip-lookup-service.js\""
"headers:add": "node scripts/add-file-headers.js"
},
"dependencies": {
"@angular/animations": "20.3.16",
@@ -71,7 +70,6 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
@@ -7,6 +7,16 @@
-->
<div class="container">
<div class="container m-b-20 align-end gap-2px">
@if (!viewAccountTransferData.reversed) {
<span>
<button mat-raised-button color="warn" *mifosxHasPermission="'ADJUST_ACCOUNTTRANSFER'">
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
</button>
</span>
}
</div>
<mat-card>
<mat-card-content>
<div class="transfer-container">
@@ -25,7 +25,6 @@ import { CreateFinancialActivityMappingComponent } from './financial-activity-ma
import { ViewFinancialActivityMappingComponent } from './financial-activity-mappings/view-financial-activity-mapping/view-financial-activity-mapping.component';
import { EditFinancialActivityMappingComponent } from './financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component';
import { MigrateOpeningBalancesComponent } from './migrate-opening-balances/migrate-opening-balances.component';
import { MigrateCashDenominationComponent } from './migrate-cash-denomination/migrate-cash-denomination.component';
import { ChartOfAccountsComponent } from './chart-of-accounts/chart-of-accounts.component';
import { CreateGlAccountComponent } from './chart-of-accounts/create-gl-account/create-gl-account.component';
import { ViewGlAccountComponent } from './chart-of-accounts/view-gl-account/view-gl-account.component';
@@ -72,7 +71,6 @@ import { ViewJournalEntryTransactionComponent } from 'app/shared/accounting/view
import { JournalEntryTransactionResolver } from './common-resolvers/journal-entry-transaction.resolver';
import { ExternalAssetOwnerJournalEntryResolver } from 'app/loans/common-resolvers/external-asset-owner-journal-entry.resolver';
import { ExternalAssetConfigurationResolver } from './common-resolvers/external-asset-configuration.resolver';
import { accountingFeatureEnabledGuard } from './disabled-features';
/** Accounting Routes */
const routes: Routes = [
@@ -100,8 +98,7 @@ const routes: Routes = [
{
path: 'frequent-postings',
component: FrequentPostingsComponent,
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Frequent Postings', breadcrumb: 'Frequent Postings', feature: 'frequentPostings' },
data: { title: 'Frequent Postings', breadcrumb: 'Frequent Postings' },
resolve: {
offices: OfficesResolver,
accountingRules: AccountingRulesAssociationsResolver,
@@ -112,8 +109,7 @@ const routes: Routes = [
{
path: 'create',
component: CreateJournalEntryComponent,
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Create Journal Entry', breadcrumb: 'Create', feature: 'createJournalEntries' },
data: { title: 'Create Journal Entry', breadcrumb: 'Create' },
resolve: {
offices: OfficesResolver,
currencies: CurrenciesResolver,
@@ -189,19 +185,6 @@ const routes: Routes = [
currencies: CurrenciesResolver
}
},
{
path: 'migrate-cash-denomination',
component: MigrateCashDenominationComponent,
data: { title: 'Migrate Cash Denomination', breadcrumb: 'Migrate Cash Denomination' },
resolve: {
offices: OfficesResolver
}
},
{
path: 'balance-sheet',
loadComponent: () => import('./balance-sheet/balance-sheet.component').then((c) => c.BalanceSheetComponent),
data: { title: 'Balance Sheet', breadcrumb: 'Balance Sheet' }
},
{
path: 'chart-of-accounts',
data: { title: 'Chart of Accounts', breadcrumb: 'Chart of Accounts' },
@@ -252,8 +235,7 @@ const routes: Routes = [
},
{
path: 'closing-entries',
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Accounting Closures', breadcrumb: 'Closing Entries', feature: 'closingEntries' },
data: { title: 'Accounting Closures', breadcrumb: 'Closing Entries' },
children: [
{
path: '',
@@ -294,8 +276,7 @@ const routes: Routes = [
},
{
path: 'accounting-rules',
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Accounting Rules', breadcrumb: 'Accounting Rules', feature: 'accountingRules' },
data: { title: 'Accounting Rules', breadcrumb: 'Accounting Rules' },
children: [
{
path: '',
@@ -342,17 +323,11 @@ const routes: Routes = [
{
path: 'periodic-accruals',
component: PeriodicAccrualsComponent,
canActivate: [accountingFeatureEnabledGuard],
data: {
title: 'Periodic Accrual Accounting',
breadcrumb: 'Execute Periodic Accrual Accounting',
feature: 'accruals'
}
data: { title: 'Periodic Accrual Accounting', breadcrumb: 'Execute Periodic Accrual Accounting' }
},
{
path: 'provisioning-entries',
canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Provisioning Entries', breadcrumb: 'Provisioning Entries', feature: 'provisioningEntries' },
data: { title: 'Provisioning Entries', breadcrumb: 'Provisioning Entries' },
children: [
{
path: '',
+59 -100
View File
@@ -17,39 +17,35 @@
<!-- Cards Grid -->
<div class="acc-grid">
<!-- Frequent Postings — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.frequentPostings) {
<a
class="acc-card"
[routerLink]="['journal-entries/frequent-postings']"
*mifosxHasPermission="'CREATE_JOURNALENTRY'"
#frequentPostings
>
<div class="acc-card__icon blue"><i class="fas fa-sync-alt"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Frequent Postings' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.These are predefined postings' | translate }}</span>
</div>
</a>
}
<!-- Frequent Postings -->
<a
class="acc-card"
[routerLink]="['journal-entries/frequent-postings']"
*mifosxHasPermission="'CREATE_JOURNALENTRY'"
#frequentPostings
>
<div class="acc-card__icon blue"><i class="fas fa-sync-alt"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Frequent Postings' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.These are predefined postings' | translate }}</span>
</div>
</a>
<!-- Create Journal Entries — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.createJournalEntries) {
<a
class="acc-card"
[routerLink]="['journal-entries/create']"
*mifosxHasPermission="'CREATE_JOURNALENTRY'"
#createJournalEntries
>
<div class="acc-card__icon green"><i class="fas fa-plus-circle"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Create Journal Entries' | translate }}</span>
<span class="acc-card__desc">{{
'labels.text.Manual journal entry transactions recorded in a journal' | translate
}}</span>
</div>
</a>
}
<!-- Create Journal Entries -->
<a
class="acc-card"
[routerLink]="['journal-entries/create']"
*mifosxHasPermission="'CREATE_JOURNALENTRY'"
#createJournalEntries
>
<div class="acc-card__icon green"><i class="fas fa-plus-circle"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Create Journal Entries' | translate }}</span>
<span class="acc-card__desc">{{
'labels.text.Manual journal entry transactions recorded in a journal' | translate
}}</span>
</div>
</a>
<!-- Search Journal Entries -->
<a class="acc-card" [routerLink]="['journal-entries']" *mifosxHasPermission="'READ_JOURNALENTRY'">
@@ -92,22 +88,6 @@
</div>
</a>
<!-- Migrate Cash Denomination -->
<a
class="acc-card"
[routerLink]="['migrate-cash-denomination']"
*mifosxHasPermission="'READ_JOURNALENTRY'"
#migrateCashDenomination
>
<div class="acc-card__icon amber"><i class="fas fa-coins"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">Migrate Cash Denomination</span>
<span class="acc-card__desc">
Count the vault opening cash into notes and coins, and open the first business day
</span>
</div>
</a>
<!-- Chart of Accounts -->
<a class="acc-card" [routerLink]="['chart-of-accounts']" *mifosxHasPermission="'READ_GLACCOUNT'" #chartofAccounts>
<div class="acc-card__icon blue"><i class="fas fa-sitemap"></i></div>
@@ -117,64 +97,43 @@
</div>
</a>
<!-- Balance Sheet -->
<a class="acc-card" [routerLink]="['balance-sheet']" *mifosxHasPermission="'READ_GLACCOUNT'" #balanceSheet>
<div class="acc-card__icon teal"><i class="fas fa-balance-scale"></i></div>
<!-- Closing Entries -->
<a class="acc-card" [routerLink]="['closing-entries']" *mifosxHasPermission="'READ_GLCLOSURE'" #closingEntries>
<div class="acc-card__icon red"><i class="fas fa-archive"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">Balance Sheet</span>
<span class="acc-card__desc">Header/Detail balances as on any date</span>
<span class="acc-card__title">{{ 'labels.heading.Closing Entries' | translate }}</span>
<span class="acc-card__desc">{{
'labels.text.Journal entries made at the end of accounting period' | translate
}}</span>
</div>
</a>
<!-- Closing Entries — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.closingEntries) {
<a class="acc-card" [routerLink]="['closing-entries']" *mifosxHasPermission="'READ_GLCLOSURE'" #closingEntries>
<div class="acc-card__icon red"><i class="fas fa-archive"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Closing Entries' | translate }}</span>
<span class="acc-card__desc">{{
'labels.text.Journal entries made at the end of accounting period' | translate
}}</span>
</div>
</a>
}
<!-- Accounting Rules -->
<a class="acc-card" [routerLink]="['accounting-rules']" *mifosxHasPermission="'READ_ACCOUNTINGRULE'">
<div class="acc-card__icon purple"><i class="fas fa-list-ul"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Accounting Rules' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.List of all accounting rules' | translate }}</span>
</div>
</a>
<!-- Accounting Rules — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.accountingRules) {
<a class="acc-card" [routerLink]="['accounting-rules']" *mifosxHasPermission="'READ_ACCOUNTINGRULE'">
<div class="acc-card__icon purple"><i class="fas fa-list-ul"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Accounting Rules' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.List of all accounting rules' | translate }}</span>
</div>
</a>
}
<!-- Accruals -->
<a class="acc-card" [routerLink]="['periodic-accruals']" *mifosxHasPermission="'EXECUTE_PERIODICACCRUALACCOUNTING'">
<div class="acc-card__icon green"><i class="fas fa-calendar-check"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Accruals' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.Accrues income, expenses and liabilities' | translate }}</span>
</div>
</a>
<!-- Accruals — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.accruals) {
<a
class="acc-card"
[routerLink]="['periodic-accruals']"
*mifosxHasPermission="'EXECUTE_PERIODICACCRUALACCOUNTING'"
>
<div class="acc-card__icon green"><i class="fas fa-calendar-check"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Accruals' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.Accrues income, expenses and liabilities' | translate }}</span>
</div>
</a>
}
<!-- Provisioning Entries — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.provisioningEntries) {
<a class="acc-card" [routerLink]="['provisioning-entries']" *mifosxHasPermission="'VIEW_PROVISIONING_ENTRIES'">
<div class="acc-card__icon amber"><i class="fas fa-cogs"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Provisioning Entries' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.Create provisioning entries' | translate }}</span>
</div>
</a>
}
<!-- Provisioning Entries -->
<a class="acc-card" [routerLink]="['provisioning-entries']" *mifosxHasPermission="'VIEW_PROVISIONING_ENTRIES'">
<div class="acc-card__icon amber"><i class="fas fa-cogs"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Provisioning Entries' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.Create provisioning entries' | translate }}</span>
</div>
</a>
</div>
</div>
+2 -9
View File
@@ -18,7 +18,6 @@ import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DISABLED_ACCOUNTING_FEATURES } from './disabled-features';
/**
* Accounting component.
@@ -64,9 +63,6 @@ export class AccountingComponent implements AfterViewInit {
// Initialize an array of 10 boolean values, all set to false
arrowBooleans: boolean[] = new Array(10).fill(false);
/** Which accounting tiles are switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
/**
* Popover function
* @param template TemplateRef<any>.
@@ -108,15 +104,12 @@ export class AccountingComponent implements AfterViewInit {
);
});
}
// Closing Entries and Create Journal Entries can be switched off (see disabled-features.ts), in
// which case their tiles never render and these refs stay undefined — the wizard simply has no
// step to point at, rather than throwing on a missing element.
if (this.configurationWizardService.showClosingEntries && this.closingEntries) {
if (this.configurationWizardService.showClosingEntries) {
setTimeout(() => {
this.showPopover(this.templateClosingEntries, this.closingEntries.nativeElement, 'bottom', true);
});
}
if (this.configurationWizardService.showCreateJournalEntries && this.createJournalEntries) {
if (this.configurationWizardService.showCreateJournalEntries) {
setTimeout(() => {
this.showPopover(this.templateCreateJournalEntries, this.createJournalEntries.nativeElement, 'bottom', true);
});
-36
View File
@@ -44,26 +44,6 @@ export class AccountingService {
return this.http.get('/accounting/profitandloss', { params: httpParams });
}
/**
* How much profit a share dividend may actually be declared out of — net profit reduced by the
* statutory appropriations that legally precede a dividend (Statutory Reserve Fund, education
* fund) and by dividends already declared for other share products over an overlapping period.
* @param {string} fromDate Period start, yyyy-MM-dd.
* @param {string} toDate Period end, yyyy-MM-dd.
* @param {string} excludeProductId Leave this product's own declarations out of `alreadyDeclared`
* (its overlapping declaration is separately blocked outright, so counting it would double up).
* @returns {Observable<any>} `{ netProfit, statutoryReserveAmount, educationFundAmount,
* distributableProfit, alreadyDeclared, availableForDeclaration, maximumRatePercentage,
* existingDeclarations[] }`.
*/
getDistributableProfit(fromDate: string, toDate: string, excludeProductId?: string): Observable<any> {
let httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
if (excludeProductId) {
httpParams = httpParams.set('excludeProductId', excludeProductId);
}
return this.http.get('/accounting/distributable-profit', { params: httpParams });
}
/**
* Total share dividend actually paid to members in a date range — derived from the Dividend
* Payable GL account's own ledger (no separate "Dividend Paid" account needed).
@@ -116,22 +96,6 @@ export class AccountingService {
return this.http.get(`/glaccounts`, { params: httpParams });
}
/**
* As-on-date balance sheet, grouped by Header/Detail hierarchy — see BalanceSheetApiResource.
* @param asOnDate yyyy-MM-dd. Defaults to today on the backend when omitted.
* @param officeId Optional — scopes every balance to one office instead of the whole organization.
*/
getBalanceSheet(asOnDate?: string, officeId?: number): Observable<any> {
let httpParams = new HttpParams();
if (asOnDate) {
httpParams = httpParams.set('asOnDate', asOnDate);
}
if (officeId != null) {
httpParams = httpParams.set('officeId', officeId.toString());
}
return this.http.get('/accounting/balancesheet', { params: httpParams });
}
/**
* @param transactionId Transaction ID of journal entries.
* @returns {Observable<any>} Journal Entries.
@@ -1,363 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/*
* Builds the Consolidated Balance Sheet PDF, laid out like the bank's printed report: liabilities
* on the left, assets on the right, each top-level group a named block with its accounts listed
* under it and a subtotal in the TOTAL column.
*
* Drawn directly with jsPDF rather than printed from the DOM: window.print() carries the whole app
* shell (sidenav, toolbar, ticker) into the output and gives no control over where a page breaks,
* which is what made the printed sheet unreadable. Here each column is paginated on its own, the
* way the printed report does it — the left side can run onto page 2 while the right side is still
* on page 1 — and every page repeats the heading.
*/
import { jsPDF } from 'jspdf';
export interface BalanceSheetPdfNode {
name: string;
glCode: string;
usage: 'HEADER' | 'DETAIL';
balance: number;
children: BalanceSheetPdfNode[];
}
export interface BalanceSheetPdfSide {
label: string;
total: number;
groups: { classification: string; total: number; nodes: BalanceSheetPdfNode[] }[];
}
export interface BalanceSheetPdfInput {
bankName: string;
officeLabel: string;
/** yyyy-MM-dd */
asOnDate: string;
creditSide: BalanceSheetPdfSide;
debitSide: BalanceSheetPdfSide;
}
// ── Page geometry, in mm on A4 portrait ─────────────────────────────────────
const PAGE_W = 210;
const PAGE_H = 297;
const MARGIN = 8;
const COL_W = (PAGE_W - MARGIN * 2) / 2;
const COL_X = [
MARGIN,
MARGIN + COL_W
];
/** Right edges of the BALANCE and TOTAL columns, relative to a column's left edge. */
const BALANCE_R = COL_W - 26;
const TOTAL_R = COL_W - 2;
/** Name text wraps within this width so it never runs under the BALANCE column. */
const NAME_W = COL_W - 30;
const BODY_TOP = 36;
/** Leaves room under the body for the sign-off row and the print-date footer. */
const BODY_BOTTOM = PAGE_H - 21;
const LINE_H = 3.1;
const INK: [number, number, number] = [
20,
20,
20
];
const MUTED: [number, number, number] = [
90,
90,
90
];
type Line =
| { kind: 'block'; text: string }
| { kind: 'subhead'; text: string; depth: number }
| { kind: 'detail'; lines: string[]; amount: string; depth: number }
| { kind: 'subsubtotal'; amount: string }
| { kind: 'subtotal'; amount: string }
| { kind: 'standalone'; lines: string[]; amount: string }
| { kind: 'grand'; amount: string }
| { kind: 'gap' };
function money(value: number): string {
return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0);
}
function ddmmyyyy(iso: string): string {
if (!iso) {
return '';
}
const [
y,
m,
d
] = iso.split('-');
return `${d}/${m}/${y}`;
}
function heightOf(line: Line): number {
switch (line.kind) {
case 'block':
// Heading plus the short dashed rule beneath it.
return LINE_H + 1.8;
case 'detail':
case 'standalone':
return LINE_H * line.lines.length;
case 'gap':
return 2.4;
case 'grand':
// Rule above, the figure, and the double rule beneath it.
return LINE_H + 6;
default:
return LINE_H;
}
}
/**
* Flattens one side into the exact sequence of printed lines. Done up front so pagination is a
* matter of measuring heights, with no recursion happening mid-page.
*/
function flatten(doc: jsPDF, side: BalanceSheetPdfSide): Line[] {
const out: Line[] = [];
// splitTextToSize measures with whatever font is currently set, so it has to match the font the
// detail lines are actually drawn in — otherwise names wrap at the wrong width.
doc.setFont('helvetica', 'normal').setFontSize(6.8);
const pushNode = (node: BalanceSheetPdfNode, depth: number): void => {
if (node.usage === 'DETAIL') {
const indent = depth * 2;
const wrapped: string[] = doc.splitTextToSize(node.name, NAME_W - indent);
out.push({ kind: 'detail', lines: wrapped, amount: money(node.balance), depth });
return;
}
out.push({ kind: 'subhead', text: node.name, depth });
node.children.forEach((child) => pushNode(child, depth + 1));
out.push({ kind: 'subsubtotal', amount: money(node.balance) });
};
for (const group of side.groups) {
for (const root of group.nodes) {
if (root.usage === 'DETAIL') {
// A Detail account sitting under no group at all: a group of one, printed on a single line
// carrying both its balance and its total, rather than a heading and a figure on separate
// rows with the BALANCE column left empty.
out.push({
kind: 'standalone',
lines: doc.splitTextToSize(root.name.toUpperCase(), NAME_W),
amount: money(root.balance)
});
out.push({ kind: 'gap' });
continue;
}
out.push({ kind: 'block', text: root.name });
root.children.forEach((child) => pushNode(child, 0));
out.push({ kind: 'subtotal', amount: money(root.balance) });
out.push({ kind: 'gap' });
}
}
// Carried as a line of its own so pagination reserves room for it, rather than squeezing it
// against the bottom margin under the last block.
out.push({ kind: 'grand', amount: money(side.total) });
return out;
}
function dottedLine(doc: jsPDF, x1: number, y: number, x2: number): void {
doc.setLineDashPattern(
[
0.4,
0.6
],
0
);
doc.setLineWidth(0.15);
doc.line(x1, y, x2, y);
doc.setLineDashPattern([], 0);
}
function drawPageFrame(doc: jsPDF, input: BalanceSheetPdfInput, page: number, pageCount: number): void {
doc.setTextColor(...INK);
doc.setFont('helvetica', 'bold').setFontSize(9.5);
doc.text(`${input.bankName.toUpperCase()} , HEAD OFFICE`, PAGE_W / 2, 11, { align: 'center' });
doc.setFontSize(8);
doc.text(`CONSOLIDATED BALANCE SHEET (SUB GROUP WISE) AS ON : ${ddmmyyyy(input.asOnDate)}`, PAGE_W / 2, 16, {
align: 'center'
});
doc.setFont('helvetica', 'normal').setFontSize(7);
doc.text(`Page ${page} of ${pageCount}`, PAGE_W - MARGIN, 16, { align: 'right' });
doc.text(`FOR BRANCHES : ${input.officeLabel}`, PAGE_W / 2, 20, { align: 'center' });
dottedLine(doc, MARGIN, 23, PAGE_W - MARGIN);
// Side titles and column headings, drawn per column.
const titles = [
'LIABILITIES ( CREDIT )',
'ASSETS ( DEBIT )'
];
for (let c = 0; c < 2; c++) {
const x = COL_X[c];
doc.setFont('helvetica', 'bold').setFontSize(7.5);
doc.text(titles[c], x + 1, 27.5);
doc.setFontSize(6.5);
doc.text('ACCOUNT NAME', x + 1, 32);
doc.text('BALANCE', x + BALANCE_R, 32, { align: 'right' });
doc.text('TOTAL', x + TOTAL_R, 32, { align: 'right' });
dottedLine(doc, x + 1, 33.4, x + COL_W - 2);
}
// The divider between the two sides.
doc.setLineWidth(0.15);
doc.setDrawColor(150, 150, 150);
doc.line(COL_X[1], 24, COL_X[1], BODY_BOTTOM + 2);
doc.setDrawColor(0, 0, 0);
// Footer.
dottedLine(doc, MARGIN, BODY_BOTTOM + 6, PAGE_W - MARGIN);
doc
.setFont('helvetica', 'normal')
.setFontSize(6.5)
.setTextColor(...MUTED);
const now = new Date();
const stamp =
`${`${now.getDate()}`.padStart(2, '0')}/${`${now.getMonth() + 1}`.padStart(2, '0')}/${now.getFullYear()} ` +
`${`${now.getHours()}`.padStart(2, '0')}:${`${now.getMinutes()}`.padStart(2, '0')}:${`${now.getSeconds()}`.padStart(2, '0')}`;
doc.text(`Print Date : ${stamp}`, MARGIN, BODY_BOTTOM + 10);
doc.text('Consolidated Balance Sheet (Sub Group Wise)', PAGE_W - MARGIN, BODY_BOTTOM + 10, { align: 'right' });
doc.setTextColor(...INK);
}
/** Draws one line into a column at y, and returns the y the next line starts at. */
function drawLine(doc: jsPDF, line: Line, x: number, y: number): number {
switch (line.kind) {
case 'block': {
doc.setFont('helvetica', 'bold').setFontSize(7);
doc.text(line.text.toUpperCase(), x + 1, y);
dottedLine(doc, x + 1, y + 1.1, x + 34);
return y + heightOf(line);
}
case 'subhead': {
doc.setFont('helvetica', 'bold').setFontSize(6.8);
doc.text(line.text, x + 1 + line.depth * 2, y);
return y + heightOf(line);
}
case 'detail': {
doc.setFont('helvetica', 'normal').setFontSize(6.8);
const indent = x + 2 + line.depth * 2;
line.lines.forEach((text, i) => doc.text(text, indent, y + i * LINE_H));
// The amount sits on the first line of a wrapped name, as the printed report has it.
doc.text(line.amount, x + BALANCE_R, y, { align: 'right' });
return y + heightOf(line);
}
case 'subsubtotal': {
doc.setFont('helvetica', 'bold').setFontSize(6.8);
dottedLine(doc, x + BALANCE_R - 22, y - 2.2, x + BALANCE_R);
doc.text(line.amount, x + BALANCE_R, y, { align: 'right' });
return y + heightOf(line);
}
case 'subtotal': {
doc.setFont('helvetica', 'bold').setFontSize(7);
doc.text(line.amount, x + TOTAL_R, y, { align: 'right' });
return y + heightOf(line);
}
case 'standalone': {
doc.setFont('helvetica', 'bold').setFontSize(7);
line.lines.forEach((text, i) => doc.text(text, x + 1, y + i * LINE_H));
doc.text(line.amount, x + BALANCE_R, y, { align: 'right' });
doc.text(line.amount, x + TOTAL_R, y, { align: 'right' });
return y + heightOf(line);
}
case 'grand': {
const top = y + 1.5;
doc.setLineWidth(0.3);
doc.line(x + 1, top, x + COL_W - 2, top);
doc.setFont('helvetica', 'bold').setFontSize(7.5);
doc.text(line.amount, x + TOTAL_R, top + 3.6, { align: 'right' });
doc.setLineWidth(0.2);
doc.line(x + 1, top + 5, x + COL_W - 2, top + 5);
doc.line(x + 1, top + 5.7, x + COL_W - 2, top + 5.7);
return y + heightOf(line);
}
default:
return y + heightOf(line);
}
}
/** Splits one side's lines into per-page chunks that fit between BODY_TOP and BODY_BOTTOM. */
function paginate(lines: Line[]): Line[][] {
const pages: Line[][] = [];
let current: Line[] = [];
let y = BODY_TOP;
for (const line of lines) {
const h = heightOf(line);
if (y + h > BODY_BOTTOM) {
pages.push(current);
current = [];
y = BODY_TOP;
}
current.push(line);
y += h;
}
// Keep the trailing chunk only if it has something on it, but always return at least one page so
// a side with no balances still gets its heading and a zero total.
if (current.length > 0 || pages.length === 0) {
pages.push(current);
}
return pages;
}
export function buildBalanceSheetPdf(input: BalanceSheetPdfInput): jsPDF {
const doc = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4', compress: true });
const creditPages = paginate(flatten(doc, input.creditSide));
const debitPages = paginate(flatten(doc, input.debitSide));
// Both sides print side by side, so the sheet runs as long as the longer of the two, plus a
// final page for the grand totals when they would not fit under the last block.
const pageCount = Math.max(creditPages.length, debitPages.length);
for (let p = 0; p < pageCount; p++) {
if (p > 0) {
doc.addPage();
}
drawPageFrame(doc, input, p + 1, pageCount);
const sides = [
creditPages[p] ?? [],
debitPages[p] ?? []
];
for (let c = 0; c < 2; c++) {
const x = COL_X[c];
let y = BODY_TOP;
for (const line of sides[c]) {
y = drawLine(doc, line, x, y);
}
}
// Sign-off row, on the closing page only.
if (p === pageCount - 1) {
doc
.setFont('helvetica', 'normal')
.setFontSize(6.5)
.setTextColor(...MUTED);
const roles = [
'CLERK',
'ACCOUNTANT / OFFICER',
'ASST. GEN MANAGER',
'GENERAL MANAGER',
'CHAIRMAN'
];
const step = (PAGE_W - MARGIN * 2) / roles.length;
roles.forEach((role, i) => doc.text(role, MARGIN + step * i + step / 2, BODY_BOTTOM + 2, { align: 'center' }));
doc.setTextColor(...INK);
}
}
return doc;
}
@@ -1,182 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="bs-page">
<!-- Controls — not part of the printed sheet. -->
<div class="bs-controls">
<label class="bs-field">
<span>As on date</span>
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
</label>
<label class="bs-field">
<span>Branch</span>
<select [(ngModel)]="officeId" (ngModelChange)="load()">
<option [ngValue]="null">All branches</option>
@for (office of offices; track office.id) {
<option [ngValue]="office.id">{{ office.name }}</option>
}
</select>
</label>
<label class="bs-check">
<input type="checkbox" [(ngModel)]="hideZeroBalances" (ngModelChange)="applyZeroFilter()" />
<span>Hide nil balances</span>
</label>
<span class="bs-controls__spacer"></span>
@if (!loading && debitSide && creditSide) {
<span class="bs-tally" [class.bs-tally--off]="!tallied">
{{ tallied ? 'Balanced' : 'Out of balance by ' + fmt(difference) }}
</span>
}
<button type="button" class="bs-btn" (click)="load()" [disabled]="loading">
{{ loading ? 'Loading…' : 'Refresh' }}
</button>
<button type="button" class="bs-btn bs-btn--primary" (click)="downloadPdf()" [disabled]="loading || !debitSide">
Download PDF
</button>
</div>
@if (error) {
<div class="bs-alert">{{ error }}</div>
}
@if (loading) {
<div class="bs-loading">Loading balance sheet…</div>
}
@if (!loading && debitSide && creditSide) {
<!-- The sheet itself — this block is what prints. -->
<div class="bs-sheet">
<header class="bs-sheet__head">
<h2>CONSOLIDATED BALANCE SHEET (SUB GROUP WISE)</h2>
<p>AS ON : {{ displayDate(asOnDateLoaded) }}</p>
<p class="bs-sheet__branches">FOR BRANCHES : {{ officeLabel }}</p>
</header>
<div class="bs-sheet__body">
<!-- Liabilities (credit) on the left, assets (debit) on the right — as the printed sheet reads. -->
<section class="bs-col">
<div class="bs-col__title">LIABILITIES ( CREDIT )</div>
<div class="bs-row bs-row--colhead">
<span>ACCOUNT NAME</span>
<span>BALANCE</span>
<span>TOTAL</span>
</div>
@for (group of creditSide.groups; track group.classification) {
@for (root of group.nodes; track root.id) {
<ng-container *ngTemplateOutlet="block; context: { $implicit: root }"></ng-container>
}
}
@if (creditSide.groups.length === 0) {
<div class="bs-empty">No liability, equity or income balances as on this date.</div>
}
<div class="bs-row bs-row--grand">
<span></span>
<span></span>
<span>{{ fmt(creditSide.total) }}</span>
</div>
</section>
<section class="bs-col">
<div class="bs-col__title">ASSETS ( DEBIT )</div>
<div class="bs-row bs-row--colhead">
<span>ACCOUNT NAME</span>
<span>BALANCE</span>
<span>TOTAL</span>
</div>
@for (group of debitSide.groups; track group.classification) {
@for (root of group.nodes; track root.id) {
<ng-container *ngTemplateOutlet="block; context: { $implicit: root }"></ng-container>
}
}
@if (debitSide.groups.length === 0) {
<div class="bs-empty">No asset or expense balances as on this date.</div>
}
<div class="bs-row bs-row--grand">
<span></span>
<span></span>
<span>{{ fmt(debitSide.total) }}</span>
</div>
</section>
</div>
<footer class="bs-sheet__foot">
<span>CLERK</span>
<span>ACCOUNTANT / OFFICER</span>
<span>ASST. GEN MANAGER</span>
<span>GENERAL MANAGER</span>
<span>CHAIRMAN</span>
</footer>
</div>
}
</div>
<!--
One top-level block: the Header account's name, a rule, its lines, then its subtotal in the TOTAL
column.
-->
<ng-template #block let-root>
@if (root.usage === 'DETAIL') {
<!-- A Detail account with no Header of its own: a group of one, so it gets a single line
carrying both its balance and its total rather than a heading repeating its own name. -->
<div class="bs-block bs-block--single">
<div class="bs-row bs-row--standalone">
<span class="bs-name" [title]="root.glCode">{{ root.name }}</span>
<span class="bs-amt">{{ fmt(root.balance) }}</span>
<span class="bs-amt">{{ fmt(root.balance) }}</span>
</div>
</div>
} @else {
<div class="bs-block">
<div class="bs-block__name" [title]="root.glCode">{{ root.name }}</div>
<div class="bs-block__rule"></div>
@for (child of root.children; track child.id) {
<ng-container *ngTemplateOutlet="lines; context: { $implicit: child, depth: 0 }"></ng-container>
}
@if (root.children.length === 0) {
<div class="bs-row bs-row--line">
<span class="bs-name bs-name--muted">No accounts under this group</span>
<span></span>
<span></span>
</div>
}
<div class="bs-row bs-row--subtotal">
<span></span>
<span></span>
<span>{{ fmt(root.balance) }}</span>
</div>
</div>
}
</ng-template>
<!--
Everything below a top-level block. A nested Header keeps its own name and its own subtotal, so a
Header inside a Header still reads as its own sub-group rather than flattening into the parent.
-->
<ng-template #lines let-node let-depth="depth">
@if (node.usage === 'DETAIL') {
<div class="bs-row bs-row--line">
<span class="bs-name" [style.paddingLeft.px]="depth * 14" [title]="node.glCode">{{ node.name }}</span>
<span class="bs-amt">{{ fmt(node.balance) }}</span>
<span></span>
</div>
} @else {
<div class="bs-row bs-row--subhead">
<span class="bs-name" [style.paddingLeft.px]="depth * 14" [title]="node.glCode">{{ node.name }}</span>
<span></span>
<span></span>
</div>
@for (child of node.children; track child.id) {
<ng-container *ngTemplateOutlet="lines; context: { $implicit: child, depth: depth + 1 }"></ng-container>
}
<div class="bs-row bs-row--subsubtotal">
<span></span>
<span class="bs-amt">{{ fmt(node.balance) }}</span>
<span></span>
</div>
}
</ng-template>
@@ -1,372 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$ink: #111827;
$ink-soft: #374151;
$ink-faint: #9ca3af;
$rule: #cbd5e1;
$border: #e2e8f0;
$accent: #1d4ed8;
$ok: #15803d;
$ok-soft: #f0fdf4;
$danger: #b91c1c;
$danger-soft: #fef2f2;
// Amount columns share one width so BALANCE and TOTAL line up down the whole sheet.
$amt-col: 128px;
.bs-page {
padding: 20px 24px 40px;
background: #f1f5f9;
min-height: 100%;
}
// ── Controls (screen only) ────────────────────────────────────────────────
.bs-controls {
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 18px;
&__spacer {
flex: 1 1 auto;
}
}
.bs-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $ink-soft;
input,
select {
height: 36px;
padding: 0 10px;
border: 1px solid $border;
border-radius: 6px;
background: #fff;
font-size: 13px;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
color: $ink;
}
}
.bs-check {
display: flex;
align-items: center;
gap: 6px;
height: 36px;
font-size: 12.5px;
font-weight: 600;
color: $ink-soft;
cursor: pointer;
user-select: none;
input {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: $accent;
}
}
.bs-btn {
height: 36px;
padding: 0 16px;
border: 1px solid $border;
border-radius: 6px;
background: #fff;
color: $ink-soft;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
&--primary {
background: $ink;
border-color: $ink;
color: #fff;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.bs-tally {
padding: 6px 12px;
border-radius: 999px;
background: $ok-soft;
color: $ok;
font-size: 12px;
font-weight: 700;
&--off {
background: $danger-soft;
color: $danger;
}
}
.bs-alert {
padding: 10px 14px;
border-radius: 6px;
background: $danger-soft;
color: $danger;
border: 1px solid #fecaca;
font-size: 13px;
margin-bottom: 14px;
}
.bs-loading {
padding: 48px 0;
text-align: center;
color: $ink-faint;
font-size: 13.5px;
}
// ── The printed sheet ─────────────────────────────────────────────────────
.bs-sheet {
background: #fff;
border: 1px solid $border;
padding: 22px 26px 18px;
// The ledger reads as a printed report, so the whole sheet is set in a mono face with
// figures on tabular numerals — columns stay aligned however long the names run.
font-family: 'DejaVu Sans Mono', 'Liberation Mono', Consolas, 'Courier New', monospace;
font-variant-numeric: tabular-nums;
color: $ink;
&__head {
text-align: center;
border-bottom: 1px dotted $rule;
padding-bottom: 10px;
margin-bottom: 12px;
h2 {
margin: 0;
font-size: 13.5px;
font-weight: 700;
letter-spacing: 0.04em;
}
p {
margin: 4px 0 0;
font-size: 11.5px;
color: $ink-soft;
}
}
&__branches {
letter-spacing: 0.05em;
}
&__body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
&__foot {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 26px;
padding-top: 10px;
border-top: 1px dotted $rule;
font-size: 10px;
letter-spacing: 0.05em;
color: $ink-soft;
}
}
.bs-col {
padding: 0 16px;
min-width: 0;
&:first-child {
border-right: 1px dotted $rule;
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
&__title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
padding-bottom: 6px;
}
}
// ── Rows: one grid so name / BALANCE / TOTAL align across every block ──────
.bs-row {
display: grid;
grid-template-columns: minmax(0, 1fr) $amt-col $amt-col;
gap: 8px;
align-items: baseline;
font-size: 11.5px;
line-height: 1.55;
> span:nth-child(2),
> span:nth-child(3) {
text-align: right;
white-space: nowrap;
}
&--colhead {
font-weight: 700;
font-size: 10.5px;
letter-spacing: 0.05em;
border-bottom: 1px dotted $rule;
padding-bottom: 4px;
margin-bottom: 6px;
color: $ink-soft;
}
&--line {
color: $ink-soft;
}
// A nested Header inside a block: its own named sub-group.
&--subhead {
font-weight: 700;
color: $ink;
padding-top: 3px;
}
// Sub-group subtotal sits in the BALANCE column, so it never collides with the
// block's own figure in TOTAL.
&--subsubtotal {
border-top: 1px dotted $rule;
font-weight: 700;
color: $accent;
}
// Top-level block subtotal, in the TOTAL column.
&--subtotal {
font-weight: 700;
color: $ink;
padding-top: 2px;
}
// A Header-less Detail account: its own one-line group.
&--standalone {
font-weight: 700;
color: $ink;
.bs-name {
text-transform: uppercase;
letter-spacing: 0.03em;
}
}
&--grand {
margin-top: 14px;
padding-top: 8px;
border-top: 1px solid $ink;
border-bottom: 3px double $ink;
font-weight: 700;
font-size: 12.5px;
}
}
.bs-name {
overflow-wrap: anywhere;
&--muted {
color: $ink-faint;
font-style: italic;
}
}
.bs-amt {
font-variant-numeric: tabular-nums;
}
.bs-block {
margin-bottom: 14px;
&--single {
margin-bottom: 10px;
}
&__name {
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
// The short dashed rule the printed sheet puts under every group heading.
&__rule {
width: 130px;
border-top: 1px dotted $ink-faint;
margin: 2px 0 4px;
}
}
.bs-empty {
padding: 14px 0;
font-size: 11.5px;
color: $ink-faint;
}
// ── Narrow screens: stack the two sides instead of squeezing the columns ───
@media (width <= 1100px) {
.bs-sheet__body {
grid-template-columns: 1fr;
}
.bs-col {
padding: 0;
&:first-child {
border-right: none;
border-bottom: 1px dotted $rule;
padding-bottom: 16px;
margin-bottom: 16px;
}
}
}
// ── Print: only the sheet, at report proportions ───────────────────────────
@media print {
.bs-controls,
.bs-alert,
.bs-loading {
display: none !important;
}
.bs-page {
padding: 0;
background: #fff;
}
.bs-sheet {
border: none;
padding: 0;
font-size: 10px;
}
.bs-sheet__body {
grid-template-columns: 1fr 1fr;
}
.bs-block {
break-inside: avoid;
}
}
@@ -1,218 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AccountingService } from '../accounting.service';
import { OrganizationService } from '../../organization/organization.service';
import { buildBalanceSheetPdf } from './balance-sheet-pdf';
interface BalanceSheetNode {
id: number;
name: string;
glCode: string;
usage: 'HEADER' | 'DETAIL';
balance: number;
children: BalanceSheetNode[];
}
interface BalanceSheetGroup {
classification: string;
total: number;
nodes: BalanceSheetNode[];
}
interface BalanceSheetSide {
label: string;
total: number;
groups: BalanceSheetGroup[];
}
/**
* Consolidated Balance Sheet (sub-group wise), laid out the way the bank's own printed balance
* sheet reads: liabilities on the left, assets on the right, each top-level Header account a named
* block whose Detail accounts are listed under it and subtotalled in the TOTAL column.
*
* Balances come from BalanceSheetApiResource, which reads the live ledger - a Detail account's own
* postings, and a Header's as the recursive sum of its children (a Header never carries a posting
* of its own). A Detail account that sits under no Header at all is shown as its own one-line block
* rather than being dropped.
*/
@Component({
selector: 'mifosx-balance-sheet',
standalone: true,
imports: [
CommonModule,
FormsModule
],
templateUrl: './balance-sheet.component.html',
styleUrl: './balance-sheet.component.scss'
})
export class BalanceSheetComponent implements OnInit {
private accountingService = inject(AccountingService);
private organizationService = inject(OrganizationService);
/** Printed at the head of the PDF. Falls back to a neutral title if the profile is not set up. */
bankName = 'Bank';
asOnDate: string = new Date().toISOString().slice(0, 10);
officeId: number | null = null;
offices: { id: number; name: string }[] = [];
loading = false;
error = '';
asOnDateLoaded = '';
/** What the server returned, kept whole so the zero-balance toggle never needs a refetch. */
private rawDebitSide: BalanceSheetSide | null = null;
private rawCreditSide: BalanceSheetSide | null = null;
debitSide: BalanceSheetSide | null = null;
creditSide: BalanceSheetSide | null = null;
/**
* A working chart of accounts carries far more accounts than a bank actually operates, and a
* printed balance sheet lists only the ones carrying a balance — leaving every nil line in makes
* the sheet unreadable. On by default for that reason; switch it off to audit the full chart.
*/
hideZeroBalances = true;
/** Both sides are derived from the same double-entry ledger, so they can only disagree if a
* posting is missing a leg — worth surfacing rather than hiding. */
get tallied(): boolean {
if (!this.debitSide || !this.creditSide) {
return true;
}
return Math.abs(this.debitSide.total - this.creditSide.total) < 0.01;
}
get difference(): number {
if (!this.debitSide || !this.creditSide) {
return 0;
}
return Math.abs(this.debitSide.total - this.creditSide.total);
}
get officeLabel(): string {
if (this.officeId == null) {
return 'ALL BRANCHES';
}
return (this.offices.find((o) => o.id === this.officeId)?.name ?? '').toUpperCase();
}
ngOnInit(): void {
this.accountingService.getOffices().subscribe({
next: (offices) => (this.offices = offices || []),
error: () => (this.offices = [])
});
this.organizationService.getInstitutionProfile().subscribe({
next: (profile: any) => {
this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName;
},
// A missing institution profile must not stop the sheet from loading — the PDF just carries
// the fallback title.
error: () => undefined
});
this.load();
}
load(): void {
this.loading = true;
this.error = '';
this.accountingService.getBalanceSheet(this.asOnDate, this.officeId ?? undefined).subscribe({
next: (res: any) => {
this.rawDebitSide = res.debitSide;
this.rawCreditSide = res.creditSide;
this.asOnDateLoaded = res.asOnDate;
this.applyZeroFilter();
this.loading = false;
},
error: () => {
this.error = 'Could not load the balance sheet for this date.';
this.loading = false;
}
});
}
/**
* Rebuilds the displayed sides from what the server sent, dropping nil lines when asked. The
* totals are deliberately left as the server computed them — hiding a nil line must never change
* a figure, and a group total is unaffected by removing children that summed to nothing.
*/
applyZeroFilter(): void {
this.debitSide = this.filterSide(this.rawDebitSide);
this.creditSide = this.filterSide(this.rawCreditSide);
}
private filterSide(side: BalanceSheetSide | null): BalanceSheetSide | null {
if (!side) {
return null;
}
if (!this.hideZeroBalances) {
return side;
}
const groups = side.groups
.map((group) => ({
...group,
nodes: group.nodes
.map((node) => this.pruneZeros(node))
.filter((node): node is BalanceSheetNode => node !== null)
}))
.filter((group) => group.nodes.length > 0);
return { ...side, groups };
}
/** Drops a node whose balance is nil, and any Header left with nothing under it. */
private pruneZeros(node: BalanceSheetNode): BalanceSheetNode | null {
if (Math.abs(node.balance) < 0.005) {
return null;
}
const children = node.children
.map((child) => this.pruneZeros(child))
.filter((child): child is BalanceSheetNode => child !== null);
return { ...node, children };
}
/** Indian grouping with two decimals, matching the printed report (2,76,92,608.00). */
fmt(value: number): string {
return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0);
}
/** dd/MM/yyyy, as the printed report dates it. */
displayDate(iso: string): string {
if (!iso) {
return '';
}
const [
y,
m,
d
] = iso.split('-');
return `${d}/${m}/${y}`;
}
/**
* Builds the report as a real PDF rather than printing the page: window.print() takes the whole
* app shell with it and breaks pages wherever they happen to fall, which is what made the earlier
* output unusable.
*/
downloadPdf(): void {
if (!this.debitSide || !this.creditSide) {
return;
}
const doc = buildBalanceSheetPdf({
bankName: this.bankName,
officeLabel: this.officeLabel,
asOnDate: this.asOnDateLoaded || this.asOnDate,
creditSide: this.creditSide,
debitSide: this.debitSide
});
doc.save(`balance-sheet-${this.asOnDateLoaded || this.asOnDate}.pdf`);
}
}
@@ -119,7 +119,7 @@
<button
mat-raised-button
color="primary"
[disabled]="!glAccountForm.valid || submitting"
[disabled]="!glAccountForm.valid"
*mifosxHasPermission="'CREATE_GLACCOUNT'"
>
{{ 'labels.buttons.Submit' | translate }}
@@ -14,7 +14,6 @@ import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { AccountingService } from '../../accounting.service';
import { AlertService } from 'app/core/alert/alert.service';
import { PopoverService } from '../../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../../configuration-wizard/configuration-wizard.service';
@@ -43,7 +42,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CreateGlAccountComponent implements OnInit, AfterViewInit {
private formBuilder = inject(UntypedFormBuilder);
private accountingService = inject(AccountingService);
private alertService = inject(AlertService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private configurationWizardService = inject(ConfigurationWizardService);
@@ -52,8 +50,6 @@ export class CreateGlAccountComponent implements OnInit, AfterViewInit {
/** GL account form. */
glAccountForm: UntypedFormGroup;
/** Submitting state to prevent duplicate submissions. */
submitting = false;
/** Chart of accounts data. */
chartOfAccountsData: any;
/** Account type data. */
@@ -170,40 +166,24 @@ export class CreateGlAccountComponent implements OnInit, AfterViewInit {
/**
* Submits the gl account form and creates gl account,
* if successful redirects to view created account or back to list if awaiting approval.
* if successful redirects to view created account.
*/
submit() {
if (this.glAccountForm.invalid || this.submitting) {
if (this.glAccountForm.invalid) {
return;
}
this.submitting = true;
this.accountingService.createGlAccount(this.glAccountForm.value).subscribe({
next: (response: any) => {
if (response?.resourceId) {
if (this.configurationWizardService.showChartofAccounts) {
this.configurationWizardService.showChartofAccounts = false;
this.openDialog();
} else {
this.router.navigate(
[
'../view',
response.resourceId
],
{ relativeTo: this.route }
);
}
} else if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'GL Account submitted successfully for approval.'
});
this.router.navigate([this.cancelRoute], { relativeTo: this.route });
} else {
this.router.navigate([this.cancelRoute], { relativeTo: this.route });
}
},
error: () => {
this.submitting = false;
this.accountingService.createGlAccount(this.glAccountForm.value).subscribe((response: any) => {
if (this.configurationWizardService.showChartofAccounts) {
this.configurationWizardService.showChartofAccounts = false;
this.openDialog();
} else {
this.router.navigate(
[
'../view',
response.resourceId
],
{ relativeTo: this.route }
);
}
});
}
-43
View File
@@ -1,43 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
/**
* Accounting features switched off for this installation.
*
* Nothing is deleted — the components, routes and services all stay in place, and flipping a flag
* back to `false` brings the feature back with its tile and its route. The matching API block lives
* in DisabledFeatureApiFilter on the backend, so a hidden feature cannot be reached by calling the
* endpoint directly either.
*/
export const DISABLED_ACCOUNTING_FEATURES = {
createJournalEntries: true,
frequentPostings: true,
closingEntries: true,
accruals: true,
provisioningEntries: true,
accountingRules: true
};
/**
* Blocks a route belonging to a disabled feature, for anyone who reaches it by typing the URL
* rather than through a tile. `data.feature` names the flag to check.
*/
export const accountingFeatureEnabledGuard: CanActivateFn = (route) => {
const router = inject(Router);
const feature = route.data?.['feature'] as keyof typeof DISABLED_ACCOUNTING_FEATURES | undefined;
if (!feature || !DISABLED_ACCOUNTING_FEATURES[feature]) {
return true;
}
router.navigate(['/accounting'], { replaceUrl: true });
return false;
};
@@ -16,7 +16,6 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { accountOptionsForActivity } from '../financial-activity-account-options';
/**
* Create financial activity mapping component.
@@ -88,11 +87,21 @@ export class CreateFinancialActivityMappingComponent implements OnInit {
*/
setGlAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
this.glAccountData = accountOptionsForActivity(
this.financialActivityData,
financialActivityId,
this.glAccountOptions
);
switch (financialActivityId) {
case 100:
case 101:
case 102:
case 103:
this.glAccountData = this.glAccountOptions.assetAccountOptions;
break;
case 200:
case 201:
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
break;
case 300:
this.glAccountData = this.glAccountOptions.equityAccountOptions;
break;
}
});
}
@@ -16,7 +16,6 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { accountOptionsForActivity } from '../financial-activity-account-options';
/**
* Edit financial activity mapping component.
@@ -99,11 +98,21 @@ export class EditFinancialActivityMappingComponent implements OnInit {
*/
setGLAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
this.glAccountData = accountOptionsForActivity(
this.financialActivityData,
financialActivityId,
this.glAccountOptions
);
switch (financialActivityId) {
case 100:
case 101:
case 102:
case 103:
this.glAccountData = this.glAccountOptions.assetAccountOptions;
break;
case 200:
case 201:
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
break;
case 300:
this.glAccountData = this.glAccountOptions.equityAccountOptions;
break;
}
});
}
@@ -1,38 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* The GL accounts a financial activity may be mapped to.
*
* The activity itself says which kind of account it needs — the template returns `mappedGLAccountType` on every
* option — so the answer is read from the activity rather than worked out from its id. The screens used to switch on
* the id instead, listing the ones they knew:
*
* case 100, 101, 102, 103 → asset
* case 200, 201 → liability
* case 300 → equity
*
* Every activity added since fell through that switch and left the account dropdown empty, which is how dividendPaid
* (202), reserveForDividend (301), distributableProfit (302), retainedProfit (303) and dividendRoundOff (304) became
* unmappable — the screen offered no accounts and gave no reason. A list keyed on ids has to be extended by hand for
* each new activity, and is silently wrong until someone notices; asking the activity what it needs cannot fall out
* of step with the backend.
*
* @param activities the template's financialActivityOptions
* @param activityId the activity the user picked
* @param glAccountOptions the template's glAccountOptions, keyed assetAccountOptions / liabilityAccountOptions / …
*/
export function accountOptionsForActivity(activities: any[], activityId: number, glAccountOptions: any): any[] {
const activity = (activities || []).find((a) => a?.id === activityId);
const accountType = activity?.mappedGLAccountType;
if (!accountType || !glAccountOptions) {
return [];
}
// ASSET → assetAccountOptions, and so on for LIABILITY, EQUITY, INCOME and EXPENSE.
return glAccountOptions[`${accountType.toLowerCase()}AccountOptions`] ?? [];
}
@@ -1,192 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container mcd">
<!-- Step 1: which branch's vault is being counted. -->
<mat-card class="mcd-card">
<mat-card-content>
<div class="mcd-intro">
<mat-icon>inventory_2</mat-icon>
<span>
Count the branch's opening cash into notes and coins. If the Cash GL account has no balance yet, this count
defines it; if it already carries one, the count must match it exactly. Either way, this also opens the
branch's first business day with the same count.
</span>
</div>
<div class="mcd-row">
<mat-form-field class="mcd-office">
<mat-label>Branch</mat-label>
<mat-select required [(ngModel)]="officeId" (selectionChange)="onOfficeChange()">
@for (office of officeData; track office) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="mcd-field" (click)="datePicker.open()">
<mat-label>Business Date</mat-label>
<input matInput [max]="maxDate" [matDatepicker]="datePicker" [(ngModel)]="businessDate" readonly />
<mat-datepicker-toggle matSuffix [for]="datePicker"></mat-datepicker-toggle>
<mat-datepicker #datePicker></mat-datepicker>
</mat-form-field>
</div>
</mat-card-content>
</mat-card>
@if (glLoading) {
<div class="mcd-loading">Checking the cash GL account…</div>
}
@if (glError) {
<div class="mcd-gl mcd-gl--error">
<mat-icon>error</mat-icon>
<div>{{ glError }}</div>
</div>
}
@if (glStatus && !glError) {
@if (todayAlreadyOpen) {
<!-- This screen is a one-time "day 1" tool - today's business day already exists, so counting
a drawer here would only be rejected at submit (BranchDayRegisterApiResource rejects
reopening an already-open day). Day Register is where every day after the first belongs. -->
<div class="mcd-gl mcd-gl--error">
<mat-icon>lock</mat-icon>
<div>
<div class="mcd-gl__label">Business Day Already Open</div>
<div class="mcd-gl__value">
{{ todayOpenInfo.businessDate }} — opened by {{ todayOpenInfo.openedByName }}, opening cash ₹{{
todayOpenInfo.openingCash | number: '1.2-2'
}}
</div>
<div class="mcd-gl__hint">
This branch has already opened today's business day — counting the drawer again here would only be rejected
on submit. Deposits, withdrawals and closing today's day happen from Day Register instead.
</div>
<button type="button" mat-stroked-button (click)="goToBranchDay()">Go to Day Register</button>
</div>
</div>
} @else {
<!-- Step 2: what the ledger already says, so the count is counted against something concrete. -->
<div class="mcd-gl" [class.mcd-gl--fresh]="!glHasBalance">
<mat-icon>account_balance</mat-icon>
<div>
<div class="mcd-gl__label">Cash GL Account</div>
<div class="mcd-gl__value">({{ glStatus.glCode }}) {{ glStatus.glAccountName }}</div>
@if (glHasBalance) {
<div class="mcd-gl__hint">
Already holds
<strong>₹{{ glTarget.toLocaleString('en-IN') }}</strong>
— count the drawer to exactly this amount.
</div>
} @else {
<div class="mcd-gl__hint">
No opening balance posted yet — whatever you count below becomes this account's opening balance.
</div>
}
</div>
</div>
<!-- Step 3: the actual count. -->
<mat-card class="mcd-card mcd-card--flush">
<mat-card-content>
<div class="mcd-grid mcd-grid--head">
<div class="mcd-grid__denom">Denomination</div>
<div class="mcd-grid__count">Count</div>
<div class="mcd-grid__amt">Amount</div>
</div>
@for (d of allDenoms; track d.id) {
<div class="mcd-grid mcd-grid--row">
<div class="mcd-grid__denom">
{{ d.label }} <span class="mcd-grid__kind">{{ d.kind }}</span>
</div>
<div class="mcd-grid__count">
<button type="button" mat-icon-button (click)="bumpCount(d, -1)"><mat-icon>remove</mat-icon></button>
<input
type="number"
min="0"
class="mcd-grid__input"
[style.width.ch]="countInputWidthCh(d)"
[ngModel]="countFor(d)"
(ngModelChange)="onCountChange(d, $event)"
/>
<button type="button" mat-icon-button (click)="bumpCount(d, 1)"><mat-icon>add</mat-icon></button>
</div>
<div class="mcd-grid__amt">₹{{ subtotalFor(d).toLocaleString('en-IN') }}</div>
</div>
}
</mat-card-content>
</mat-card>
<!-- The one number that matters. -->
<div class="mcd-balance" [class.mcd-balance--ok]="isReconciled">
<div class="mcd-balance__figure">
<span class="mcd-balance__label">Counted Total</span>
<span class="mcd-balance__amt">₹{{ countedTotal.toLocaleString('en-IN') }}</span>
</div>
@if (glHasBalance) {
<div class="mcd-balance__figure">
<span class="mcd-balance__label">GL Target</span>
<span class="mcd-balance__amt">₹{{ glTarget.toLocaleString('en-IN') }}</span>
</div>
}
<div class="mcd-balance__status">
@if (countedTotal <= 0) {
<mat-icon>edit_note</mat-icon>
<span>Count each denomination above</span>
} @else if (isReconciled) {
<mat-icon>check_circle</mat-icon>
<span>{{ glHasBalance ? 'Matches the GL balance' : 'Will define the GL opening balance' }}</span>
} @else {
<mat-icon>error</mat-icon>
<span
>Off by ₹{{
(countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString(
'en-IN'
)
}}</span
>
}
</div>
</div>
@if (needsDifferenceReason) {
<mat-card class="mcd-card">
<mat-card-content>
<mat-form-field class="mcd-reason">
<mat-label>Why does the count differ from the GL balance?</mat-label>
<input matInput [(ngModel)]="differenceReason" />
</mat-form-field>
</mat-card-content>
</mat-card>
}
@if (errorMsg) {
<div class="mcd-error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="mcd-success">
<mat-icon>check_circle</mat-icon>
<div>
{{ successMsg }}
<div class="mcd-success__actions">
<button type="button" mat-stroked-button (click)="goToBranchDay()">Go to Day Register</button>
</div>
</div>
</div>
}
@if (!successMsg) {
<div class="mcd-actions">
<button mat-raised-button color="primary" [disabled]="!canSubmit" (click)="submit()">
{{ submitting ? 'Submitting…' : 'Migrate & Open Business Day' }}
</button>
</div>
}
}
}
</div>
@@ -1,316 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.mcd {
max-width: 760px;
}
.mcd-card {
margin-bottom: 14px;
border-radius: 8px;
&--flush mat-card-content {
padding: 0;
}
}
.mcd-intro {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
line-height: 1.5;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: none;
}
}
.mcd-row {
display: flex;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.mcd-office {
flex: 1 1 280px;
}
.mcd-field {
flex: 1 1 200px;
}
.mcd-loading {
padding: 10px 14px;
margin-bottom: 14px;
font-size: 12px;
color: #64748b;
}
.mcd-gl {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 14px;
margin-bottom: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-left: 4px solid #0ea5e9;
border-radius: 8px;
mat-icon {
color: #0ea5e9;
margin-top: 2px;
}
&--fresh {
border-left-color: #f59e0b;
mat-icon {
color: #f59e0b;
}
}
&--error {
border-left-color: #dc2626;
background: #fef2f2;
mat-icon {
color: #dc2626;
}
}
&__label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #64748b;
}
&__value {
font-size: 13px;
font-weight: 600;
color: #0d1b2a;
margin: 2px 0 4px;
}
&__hint {
font-size: 11.5px;
color: #64748b;
line-height: 1.4;
}
button {
margin-top: 10px;
}
}
.mcd-grid {
display: grid;
grid-template-columns: 1fr 160px 120px;
align-items: center;
gap: 8px;
padding: 10px 14px;
&--head {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #64748b;
}
&--row {
border-bottom: 1px solid #f1f5f9;
}
&__denom {
font-size: 13px;
font-weight: 600;
color: #0d1b2a;
}
&__kind {
font-size: 10px;
font-weight: 500;
color: #94a3b8;
margin-left: 6px;
text-transform: uppercase;
}
&__count {
display: flex;
align-items: center;
gap: 2px;
button {
width: 28px;
height: 28px;
line-height: 28px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
&__input {
/* Width is set inline per row (see countInputWidthCh()), growing with the digit count typed —
min-width here only covers the fallback before Angular has bound anything. */
min-width: 48px;
max-width: 96px;
transition: width 0.1s ease-out;
text-align: center;
font-variant-numeric: tabular-nums;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 4px 2px;
font-size: 13px;
}
&__amt {
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 13px;
text-align: right;
color: #0d1b2a;
}
}
.mcd-balance {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
padding: 12px 16px;
margin-bottom: 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 8px;
&--ok {
background: #f0fdf4;
border-color: #bbf7d0;
border-left-color: #16a34a;
}
&__figure {
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #64748b;
}
&__amt {
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 16px;
white-space: nowrap;
color: #0d1b2a;
}
&__status {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 13px;
font-weight: 600;
color: #b91c1c;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
&--ok &__status {
color: #15803d;
}
}
.mcd-reason {
width: 100%;
}
.mcd-error {
padding: 10px 14px;
margin-bottom: 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12.5px;
}
.mcd-success {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px 16px;
margin-bottom: 14px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
color: #14532d;
font-size: 13px;
mat-icon {
color: #16a34a;
margin-top: 1px;
}
&__actions {
margin-top: 10px;
}
}
.mcd-actions {
display: flex;
justify-content: flex-end;
padding-bottom: 24px;
}
@media (width <= 560px) {
.mcd-grid {
grid-template-columns: 1fr auto;
row-gap: 4px;
&--head {
display: none;
}
&__amt {
grid-column: 1 / -1;
text-align: left;
}
}
}
@@ -1,262 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../../cashier/cashier.service';
import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
interface Denom {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
/**
* Migrate Cash Denomination — the vault-side counterpart to Migrate Opening Balances.
*
* A freshly set-up institution's chart of accounts carries a "Cash at Vault" GL balance (defined via
* Migrate Opening Balances, or not yet defined at all), but that figure is meaningless to a cashier
* standing at the drawer until it is broken into actual notes and coins, and the branch has nowhere
* to track that breakdown until its first business day is open. This screen is the one place both of
* those happen together: count the drawer, and it either defines the cash GL's opening balance from
* that count (nothing posted to it yet) or checks the count against what is already posted, then
* opens the business day with that same breakdown - so a new bank goes from "GL seeded" straight to
* "vault countable" without a separate trip through Manage Tellers or Open Business Day first.
*/
@Component({
selector: 'mifosx-migrate-cash-denomination',
templateUrl: './migrate-cash-denomination.component.html',
styleUrls: ['./migrate-cash-denomination.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
...STANDALONE_SHARED_IMPORTS
]
})
export class MigrateCashDenominationComponent implements OnInit {
private service = inject(CashierService);
private settingsService = inject(SettingsService);
private route = inject(ActivatedRoute);
private router = inject(Router);
officeData: any[] = [];
officeId: number | null = null;
/** Bound to the datepicker as a Date, converted to a strict yyyy-MM-dd string only when
* submitting — the branchday endpoints parse this with LocalDate.parse() directly, with no
* locale/dateFormat hint alongside it, so a locale-formatted string (what dateUtils.formatDate
* produces) is rejected outright. */
businessDate: Date = new Date();
maxDate = new Date();
glLoading = false;
glStatus: any = null;
glError = '';
/** Set once per office change: whether TODAY's business day is already open for this office. This
* screen is a one-time "day 1" tool (see the class doc) - once a day has actually been opened,
* reopening the same one is exactly what BranchDayRegisterApiResource#migrateCashDenomination
* itself refuses (its own openDayInternal call rejects a day that is already open), so this is
* checked here too, before the user counts an entire drawer only to have the submit rejected. */
todayAlreadyOpen = false;
todayOpenInfo: any = null;
notes: Denom[] = [];
coins: Denom[] = [];
counts: Record<number, number> = {};
submitting = false;
errorMsg = '';
successMsg = '';
result: any = null;
/** Only asked for when the counted total disagrees with an already-posted GL balance — mirrors
* the same "explain the gap" gate Open Business Day applies. */
differenceReason = '';
get allDenoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.businessDate = this.maxDate;
this.route.data.subscribe((data: { offices: any }) => {
this.officeData = data.offices || [];
});
this.service.getActiveDenominations().subscribe({
next: (list) => {
const rows: Denom[] = (list || []).map((x: any) => ({
id: x.id,
value: x.denomination,
kind: x.kind,
label: '₹' + x.denomination
}));
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
},
error: () => {
this.notes = [];
this.coins = [];
}
});
}
onOfficeChange(): void {
this.glStatus = null;
this.glError = '';
this.result = null;
this.successMsg = '';
this.errorMsg = '';
this.todayAlreadyOpen = false;
this.todayOpenInfo = null;
if (!this.officeId) return;
this.glLoading = true;
this.service
.cashGlStatus(this.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.glLoading = false;
this.glStatus = res;
if (res && res.configured === false) {
this.glError = res.message || 'No cash GL account is configured for this tenant yet.';
}
});
this.service
.branchDayRegister(this.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
const openDay = res?.openDay;
this.todayAlreadyOpen = !!openDay && openDay.businessDate === res?.today;
this.todayOpenInfo = this.todayAlreadyOpen ? openDay : null;
});
}
countFor(d: Denom): number {
return this.counts[d.id] || 0;
}
subtotalFor(d: Denom): number {
return this.countFor(d) * d.value;
}
onCountChange(d: Denom, raw: any): void {
this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
}
bumpCount(d: Denom, delta: number): void {
this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
}
/** Widens the count box as the typed count grows more digits, so a large count (hundreds,
* thousands) is never clipped or hard to read inside a field sized for one or two digits. */
countInputWidthCh(d: Denom): number {
return Math.max(3, String(this.countFor(d)).length + 2);
}
get countedTotal(): number {
return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0);
}
/** Whether this office's GL already carries a balance the count has to match, vs. an empty
* account the count itself will define. */
get glHasBalance(): boolean {
return !!this.glStatus?.hasBalance;
}
get glTarget(): number {
return Number(this.glStatus?.currentBalance ?? 0);
}
get isReconciled(): boolean {
if (!this.glStatus || this.glStatus.configured === false) return false;
if (this.countedTotal <= 0) return false;
return !this.glHasBalance || this.countedTotal === this.glTarget;
}
get needsDifferenceReason(): boolean {
return (
this.glHasBalance && this.countedTotal > 0 && this.countedTotal !== this.glTarget && !this.differenceReason.trim()
);
}
get canSubmit(): boolean {
return (
!!this.officeId &&
!!this.businessDate &&
!this.glError &&
this.countedTotal > 0 &&
!this.needsDifferenceReason &&
!this.submitting
);
}
private denominationPayload() {
return this.allDenoms
.filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }));
}
/** Strict yyyy-MM-dd, independent of the viewer's locale/date-format setting — see the comment on
* `businessDate` for why this can't go through dateUtils.formatDate(). */
private toIsoDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
submit(): void {
if (!this.canSubmit || !this.officeId) return;
this.submitting = true;
this.errorMsg = '';
this.successMsg = '';
this.service
.migrateCashDenomination(
this.officeId,
this.toIsoDate(this.businessDate),
this.denominationPayload(),
this.differenceReason.trim() || undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.message ??
'Could not migrate cash denomination.';
return of(null);
})
)
.subscribe((res: any) => {
this.submitting = false;
if (res) {
this.result = res;
this.successMsg = res.glOpeningBalancePosted
? `Cash GL opening balance defined at ₹${this.countedTotal.toLocaleString('en-IN')} and business day ${res.businessDate} opened.`
: `Business day ${res.businessDate} opened — count matched the existing GL balance.`;
}
});
}
goToBranchDay(): void {
this.router.navigate(['/branch-ops/day-register']);
}
}
@@ -6,67 +6,48 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container mob">
<form [formGroup]="openingBalancesForm" (ngSubmit)="submit()">
<!-- Step 1: pick the branch whose old books are being carried into this GL. -->
<mat-card class="mob-card">
<!-- TODO: Responsiveness/design enhancement and reduction in form loading time -->
<div class="container">
<mat-card>
<form [formGroup]="openingBalancesForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="mob-intro">
<mat-icon>info</mat-icon>
<span>
Bring a branch's existing balances into this system's ledger, one time, as a single balanced entry against
the Opening Balances Contra account below.
</span>
</div>
<div #searchFormRef class="mob-row">
<mat-form-field class="mob-office">
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
<mat-select required formControlName="officeId">
@for (office of officeData; track office) {
<mat-option [value]="office.id">
{{ office.name }}
</mat-option>
<div #searchFormRef class="layout-row-wrap responsive-column gap-2px">
<div class="m-r-30 flex-83">
<mat-form-field class="flex-96">
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
<mat-select required formControlName="officeId">
@for (office of officeData; track office) {
<mat-option [value]="office.id">
{{ office.name }}
</mat-option>
}
</mat-select>
@if (openingBalancesForm.controls.officeId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-select>
@if (openingBalancesForm.controls.officeId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<button
type="button"
mat-raised-button
color="primary"
[disabled]="!openingBalancesForm.controls.officeId.value"
(click)="retrieveOpeningBalances()"
>
{{ 'labels.buttons.Retrieve' | translate }}
</button>
</div>
</mat-card-content>
</mat-card>
@if (openingBalancesData) {
<!-- Step 2: the contra account this entry balances against, plus currency and date. -->
<mat-card class="mob-card">
<mat-card-content>
<div class="mob-contra">
<mat-icon>account_balance</mat-icon>
<div>
<div class="mob-contra__label">Opening Balances Contra Account</div>
<div class="mob-contra__value">
<mifosx-gl-account-display [glAccount]="openingBalancesData.contraAccount"></mifosx-gl-account-display>
</div>
<div class="mob-contra__hint">
Every debit and credit below posts against this one account, so the whole entry always balances to zero.
</div>
</div>
</mat-form-field>
</div>
<div class="mob-row">
<mat-form-field class="mob-field">
<div class="flex-15">
<button mat-raised-button color="primary" (click)="retrieveOpeningBalances()">
{{ 'labels.buttons.Retrieve' | translate }}
</button>
</div>
</div>
@if (openingBalancesData) {
<div class="layout-row-wrap gap-2percent responsive-column">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Opening Balances Contra Account'"
[glAccount]="openingBalancesData.contraAccount"
[withTitle]="'50%'"
>
</mifosx-gl-account-display>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Currency' | translate }}</mat-label>
<mat-select required formControlName="currencyCode">
@for (currency of currencyData; track currency) {
@@ -82,7 +63,7 @@
</mat-error>
}
</mat-form-field>
<mat-form-field class="mob-field" (click)="openingBalancesDatePicker.open()">
<mat-form-field class="flex-48" (click)="openingBalancesDatePicker.open()">
<mat-label>{{ 'labels.inputs.Opening Balances Date' | translate }}</mat-label>
<input
matInput
@@ -101,114 +82,79 @@
</mat-error>
}
</mat-form-field>
</div>
</mat-card-content>
</mat-card>
<!-- The one number that matters: does the sheet balance yet. Pinned above the entry grid so it
never has to be scrolled to. -->
<div class="mob-balance" [class.mob-balance--ok]="debitsSum === creditsSum && debitsSum > 0">
<div class="mob-balance__figure">
<span class="mob-balance__label">{{ 'labels.inputs.Total Debit' | translate }}</span>
<span class="mob-balance__amt mob-balance__amt--dr">{{
debitsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2'
}}</span>
</div>
<div class="mob-balance__figure">
<span class="mob-balance__label">{{ 'labels.inputs.Total Credit' | translate }}</span>
<span class="mob-balance__amt mob-balance__amt--cr">{{
creditsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2'
}}</span>
</div>
<div class="mob-balance__status">
@if (debitsSum === creditsSum && debitsSum > 0) {
<mat-icon>check_circle</mat-icon>
<span>Balanced — ready to submit</span>
} @else if (debitsSum === 0 && creditsSum === 0) {
<mat-icon>edit_note</mat-icon>
<span>Enter each account's opening balance below</span>
} @else {
<mat-icon>error</mat-icon>
<span
>Out of balance by {{ debitsSum - creditsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}</span
>
}
</div>
</div>
<!-- Step 3: one row per GL account, grouped by account type, debit on one side and credit on
the other — a debit balance goes in the Debit box, a credit balance in the Credit box,
never both. -->
<mat-card class="mob-card mob-card--flush">
<mat-card-content>
<div class="mob-grid mob-grid--head">
<div class="mob-grid__code">{{ 'labels.inputs.GL Code' | translate }}</div>
<div class="mob-grid__name">
{{ 'labels.inputs.GL Name' | translate }} {{ 'labels.inputs.Account' | translate }}
<div class="layout-row-wrap gap-2percent responsive-column flex-100 content">
<div class="header flex-8">
{{ 'labels.inputs.Type' | translate }}
</div>
<div class="header flex-18">
{{ 'labels.inputs.GL Code' | translate }}
</div>
<div class="header flex-18">
{{ 'labels.inputs.GL Name' | translate }}
{{ 'labels.inputs.Account' | translate }}
</div>
<div class="header r-amount flex-18">
{{ 'labels.inputs.Total Debit' | translate }}:
{{ debitsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
</div>
<div class="header r-amount flex-23">
{{ 'labels.inputs.Total Credit' | translate }}:
{{ creditsSum | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<div class="mob-grid__amt">{{ 'labels.inputs.Debit' | translate }}</div>
<div class="mob-grid__amt">{{ 'labels.inputs.Credit' | translate }}</div>
</div>
@for (credit of glAccountEntries.controls; track credit; let i = $index) {
@if (
openingBalancesData.glAccounts[i].glAccountType.value !==
openingBalancesData.glAccounts[i - 1]?.glAccountType.value
) {
<div class="mob-grid__section">
{{ glAccountTypeLabel(openingBalancesData.glAccounts[i].glAccountType.value) }}
@for (credit of glAccountEntries.controls; track credit; let i = $index) {
<div class="flex-fill layout-row-wrap responsive-column content" formArrayName="glAccountEntries">
<div class="flex-fill layout-row-wrap gap-2percent responsive-column" [formGroupName]="i">
<div class="flex-8">
{{
openingBalancesData.glAccounts[i].glAccountType.value !==
openingBalancesData.glAccounts[i - 1]?.glAccountType.value
? glAccountTypeLabel(openingBalancesData.glAccounts[i].glAccountType.value)
: ''
}}
</div>
<div class="flex-13">
{{ openingBalancesData.glAccounts[i].glAccountCode }}
</div>
<div class="flex-29">
{{ openingBalancesData.glAccounts[i].glAccountName }}
</div>
<div class="flex-20">
<mat-form-field>
<mat-label>{{ 'labels.inputs.Debit' | translate }}</mat-label>
<input matInput type="number" formControlName="debit" />
</mat-form-field>
</div>
<div class="flex-20">
<mat-form-field>
<mat-label>{{ 'labels.inputs.Credit' | translate }}</mat-label>
<input matInput type="number" formControlName="credit" />
</mat-form-field>
</div>
</div>
</div>
}
<div class="mob-grid mob-grid--data" formArrayName="glAccountEntries">
<div class="mob-grid__row" [formGroupName]="i">
<div class="mob-grid__code">{{ openingBalancesData.glAccounts[i].glAccountCode }}</div>
<div class="mob-grid__name">
{{ openingBalancesData.glAccounts[i].glAccountName }}
@if (isDenominationManaged(openingBalancesData.glAccounts[i].glAccountId)) {
<span
class="mob-grid__lock"
matTooltip="Set by Migrate Cash Denomination — use that screen to change it"
>
<mat-icon inline="true">lock</mat-icon>
<a [routerLink]="['/accounting/migrate-cash-denomination']">Migrate Cash Denomination</a>
</span>
}
</div>
<div class="mob-grid__amt">
<mat-form-field class="mob-grid__input">
<input matInput type="number" min="0" formControlName="debit" placeholder="0.00" />
</mat-form-field>
</div>
<div class="mob-grid__amt">
<mat-form-field class="mob-grid__input">
<input matInput type="number" min="0" formControlName="credit" placeholder="0.00" />
</mat-form-field>
</div>
</div>
</div>
}
</mat-card-content>
</mat-card>
<div class="mob-actions">
<button type="button" mat-stroked-button [routerLink]="['../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<span class="mob-actions__spacer"></span>
@if (!(debitsSum === creditsSum && debitsSum > 0)) {
<span class="mob-actions__hint">Total Debit must equal Total Credit before this can be submitted</span>
</div>
}
<button
mat-raised-button
color="primary"
[disabled]="!openingBalancesForm.valid || !(debitsSum === creditsSum && debitsSum > 0)"
*mifosxHasPermission="'DEFINEOPENINGBALANCE_JOURNALENTRY'"
>
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
}
</form>
</mat-card-content>
@if (openingBalancesData) {
<mat-card-actions class="layout-row gap-5px align-center responsive-column">
<button type="button" mat-raised-button [routerLink]="['../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
[disabled]="!openingBalancesForm.valid"
*mifosxHasPermission="'DEFINEOPENINGBALANCE_JOURNALENTRY'"
>
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
}
</form>
</mat-card>
</div>
<ng-template #templateSearchFormRef let-popover="popover">
@@ -6,326 +6,14 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.mob {
max-width: 960px;
}
.content {
padding-top: 20px;
.mob-card {
margin-bottom: 14px;
border-radius: 8px;
div {
word-wrap: break-word;
&--flush mat-card-content {
padding: 0;
}
}
.mob-intro {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
line-height: 1.5;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: none;
}
}
.mob-row {
display: flex;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.mob-office {
flex: 1 1 320px;
}
.mob-field {
flex: 1 1 240px;
}
.mob-contra {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 14px;
margin-bottom: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-left: 4px solid #0ea5e9;
border-radius: 8px;
mat-icon {
color: #0ea5e9;
margin-top: 2px;
}
&__label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #64748b;
}
&__value {
font-size: 13px;
font-weight: 600;
color: #0d1b2a;
margin: 2px 0 4px;
}
&__hint {
font-size: 11px;
color: #64748b;
line-height: 1.4;
}
}
/* The one figure that decides whether this can be submitted — kept visually separate from the
cards around it so it reads as a status, not just another form section. */
.mob-balance {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
padding: 12px 16px;
margin-bottom: 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 8px;
&--ok {
background: #f0fdf4;
border-color: #bbf7d0;
border-left-color: #16a34a;
}
&__figure {
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #64748b;
}
&__amt {
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 16px;
white-space: nowrap;
&--dr {
color: #b91c1c;
}
&--cr {
color: #15803d;
}
}
&__status {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 13px;
font-weight: 600;
color: #b91c1c;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
&--ok &__status {
color: #15803d;
}
}
/* Account entry grid: a header row, a section label per account type, then one row per account. */
.mob-grid {
display: grid;
grid-template-columns: 90px 1fr 160px 160px;
align-items: center;
gap: 8px;
padding: 0 14px;
&--head {
padding: 12px 14px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #64748b;
}
&__section {
padding: 8px 14px 4px;
margin-top: 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #0ea5e9;
border-top: 1px solid #e2e8f0;
&:first-of-type {
border-top: none;
margin-top: 0;
}
}
&--data {
padding-top: 4px;
padding-bottom: 4px;
border-bottom: 1px solid #f1f5f9;
}
&__row {
display: contents;
}
&__code {
font-variant-numeric: tabular-nums;
font-size: 12px;
color: #64748b;
}
&__name {
font-size: 13px;
color: #0d1b2a;
word-break: break-word;
}
/* Cash at Vault / Cash at Teller — locked, see MigrateOpeningBalancesComponent.isDenominationManaged. */
&__lock {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
padding: 1px 8px 1px 6px;
border-radius: 999px;
background: #fff7ed;
color: #9a3412;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
vertical-align: middle;
mat-icon {
font-size: 13px;
width: 13px;
height: 13px;
}
a {
color: inherit;
text-decoration: underline;
}
}
&__amt {
text-align: right;
}
&__input {
width: 100%;
::ng-deep .mat-mdc-form-field-infix {
padding-top: 10px;
padding-bottom: 10px;
min-height: unset;
}
::ng-deep .mat-mdc-text-field-wrapper {
padding: 0 8px;
}
input {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
}
.mob-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
padding-bottom: 24px;
}
.mob-actions__spacer {
flex: 1 1 auto;
}
.mob-actions__hint {
font-size: 12px;
color: #b91c1c;
}
@media (width <= 640px) {
.mob-grid {
grid-template-columns: 70px 1fr;
row-gap: 4px;
&--head {
display: none;
}
&__row {
display: grid;
grid-template-columns: 70px 1fr;
grid-template-areas:
'code name'
'debit debit'
'credit credit';
gap: 4px;
padding: 10px 0;
border-bottom: 1px solid #f1f5f9;
}
&__code {
grid-area: code;
}
&__name {
grid-area: name;
}
&__amt:nth-of-type(1) {
grid-area: debit;
}
&__amt:nth-of-type(2) {
grid-area: credit;
}
&__amt {
text-align: left;
&.header {
font-weight: 500;
}
}
}
@@ -16,8 +16,6 @@ import {
ReactiveFormsModule
} from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
/** Custom Services */
import { AccountingService } from '../accounting.service';
@@ -43,9 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
GlAccountDisplayComponent,
CurrencyPipe,
MatIconModule,
MatTooltipModule
CurrencyPipe
]
})
export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
@@ -76,13 +72,6 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
debitsSum = 0;
/** Sum total of credits. */
creditsSum = 0;
/** GL account ids mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) — the
* account(s) Migrate Cash Denomination owns. Their opening balance is shown here for visibility
* but locked against editing: redefining it as a bare number here, instead of through a counted
* denomination breakdown, would let the GL figure drift away from what is physically in the till.
* The backend enforces the same rule independently (see defineOpeningBalance's
* validateNoDenominationManagedCashAccounts) — this is a convenience, not the source of truth. */
denominationManagedGlAccountIds: number[] = [];
/* Reference of search form */
@ViewChild('searchFormRef') searchFormRef: ElementRef<any>;
@@ -104,22 +93,6 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
this.officeData = data.offices;
this.currencyData = data.currencies.selectedCurrencyOptions;
});
// Office-independent: which GL account(s) this tenant has mapped to Cash at Vault / Cash at
// Teller, so their row can be locked the moment the office's accounts are drawn.
this.accountingService.getFinancialActivityAccounts().subscribe((mappings: any[]) => {
this.denominationManagedGlAccountIds = (mappings || [])
.filter((mapping) => mapping.financialActivityData?.id === 101 || mapping.financialActivityData?.id === 102)
.map((mapping) => mapping.glAccountData?.id)
.filter((id) => id != null);
// Retrieve may already have run before this (fast) request came back — lock any row it
// missed rather than leaving it editable for the rest of this visit.
this.glAccountEntries?.controls.forEach((control) => {
const glAccountId = control.get('glAccountId')?.value;
if (this.isDenominationManaged(glAccountId) && control.enabled) {
control.disable();
}
});
});
}
/**
@@ -161,31 +134,14 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
* @returns {FormGroup} GL Account entry form.
*/
createGLAccountEntryForm(glAccount: any): UntypedFormGroup {
// A never-touched account arrives as a placeholder row (amount/entryType both null); one that
// already carries an opening balance arrives with its real, committed figure — show that
// instead of leaving the field looking blank.
const existingDebit = glAccount.entryType?.value === 'DEBIT' ? glAccount.amount : null;
const existingCredit = glAccount.entryType?.value === 'CREDIT' ? glAccount.amount : null;
const group = this.formBuilder.group(
return this.formBuilder.group(
{
glAccountId: [glAccount.glAccountId],
debit: [existingDebit],
credit: [existingCredit]
debit: [null],
credit: [null]
},
{ validator: onlyOneOfTheFieldsIsRequiredValidator }
);
if (this.isDenominationManaged(glAccount.glAccountId)) {
// Disabling excludes this row from the form's own .value, so it is never resubmitted here —
// the account keeps whatever Migrate Cash Denomination last set it to.
group.disable();
}
return group;
}
/** Whether this GL account is Cash at Vault / Cash at Teller — locked here, see
* denominationManagedGlAccountIds. */
isDenominationManaged(glAccountId: number): boolean {
return this.denominationManagedGlAccountIds.includes(glAccountId);
}
/**
@@ -218,30 +174,17 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
this.openingBalancesData = openingBalancesData;
entry.valueChanges.subscribe(() => this.recomputeSums());
// Runs once immediately too: valueChanges only fires on a later edit, and the locked cash
// row's own prefilled figure (set once, above, never through a further value change) would
// otherwise never be reflected in the banner at all.
this.recomputeSums();
entry.valueChanges.subscribe(() => {
this.debitsSum = 0;
this.creditsSum = 0;
entry.controls.forEach((value) => {
this.debitsSum += value.value.debit;
this.creditsSum += value.value.credit;
});
});
});
}
/** Total Debit / Total Credit shown in the balance banner. A locked (denomination-managed) row is
* excluded here exactly as it is excluded from the form's own .value used at submit time — this
* screen only needs to balance the accounts it is itself about to (re)define, not the one
* Migrate Cash Denomination already settled and this form can never touch. */
private recomputeSums(): void {
this.debitsSum = 0;
this.creditsSum = 0;
this.glAccountEntries.controls.forEach((control) => {
if (control.disabled) {
return;
}
this.debitsSum += control.value.debit || 0;
this.creditsSum += control.value.credit || 0;
});
}
/**
* Submits the opening balances form and defines opening balances,
* if successful redirects to view created transaction.
@@ -53,13 +53,10 @@
<fa-icon icon="sync" [class.fa-spin]="ledgerLoading"></fa-icon>
Refresh
</button>
<!-- New Entry — hidden while Create Journal Entries is disabled, see disabled-features.ts -->
@if (!disabledFeatures.createJournalEntries) {
<button class="gl-hero-btn gl-hero-btn--primary" [routerLink]="['/accounting/journal-entries/create']">
<fa-icon icon="plus"></fa-icon>
New Entry
</button>
}
<button class="gl-hero-btn gl-hero-btn--primary" [routerLink]="['/accounting/journal-entries/create']">
<fa-icon icon="plus"></fa-icon>
New Entry
</button>
</div>
</div>
</div>
@@ -279,7 +276,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [formControl]="transactionDateFrom" />
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [max]="maxDate" [formControl]="transactionDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
<mat-datepicker #dpFrom></mat-datepicker>
</mat-form-field>
@@ -288,7 +285,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpTo" [min]="minDate" [formControl]="transactionDateTo" />
<input matInput [matDatepicker]="dpTo" [min]="minDate" [max]="maxDate" [formControl]="transactionDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
<mat-datepicker #dpTo></mat-datepicker>
</mat-form-field>
@@ -353,7 +350,13 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
<input
matInput
[matDatepicker]="dpSubFrom"
[min]="minDate"
[max]="maxDate"
[formControl]="submittedOnDateFrom"
/>
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
<mat-datepicker #dpSubFrom></mat-datepicker>
</mat-form-field>
@@ -362,7 +365,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [formControl]="submittedOnDateTo" />
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [max]="maxDate" [formControl]="submittedOnDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
<mat-datepicker #dpSubTo></mat-datepicker>
</mat-form-field>
@@ -533,7 +536,7 @@
</div>
<div class="gl-table-shell">
<mat-table [dataSource]="dataSource" matSort matSortActive="id" matSortDirection="desc" class="gl-mat-table">
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
<!-- #ID -->
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef mat-sort-header class="gl-th">#</mat-header-cell>
@@ -712,10 +715,7 @@
></mat-row>
</mat-table>
<!-- Keyed off the rows actually rendered, not totalEntries: that count comes from the ledger
summary, which only reports a figure once a GL account is chosen, so the search view would
otherwise show "No entries found" above a full table. -->
@if (((dataSource?.entries$ | async) || []).length === 0) {
@if (totalEntries === 0) {
<div class="gl-empty">
<div class="gl-empty__icon"><fa-icon icon="search"></fa-icon></div>
<div class="gl-empty__title">No entries found</div>
@@ -724,11 +724,7 @@
}
<div class="gl-pager-wrap">
<mat-paginator
[length]="(dataSource?.records$ | async) || 0"
[pageSizeOptions]="[10, 25, 50, 100]"
showFirstLastButtons
></mat-paginator>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</div>
</div>
@@ -14,7 +14,6 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
import { merge } from 'rxjs';
import { tap, startWith, map, distinctUntilChanged, debounceTime } from 'rxjs/operators';
import { AccountingService } from '../accounting.service';
import { DISABLED_ACCOUNTING_FEATURES } from '../disabled-features';
import { SettingsService } from 'app/settings/settings.service';
import { JournalEntriesDataSource } from './journal-entry.datasource';
import { Dates } from 'app/core/utils/dates';
@@ -90,9 +89,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
maxDate: Date = null;
/** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
maxDate = new Date();
// ── Filter controls ───────────────────────────────────────────────────────
officeName = new UntypedFormControl();
@@ -208,7 +205,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
}
ngOnInit() {
this.maxDate = null;
this.maxDate = this.settingsService.businessDate;
this.paginator.pageSize = 10;
this.restoreState();
this.setFilteredOffices();
@@ -11,14 +11,6 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { AccountsListComponent } from './accounts-list/accounts-list.component';
import { PartyMasterComponent } from '../deposits/party-master/party-master.component';
import { CreatePartyComponent } from '../deposits/party-master/create-party/create-party.component';
import { DueForClosureComponent } from '../deposits/party-master/due-for-closure/due-for-closure.component';
import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component';
import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component';
import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component';
import { DdsAccountsComponent } from '../deposits/dds-accounts/dds-accounts.component';
import { DdsAccountViewComponent } from '../deposits/dds-accounts/dds-account-view/dds-account-view.component';
const routes: Routes = [
Route.withShell([
@@ -49,59 +41,6 @@ const routes: Routes = [
title: 'Recurring Deposit Accounts',
breadcrumb: 'Recurring Deposit Accounts'
}
},
{
path: 'dds',
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' },
children: [
{ path: '', component: PartyMasterComponent },
{
path: 'create',
component: CreatePartyComponent,
data: { title: 'Create Party', breadcrumb: 'Create' }
},
{
// Ahead of the :partyId routes, so "dueforclosure" is not read as a party id.
path: 'dueforclosure',
component: DueForClosureComponent,
data: { title: 'Accounts Due for Closure', breadcrumb: 'Due for Closure' }
},
{
path: ':partyId/edit',
component: CreatePartyComponent,
data: { title: 'Edit Party', breadcrumb: 'Edit' }
}
]
},
{
// The DDS accounts listed like Savings Accounts, each opening onto its transactions.
path: 'dds-accounts',
data: { title: 'DDS Accounts', breadcrumb: 'DDS Accounts' },
children: [
{ path: '', component: DdsAccountsComponent },
{
path: ':partyId',
component: DdsAccountViewComponent,
data: { title: 'DDS Account', breadcrumb: 'Account', routeParamBreadcrumb: false }
}
]
},
{
path: 'dds-collection',
component: DailyTransactionEntryComponent,
data: { title: 'Daily Transaction Entry', breadcrumb: 'Daily Transaction Entry' }
},
{
path: 'dds-reports',
data: { title: 'DDS Reports', breadcrumb: 'DDS Reports' },
children: [
{ path: '', component: DdsReportsComponent },
{
path: 'view/:reportName',
component: ReportViewerComponent,
data: { title: 'DDS Report', breadcrumb: 'reportName', routeParamBreadcrumb: 'reportName' }
}
]
}
]
}
-16
View File
@@ -74,22 +74,6 @@ export class AccountsService {
return this.http.get('/fixeddepositaccounts', { params: httpParams });
}
/**
* FD Dashboard's data source: every ACTIVE FD, id/account/client/product identity, maturity date
* and deposit/maturity amounts only. Deliberately a separate, narrow endpoint rather than
* getFixedDepositAccounts() above -- that one's backend query joins in six more tables (audit
* who-did-what-when, currency, tax group) an unbounded dashboard fetch does not need, over columns
* that had no index until this feature added one (see backend migration 0571).
*/
getFixedDepositMaturityDashboard(): Observable<any> {
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
}
/** RD Dashboard: running RDs with their installment position, closures and penalty income for the month. */
getRecurringDepositDashboard(): Observable<any> {
return this.http.get('/recurringdepositaccounts/dashboard');
}
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
let httpParams = new HttpParams()
.set('paged', 'true')
-54
View File
@@ -34,17 +34,6 @@ export class AmlService {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
}
/**
* Decides named rules within one alert, leaving its other rules open.
*
* <p>
* The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules
* leaves the alert open on the sixth.
*/
reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable<any> {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes });
}
// ═══ Recurring-alert review queue ═══
/**
@@ -133,15 +122,6 @@ export class AmlService {
return this.http.get<any[]>('/aml/ctr-reports');
}
/**
* The reportable lines behind the generated CTRs, each resolved to the account, customer, branch
* and date a FIU-IND filing names. Omit `reportId` for every generated month.
*/
getCtrReportLines(reportId?: number): Observable<any[]> {
const params = reportId ? new HttpParams().set('reportId', String(reportId)) : undefined;
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
}
/** month format: yyyy-MM */
generateCtrReport(month: string): Observable<any> {
return this.http.post('/aml/ctr-reports/generate', { month });
@@ -152,38 +132,4 @@ export class AmlService {
getRiskCategoryHistory(clientId: number): Observable<any[]> {
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
}
// ═══ Risk-category changes (maker-checker) ═══
//
// The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so
// it moves only once a checker agrees rather than on one officer's say-so.
/** @param requestedCategory 1 Low, 2 Medium, 3 High */
requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable<any> {
return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason });
}
getRiskChangeRequests(status?: string): Observable<any[]> {
const params = status ? new HttpParams().set('status', status) : undefined;
return this.http.get<any[]>('/aml/risk-change-requests', { params });
}
approveRiskChange(requestId: number): Observable<any> {
return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {});
}
rejectRiskChange(requestId: number, rejectionReason: string): Observable<any> {
return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason });
}
/**
* The latest behaviour window for each customer, as the nightly job computed it.
*
* One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report
* prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of
* transactions that happened to trip a rule.
*/
getBehaviourWindows(): Observable<any[]> {
return this.http.get<any[]>('/risk-review/behaviour-windows');
}
}
-7
View File
@@ -13,7 +13,6 @@ import { Routes, RouterModule } from '@angular/router';
// Not Found Component
import { NotFoundComponent } from './not-found/not-found.component';
import { CallbackComponent } from './zitadel/callback/callback.component';
import { PlatformMenuAccessComponent } from './platform-admin/menu-access/menu-access.component';
/**
* Fallback to this route when no prior route is matched.
@@ -23,12 +22,6 @@ const routes: Routes = [
path: 'callback',
component: CallbackComponent
},
{
// Platform-operator only — outside the normal login/tenant flow on purpose, see
// PlatformMenuAccessComponent's own doc comment for why that is safe.
path: 'platform-admin/menu-access',
component: PlatformMenuAccessComponent
},
{
path: '**',
component: NotFoundComponent
-8
View File
@@ -62,9 +62,6 @@ import { RbiCircularsModule } from './rbi-circulars/rbi-circulars.module';
import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
import { RdDashboardModule } from './rd-dashboard/rd-dashboard.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -97,7 +94,6 @@ import { TokenInterceptor as ZitadelTokenInterceptor } from './zitadel/token.int
import { AuthService } from './zitadel/auth.service';
import { environment } from '../environments/environment';
import { CallbackComponent } from './zitadel/callback/callback.component';
import { LoadingScreenComponent } from './shared/loading-screen/loading-screen.component';
import { OAuthModule } from 'angular-oauth2-oidc';
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
@@ -178,9 +174,6 @@ export function HttpLoaderFactory(http: HttpClient) {
CfcfrmsModule,
BankCalendarModule,
DeafModule,
SavingsDailyInterestModule,
FdDashboardModule,
RdDashboardModule,
DicgcModule,
FiuModule,
GlReportsModule,
@@ -198,7 +191,6 @@ export function HttpLoaderFactory(http: HttpClient) {
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
LoadingScreenComponent,
OAuthModule.forRoot()
],
providers: [
@@ -44,11 +44,8 @@
<div class="cal-stat sat2nd4th">
<fa-icon icon="calendar-times" class="cal-stat__icon"></fa-icon>
<div class="cal-stat__info">
<div class="cal-stat__value">{{ overallStats.sat2nd4th + overallStats.weeklyOff }}</div>
<div class="cal-stat__label">
{{ saturdayIsWorkingDay ? '2nd & 4th Sat' : 'Saturdays'
}}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }}
</div>
<div class="cal-stat__value">{{ overallStats.sat2nd4th }}</div>
<div class="cal-stat__label">2nd &amp; 4th Sat</div>
</div>
</div>
<div class="cal-stat holidays">
@@ -72,11 +69,7 @@
<div class="cal-legend__items">
<div class="cal-legend__item"><span class="cal-legend__dot working"></span> Working Day</div>
<div class="cal-legend__item"><span class="cal-legend__dot sunday"></span> Sunday</div>
<div class="cal-legend__item">
<span class="cal-legend__dot sat2nd4th"></span>
{{ saturdayIsWorkingDay ? '2nd / 4th Saturday' : 'Saturday'
}}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }}
</div>
<div class="cal-legend__item"><span class="cal-legend__dot sat2nd4th"></span> 2nd / 4th Saturday</div>
<div class="cal-legend__item"><span class="cal-legend__dot holiday"></span> Holiday</div>
<div class="cal-legend__item"><span class="cal-legend__dot today"></span> Today</div>
</div>
@@ -326,9 +319,7 @@
[class.cal-grid__day--other]="!day.isCurrentMonth"
[class.cal-grid__day--today]="day.isToday"
[class.cal-grid__day--sunday]="day.isSunday && day.isCurrentMonth"
[class.cal-grid__day--sat2nd4th]="
(day.is2ndSaturday || day.is4thSaturday || day.isWeeklyOff) && day.isCurrentMonth
"
[class.cal-grid__day--sat2nd4th]="(day.is2ndSaturday || day.is4thSaturday) && day.isCurrentMonth"
[class.cal-grid__day--holiday]="day.isHoliday && day.isCurrentMonth"
[class.cal-grid__day--working]="day.isWorking && day.isCurrentMonth"
[matTooltip]="
@@ -337,11 +328,9 @@
? '2nd Saturday Off'
: day.is4thSaturday
? '4th Saturday Off'
: day.isWeeklyOff && day.isCurrentMonth
? weekDays[day.dayOfWeek] + ' Off (Working Days)'
: day.isSunday && day.isCurrentMonth
? 'Sunday'
: '')
: day.isSunday && day.isCurrentMonth
? 'Sunday'
: '')
"
>
{{ day.isCurrentMonth ? day.date : '' }}
@@ -354,15 +343,10 @@
@if (month.holidays > 0) {
<span class="summary-tag holiday">{{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }}</span>
}
@if (month.sundays > 0) {
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
}
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
@if (month.secondFourthSat > 0) {
<span class="summary-tag sat">{{ month.secondFourthSat }} Sat Off</span>
}
@if (month.weeklyOff > 0) {
<span class="summary-tag sat">{{ month.weeklyOff }} Weekly Off</span>
}
</div>
</div>
}
@@ -63,8 +63,6 @@ interface CalendarDay {
isSunday: boolean;
is2ndSaturday: boolean;
is4thSaturday: boolean;
/** Off every week because Admin > Working Days leaves this weekday out (Sunday aside). */
isWeeklyOff: boolean;
holidayName?: string;
dayOfWeek: number; // 0=Sun, 6=Sat
}
@@ -78,23 +76,9 @@ interface MonthData {
nonWorkingDays: number;
holidays: number;
sundays: number;
/** Saturdays off: the 2nd and 4th when Saturday is a working day, every one when it is not. */
secondFourthSat: number;
/** Other weekdays off every week because Working Days leaves them out. */
weeklyOff: number;
}
/** Weekday codes in Date#getDay() order, as used by the /workingdays recurrence (BYDAY=MO,TU,...). */
const DAY_CODES = [
'SU',
'MO',
'TU',
'WE',
'TH',
'FR',
'SA'
];
@Component({
selector: 'mifosx-bank-calendar',
templateUrl: './bank-calendar.component.html',
@@ -134,7 +118,7 @@ export class BankCalendarComponent implements OnInit {
];
workingDaysConfig: string[] = [];
holidays: any[] = [];
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, weeklyOff: 0, totalDays: 0 };
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
// ───── Holiday list (office-wise) ─────
offices: OfficeLite[] = [];
@@ -218,8 +202,7 @@ export class BankCalendarComponent implements OnInit {
}
private loadWorkingDays() {
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off), used until
// Admin > Working Days answers, or if it cannot be read.
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off)
this.workingDaysConfig = [
'MO',
'TU',
@@ -232,19 +215,6 @@ export class BankCalendarComponent implements OnInit {
this.buildCalendar();
this.rebuildHolidayRows();
// The calendar has to agree with Admin > Working Days: a weekday unticked there is off every
// week here too.
this.organizationService.getWorkingDays().subscribe({
next: (data: any) => {
const configured = this.parseWorkingDays(data?.recurrence);
if (configured.length > 0) {
this.workingDaysConfig = configured;
this.buildCalendar();
}
},
error: () => {}
});
// Pull offices then fan-out a per-office holiday fetch so each office's
// calendar entries are surfaced in the list panel (calendar grid still
// unions everything for the year-at-a-glance view).
@@ -303,23 +273,6 @@ export class BankCalendarComponent implements OnInit {
});
}
/** Weekday codes out of a recurrence such as "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR". */
private parseWorkingDays(recurrence: string | undefined): string[] {
const byDay = /BYDAY=([^;]*)/.exec(recurrence || '');
if (!byDay) {
return [];
}
return byDay[1]
.split(',')
.map((d) => d.trim().toUpperCase())
.filter((d) => DAY_CODES.includes(d));
}
/** True when Admin > Working Days counts Saturday as a working day (so only the 2nd and 4th are off). */
get saturdayIsWorkingDay(): boolean {
return this.workingDaysConfig.includes('SA');
}
/** Flatten the holidays array into a presentable list with office labels. */
private rebuildHolidayRows(): void {
this.holidayRows = this.holidays.map((h: any) => {
@@ -558,15 +511,7 @@ export class BankCalendarComponent implements OnInit {
buildCalendar() {
this.months = [];
this.overallStats = {
working: 0,
nonWorking: 0,
holidays: 0,
sundays: 0,
sat2nd4th: 0,
weeklyOff: 0,
totalDays: 0
};
this.overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
for (let m = 0; m < 12; m++) {
const monthData = this.buildMonth(m, this.currentYear);
@@ -576,7 +521,6 @@ export class BankCalendarComponent implements OnInit {
this.overallStats.holidays += monthData.holidays;
this.overallStats.sundays += monthData.sundays;
this.overallStats.sat2nd4th += monthData.secondFourthSat;
this.overallStats.weeklyOff += monthData.weeklyOff;
}
this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking;
}
@@ -608,7 +552,6 @@ export class BankCalendarComponent implements OnInit {
let holidayCount = 0;
let sundayCount = 0;
let sat2nd4thCount = 0;
let weeklyOffCount = 0;
// Previous month padding
const prevMonthDays = new Date(year, month, 0).getDate();
@@ -624,7 +567,6 @@ export class BankCalendarComponent implements OnInit {
isSunday: false,
is2ndSaturday: false,
is4thSaturday: false,
isWeeklyOff: false,
dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7
});
}
@@ -634,29 +576,24 @@ export class BankCalendarComponent implements OnInit {
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d);
const dow = date.getDay();
const isSunday = dow === 0;
const isSaturday = dow === 6;
const isWorkingWeekday = this.workingDaysConfig.includes(DAY_CODES[dow]);
const isSunday = dow === 0 && !isWorkingWeekday;
if (isSaturday) saturdayCount++;
// RBI: when Saturday is a working day the 2nd and 4th are still off. When Working Days leaves
// Saturday out, every Saturday is a weekly off instead.
const is2ndSat = isSaturday && isWorkingWeekday && saturdayCount === 2;
const is4thSat = isSaturday && isWorkingWeekday && saturdayCount === 4;
const isWeeklyOff = dow !== 0 && !isWorkingWeekday;
const is2ndSat = isSaturday && saturdayCount === 2;
const is4thSat = isSaturday && saturdayCount === 4;
const isToday = date.toDateString() === today.toDateString();
// Check if holiday
const holiday = this.getHolidayForDate(year, month, d);
const isHoliday = !!holiday;
// Non-working: Sunday, 2nd Sat, 4th Sat, a weekly off from Working Days, or holiday
const isNonWorking = isSunday || is2ndSat || is4thSat || isWeeklyOff || isHoliday;
// Non-working: Sunday, 2nd Sat, 4th Sat, or holiday
const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday;
if (isSunday) sundayCount++;
if (is2ndSat || is4thSat || (isSaturday && isWeeklyOff)) sat2nd4thCount++;
if (isWeeklyOff && !isSaturday) weeklyOffCount++;
if (is2ndSat || is4thSat) sat2nd4thCount++;
if (isHoliday) holidayCount++;
if (isNonWorking) {
@@ -676,7 +613,6 @@ export class BankCalendarComponent implements OnInit {
isSunday,
is2ndSaturday: is2ndSat,
is4thSaturday: is4thSat,
isWeeklyOff,
holidayName: holiday?.name,
dayOfWeek: dow
});
@@ -696,7 +632,6 @@ export class BankCalendarComponent implements OnInit {
isSunday: false,
is2ndSaturday: false,
is4thSaturday: false,
isWeeklyOff: false,
dayOfWeek: days.length % 7
});
}
@@ -710,8 +645,7 @@ export class BankCalendarComponent implements OnInit {
nonWorkingDays: nonWorkingCount,
holidays: holidayCount,
sundays: sundayCount,
secondFourthSat: sat2nd4thCount,
weeklyOff: weeklyOffCount
secondFourthSat: sat2nd4thCount
};
}
@@ -1,154 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="bor-root">
<div class="bor-head">
<div class="bor-head-left">
<fa-icon icon="gavel" class="bor-head-ic"></fa-icon>
<div>
<h2 class="bor-head-t">Board of Resolution Register — Share Capital</h2>
<p class="bor-head-s">
Every share account with the board resolution that authorised the allotment. Accounts with no resolution on
record sort to the top.
</p>
</div>
</div>
<button
class="bor-btn bor-btn--xls"
type="button"
[disabled]="exporting || !filtered.length"
(click)="exportExcel()"
>
<fa-icon icon="file-excel"></fa-icon>
{{ exporting ? 'Exporting…' : 'Export' }}
</button>
</div>
@if (loading) {
<div class="bor-note">Loading the register…</div>
} @else if (loadError) {
<div class="bor-note bor-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ loadError }}</div>
}
@if (!loading && !loadError) {
<!-- Counted over the whole register, not the filtered view: it is a standing figure the
secretary acts on, and hiding it behind a filter would hide the problem. -->
@if (missingResolutionCount > 0) {
<div class="bor-note bor-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
{{ missingResolutionCount }} share account{{ missingResolutionCount === 1 ? '' : 's' }} carr{{
missingResolutionCount === 1 ? 'ies' : 'y'
}}
no resolution number. Share capital allotted without a board resolution cannot be evidenced to the Registrar.
</div>
}
<div class="bor-kpis">
<div class="bor-kpi">
<div class="bor-kpi-n">{{ filtered.length | number }}</div>
<div class="bor-kpi-l">Share Accounts</div>
</div>
<div class="bor-kpi bor-kpi--blue">
<div class="bor-kpi-n">{{ distinctResolutions | number }}</div>
<div class="bor-kpi-l">Resolutions</div>
</div>
<div class="bor-kpi bor-kpi--green">
<div class="bor-kpi-n">{{ totalShares | number }}</div>
<div class="bor-kpi-l">Shares Allotted</div>
</div>
<div class="bor-kpi bor-kpi--amber">
<div class="bor-kpi-n">&#8377;{{ totalValue | number: '1.0-0' }}</div>
<div class="bor-kpi-l">Share Value</div>
</div>
</div>
<div class="bor-filters">
<div class="bor-filter">
<label>Resolution No.</label>
<input class="bor-inp" type="text" [(ngModel)]="filterResolution" placeholder="e.g. BOR/2026-27/001" />
</div>
<div class="bor-filter">
<label>Branch</label>
<select class="bor-inp" [(ngModel)]="filterBranch">
<option value="">All branches</option>
@for (b of branches; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
<div class="bor-filter">
<label>Status</label>
<select class="bor-inp" [(ngModel)]="filterStatus">
<option [ngValue]="''">Any status</option>
@for (s of statuses; track s) {
<option [ngValue]="s">{{ statusText(s) }}</option>
}
</select>
</div>
<div class="bor-filter bor-filter--grow">
<label>Search</label>
<input class="bor-inp" type="text" [(ngModel)]="search" placeholder="Member name, share A/c or CIF…" />
</div>
<label class="bor-check">
<input type="checkbox" [(ngModel)]="missingOnly" />
No resolution only
</label>
<button class="bor-btn" type="button" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
<div class="bor-table-wrap">
<table class="bor-table">
<thead>
<tr>
<th>Resolution No.</th>
<th>Resolution Date</th>
<th>Share A/c No.</th>
<th>CIF No.</th>
<th>Member Name</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Shares</th>
<th class="num">Share Value</th>
<th class="num">Tranches</th>
<th>Status</th>
<th>Activated On</th>
</tr>
</thead>
<tbody>
@for (r of filtered; track r.shareAccountId) {
<tr [class.bor-row--missing]="!r.resolutionNumber">
<td>
@if (r.resolutionNumber) {
<strong class="bor-res">{{ r.resolutionNumber }}</strong>
} @else {
<span class="bor-missing">Not recorded</span>
}
</td>
<td class="mono">{{ r.resolutionDate || '—' }}</td>
<td class="mono">{{ r.accountNo }}</td>
<td class="mono">{{ r.cifNumber || '—' }}</td>
<td>{{ r.clientName }}</td>
<td>{{ r.officeName || '—' }}</td>
<td>{{ r.productName || '—' }}</td>
<td class="num">{{ r.approvedShares | number }}</td>
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
<td class="num">{{ r.trancheCount }}</td>
<td>
<span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span>
</td>
<td class="mono">{{ r.activatedDate || '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="12" class="bor-empty">No share account matches the filters.</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -1,313 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.bor-root {
padding: 20px 24px 40px;
background: #f8fafc;
min-height: 100%;
}
.bor-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.bor-head-left {
display: flex;
align-items: flex-start;
gap: 12px;
}
.bor-head-ic {
font-size: 26px;
color: #0f4f3c;
margin-top: 2px;
}
.bor-head-t {
margin: 0 0 3px;
font-size: 20px;
font-weight: 700;
color: #1e293b;
}
.bor-head-s {
margin: 0;
max-width: 780px;
font-size: 13px;
line-height: 1.5;
color: #64748b;
}
.bor-note {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 11px 14px;
margin-bottom: 14px;
border: 1px solid #bfdbfe;
border-radius: 9px;
background: #eff6ff;
color: #1e3a8a;
font-size: 12.5px;
line-height: 1.55;
fa-icon {
flex: 0 0 auto;
margin-top: 1px;
}
/* An allotment with no resolution behind it is the finding, not a detail. */
&--warn {
border-color: #fde68a;
background: #fffbeb;
color: #92400e;
fa-icon {
color: #d97706;
}
}
}
.bor-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.bor-kpi {
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #0f4f3c;
border-radius: 11px;
padding: 12px 16px;
&--blue {
border-left-color: #2563eb;
}
&--green {
border-left-color: #15803d;
}
&--amber {
border-left-color: #d97706;
}
}
.bor-kpi-n {
font-size: 22px;
font-weight: 700;
color: #1e293b;
line-height: 1.2;
}
.bor-kpi-l {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
.bor-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
padding: 14px 16px;
margin-bottom: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
}
.bor-filter {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&--grow {
flex: 1 1 240px;
}
}
.bor-inp {
height: 34px;
min-width: 170px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 7px;
font: inherit;
font-size: 13px;
color: #1e293b;
background: #fff;
&:focus {
outline: 2px solid #2563eb;
outline-offset: -1px;
}
}
.bor-check {
display: flex;
align-items: center;
gap: 6px;
height: 34px;
font-size: 12.5px;
color: #334155;
cursor: pointer;
}
.bor-btn {
display: inline-flex;
align-items: center;
gap: 7px;
height: 34px;
padding: 0 14px;
border: 1px solid #cbd5e1;
border-radius: 7px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
&:hover:not(:disabled) {
background: #f1f5f9;
}
&:disabled {
opacity: 0.55;
cursor: default;
}
&--xls {
border-color: #15803d;
background: #16a34a;
color: #fff;
&:hover:not(:disabled) {
background: #15803d;
}
}
}
.bor-table-wrap {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
overflow-x: auto;
}
.bor-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 10px;
background: #f1f5f9;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #475569;
white-space: nowrap;
border-bottom: 1px solid #e2e8f0;
}
td {
padding: 11px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
color: #334155;
}
.num {
text-align: right;
font-family: 'DM Mono', monospace;
}
.mono {
font-family: 'DM Mono', monospace;
}
.strong {
font-weight: 700;
}
tbody tr:hover {
background: #f8fafc;
}
}
/* The row an auditor is looking for, marked in the table as well as counted above it. */
.bor-row--missing {
background: #fffbeb;
}
.bor-res {
color: #0f4f3c;
font-family: 'DM Mono', monospace;
}
.bor-missing {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-size: 11px;
font-weight: 700;
}
.bor-status {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
&--active {
background: #dcfce7;
color: #15803d;
}
&--submitted,
&--approved {
background: #fef3c7;
color: #b45309;
}
&--rejected,
&--closed {
background: #fee2e2;
color: #b91c1c;
}
}
.bor-empty {
padding: 30px;
text-align: center;
color: #94a3b8;
}
@@ -1,221 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { exportWorkbook } from '../cashier/cashier-export';
import { BorRegisterRow, BorReportService } from './bor-report.service';
/** m_share_account.status_enum, as the register labels it. */
const STATUS_LABEL: Record<number, string> = {
100: 'Submitted',
200: 'Approved',
300: 'Active',
400: 'Rejected',
500: 'Closed',
600: 'Inactive'
};
/**
* The Board of Resolution register for share capital.
*
* A co-operative allots shares only under a board resolution, and this is the list the Registrar or
* an auditor asks for: every member's holding against the resolution that authorised it. The
* account with no resolution recorded is the point of the register, so it sorts to the top and is
* counted on its own rather than being left to be noticed.
*/
@Component({
selector: 'mifosx-bor-report',
templateUrl: './bor-report.component.html',
styleUrls: ['./bor-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class BorReportComponent implements OnInit {
private borService = inject(BorReportService);
readonly statusLabel = STATUS_LABEL;
rows: BorRegisterRow[] = [];
loading = false;
loadError = '';
/** '' = every status. */
filterStatus: number | '' = '';
filterBranch = '';
filterResolution = '';
search = '';
/** Only the accounts with no resolution on file — the ones that need chasing. */
missingOnly = false;
exporting = false;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.borService.getRegister().subscribe({
next: (rows) => {
this.loading = false;
this.rows = rows;
},
error: (err: any) => {
this.loading = false;
this.rows = [];
this.loadError = err?.error?.defaultUserMessage || 'Could not load the BOR register.';
}
});
}
/** Branches present in the data, so the filter offers only what can actually be selected. */
get branches(): string[] {
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
}
get statuses(): number[] {
return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b);
}
get filtered(): BorRegisterRow[] {
const needle = this.search.trim().toLowerCase();
const resolutionNeedle = this.filterResolution.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.filterStatus !== '' && r.statusEnum !== this.filterStatus) {
return false;
}
if (this.filterBranch && r.officeName !== this.filterBranch) {
return false;
}
if (this.missingOnly && r.resolutionNumber) {
return false;
}
if (resolutionNeedle && !r.resolutionNumber.toLowerCase().includes(resolutionNeedle)) {
return false;
}
if (
needle &&
!(
r.clientName.toLowerCase().includes(needle) ||
r.accountNo.toLowerCase().includes(needle) ||
r.cifNumber.toLowerCase().includes(needle)
)
) {
return false;
}
return true;
});
}
get totalShares(): number {
return this.filtered.reduce((s, r) => s + r.approvedShares, 0);
}
get totalValue(): number {
return this.filtered.reduce((s, r) => s + r.shareValue, 0);
}
/** How many resolutions the filtered holdings were authorised under. */
get distinctResolutions(): number {
return new Set(this.filtered.map((r) => r.resolutionNumber).filter(Boolean)).size;
}
get missingResolutionCount(): number {
return this.rows.filter((r) => !r.resolutionNumber).length;
}
statusText(statusEnum: number): string {
return STATUS_LABEL[statusEnum] || '—';
}
statusClass(statusEnum: number): string {
return 'bor-status bor-status--' + (STATUS_LABEL[statusEnum] || 'unknown').toLowerCase();
}
resetFilters(): void {
this.filterStatus = '';
this.filterBranch = '';
this.filterResolution = '';
this.search = '';
this.missingOnly = false;
}
async exportExcel(): Promise<void> {
this.exporting = true;
try {
const rows = this.filtered;
await exportWorkbook(
[
{
sheetName: 'BOR Register',
title: 'Board of Resolution Register — Share Capital',
subtitle: `${rows.length} share account(s) · ${this.distinctResolutions} resolution(s) · ${this.totalShares} shares`,
headers: [
'Resolution No.',
'Resolution Date',
'Share A/c No.',
'CIF No.',
'Member Name',
'Branch',
'Product',
'Shares',
'Unit Price',
'Share Value',
'Tranches',
'Status',
'Activated On'
],
widths: [
20,
16,
18,
16,
30,
18,
18,
10,
12,
14,
10,
12,
14
],
rows: rows.map((r) => [
// Named rather than left blank, because an empty cell in a filing reads as an
// oversight in the export rather than a gap in the register.
r.resolutionNumber || 'NOT RECORDED',
r.resolutionDate || '—',
r.accountNo,
r.cifNumber,
r.clientName,
r.officeName,
r.productName,
r.approvedShares,
r.unitPrice,
r.shareValue,
r.trancheCount,
this.statusText(r.statusEnum),
r.activatedDate || '—'
])
}
],
'BOR-Register'
);
} finally {
this.exporting = false;
}
}
}
@@ -1,94 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
/** Custom Helpers */
import { toIsoDate } from '../core/utils/api-date';
/** One share account and the board resolution that authorised it. */
export interface BorRegisterRow {
shareAccountId: number;
accountNo: string;
clientId: number;
cifNumber: string;
clientName: string;
officeId: number | null;
officeName: string;
productName: string;
currencyCode: string;
statusEnum: number;
approvedShares: number;
pendingShares: number;
unitPrice: number;
shareValue: number;
resolutionNumber: string;
/** ISO date, or '' where no meeting date was ever recorded against the allotment. */
resolutionDate: string;
/** How many purchase tranches make up the holding. */
trancheCount: number;
submittedDate: string;
approvedDate: string;
activatedDate: string;
closedDate: string;
}
/**
* The BOR register.
*
* One call returns every account with its resolution. The share allotment report next door fetches
* each account separately and stops at two hundred, which is a limit on a register rather than on a
* page of one.
*/
@Injectable({ providedIn: 'root' })
export class BorReportService {
private http = inject(HttpClient);
getRegister(status?: number, officeId?: number): Observable<BorRegisterRow[]> {
let params = new HttpParams();
if (status != null) {
params = params.set('status', String(status));
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
return this.http
.get<any[]>('/shareaccounts/bor-register', { params })
.pipe(map((rows) => (rows || []).map((r) => this.toRow(r))));
}
/** Dates arrive as [y, M, d] arrays; flattened to ISO so they sort and filter as strings. */
private toRow(r: any): BorRegisterRow {
return {
shareAccountId: r.shareAccountId,
accountNo: r.accountNo || '',
clientId: r.clientId,
cifNumber: r.cifNumber || '',
clientName: r.clientName || '',
officeId: r.officeId ?? null,
officeName: r.officeName || '',
productName: r.productName || '',
currencyCode: r.currencyCode || 'INR',
statusEnum: Number(r.statusEnum) || 0,
approvedShares: Number(r.approvedShares) || 0,
pendingShares: Number(r.pendingShares) || 0,
unitPrice: Number(r.unitPrice) || 0,
shareValue: Number(r.shareValue) || 0,
resolutionNumber: (r.resolutionNumber || '').trim(),
resolutionDate: toIsoDate(r.resolutionDate),
trancheCount: Number(r.trancheCount) || 0,
submittedDate: toIsoDate(r.submittedDate),
approvedDate: toIsoDate(r.approvedDate),
activatedDate: toIsoDate(r.activatedDate),
closedDate: toIsoDate(r.closedDate)
};
}
}
@@ -1,169 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg ar-print">
<header class="pg-head">
<div>
<div class="pg-eyebrow">REPORTS · ACCOUNT REGISTER</div>
<h1 class="pg-title">Account Register</h1>
<p class="pg-sub">
Every account as it stood on the chosen date — which ones opened and closed that day, the balance each held
then, and what it holds now.
</p>
</div>
<div class="pg-toolbar ar-noprint">
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
<mat-icon>chevron_left</mat-icon>
</button>
<label class="pg-field">
<span>As on date</span>
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
</label>
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
<mat-icon>chevron_right</mat-icon>
</button>
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
<!-- Shown only on paper, where the toolbar is hidden and the date has to be stated. -->
<p class="ar-printhead">Account Register as on {{ date }} · {{ section }}</p>
<div class="db-filters ar-noprint">
<label class="pg-field">
<span>Section</span>
<select [(ngModel)]="section" (change)="load()">
@for (s of sections; track s.value) {
<option [value]="s.value">{{ s.label }}</option>
}
</select>
</label>
<label class="pg-field ar-check">
<span>Movement</span>
<span class="ar-check__box">
<input type="checkbox" [(ngModel)]="movementOnly" />
Only accounts opened / closed on this date
</span>
</label>
<label class="pg-field db-filters__search">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Account no, client, product, status…" />
</label>
<button type="button" class="pg-btn" (click)="printRegister()" [disabled]="filteredRows.length === 0">
<mat-icon>print</mat-icon> Print
</button>
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
<mat-icon>download</mat-icon> CSV
</button>
</div>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded) {
<!-- What actually happened that day, read before the list itself. -->
<div class="ar-summary">
<div class="ar-summary__item ar-summary__item--in">
<span class="ar-summary__label">Accounts opened on {{ date }}</span>
<span class="ar-summary__value">{{ openedCount }}</span>
</div>
<div class="ar-summary__item ar-summary__item--out">
<span class="ar-summary__label">Accounts closed on {{ date }}</span>
<span class="ar-summary__value">{{ closedCount }}</span>
</div>
<div class="ar-summary__item">
<span class="ar-summary__label">Balance on {{ date }}</span>
<span class="ar-summary__value">₹{{ shownBalanceOnDate | number: '1.2-2' }}</span>
</div>
<div class="ar-summary__item">
<span class="ar-summary__label">Current balance</span>
<span class="ar-summary__value">₹{{ shownCurrentBalance | number: '1.2-2' }}</span>
</div>
</div>
}
@if (!loading && loaded && filteredRows.length === 0) {
<div class="pg-empty">
<mat-icon>account_balance_wallet</mat-icon>
<p>
@if (movementOnly) {
No account was opened or closed on {{ date }}.
} @else {
No accounts existed on {{ date }}.
}
</p>
</div>
}
@if (filteredRows.length > 0) {
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Section</th>
<th>Client</th>
<th>Product</th>
<th>Status</th>
<th>Opened</th>
<th>Closed</th>
<th class="num">Balance on {{ date }}</th>
<th class="num">Current Balance</th>
</tr>
</thead>
<tbody>
@for (r of filteredRows; track r.section + r.accountId) {
<tr [class.ar-row--opened]="r.openedOnDate" [class.ar-row--closed]="r.closedOnDate">
<td>
<span class="pg-chip">{{ r.accountNo }}</span>
@if (r.openedOnDate) {
<span class="ar-tag ar-tag--in">OPENED</span>
}
@if (r.closedOnDate) {
<span class="ar-tag ar-tag--out">CLOSED</span>
}
</td>
<td>{{ r.section }}</td>
<td>{{ r.clientName ?? '—' }}</td>
<td>{{ r.productName ?? '—' }}</td>
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.openedOn ?? '—' }}</td>
<td>{{ r.closedOn ?? '—' }}</td>
<td class="num">₹{{ r.balanceOnDate | number: '1.2-2' }}</td>
<td class="num">
₹{{ r.currentBalance | number: '1.2-2' }}
@if (r.section === 'SHARE' && r.sharesHeld) {
<span class="db-muted">· {{ r.sharesHeld }} sh</span>
}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7">
{{ filteredRows.length }} account(s) · {{ openedCount }} opened · {{ closedCount }} closed
</td>
<td class="num">₹{{ shownBalanceOnDate | number: '1.2-2' }}</td>
<td class="num">₹{{ shownCurrentBalance | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -1,144 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* Page chrome comes from cashier-shared.scss; badges, filters and muted text are shared with the
day book. Only what belongs to the account register lives here. */
.ar-summary {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
&__item {
flex: 1 1 180px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
&--in {
border-left: 4px solid #16a34a;
}
&--out {
border-left: 4px solid #dc2626;
}
}
&__label {
display: block;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
&__value {
display: block;
margin-top: 4px;
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0d1b2a;
}
}
/* Checkbox sits inside a pg-field, which is laid out for inputs — this keeps the label and box on
one line without fighting that. */
.ar-check {
&__box {
display: flex;
align-items: center;
gap: 6px;
height: 38px;
font-size: 12px;
color: #475569;
white-space: nowrap;
}
}
/* Marks the accounts that actually moved on the chosen date — the reason someone opens a register
for a past day at all. */
.ar-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.04em;
&--in {
background: #dcfce7;
color: #15803d;
}
&--out {
background: #fee2e2;
color: #b91c1c;
}
}
.ar-row--opened td:first-child {
box-shadow: inset 3px 0 0 #22c55e;
}
.ar-row--closed td:first-child {
box-shadow: inset 3px 0 0 #ef4444;
}
/* Only shown on paper: the screen already states the date in the toolbar. */
.ar-printhead {
display: none;
}
/* A register is meant to be printed and filed, so paper gets its own treatment: the toolbar and
filters go, the table gains borders, and rows are not split across pages. */
@media print {
.ar-noprint {
display: none !important;
}
.ar-printhead {
display: block;
margin: 0 0 10px;
font-size: 13px;
font-weight: 800;
color: #000;
}
.pg {
padding: 0;
}
.ar-summary__item {
border: 1px solid #999;
}
.pg-table {
font-size: 10px;
th,
td {
border: 1px solid #999;
padding: 4px 6px;
color: #000;
}
}
tr {
page-break-inside: avoid;
}
/* Table headers repeat on every page, so a multi-page register stays readable. */
thead {
display: table-header-group;
}
}
@@ -1,222 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface RegisterRow {
accountId: number;
accountNo: string;
section: 'SAVINGS' | 'FD' | 'RD' | 'SHARE';
clientName: string | null;
productName: string | null;
status: string;
openedOn: string | null;
closedOn: string | null;
/** True when this account was opened (or closed) on the very date being viewed. */
openedOnDate: boolean;
closedOnDate: boolean;
/** The account's balance as it stood on the register's date, for every section. */
balanceOnDate: number | null;
currentBalance: number | null;
sharesHeld?: number;
}
/**
* Account register for one date: which accounts opened and closed that day, each account's balance
* as it stood then, and what it holds now.
*/
@Component({
selector: 'mifosx-account-register',
templateUrl: './account-register.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./account-register.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class AccountRegisterComponent implements OnInit {
private service = inject(CashierService);
date = '';
section: 'ALL' | 'SAVINGS' | 'FD' | 'RD' | 'SHARE' = 'ALL';
search = '';
/** Narrows to just the accounts that opened or closed on the chosen date. */
movementOnly = false;
rows: RegisterRow[] = [];
openedCount = 0;
closedCount = 0;
totalBalanceOnDate = 0;
totalCurrentBalance = 0;
loading = false;
loaded = false;
errorMsg = '';
readonly sections = [
{ value: 'ALL', label: 'All accounts' },
{ value: 'SAVINGS', label: 'Savings' },
{ value: 'FD', label: 'Fixed Deposit' },
{ value: 'RD', label: 'Recurring Deposit' },
{ value: 'SHARE', label: 'Share' }
];
ngOnInit(): void {
this.date = this.today();
this.load();
}
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
load(): void {
if (!this.date) return;
this.loading = true;
this.errorMsg = '';
this.service
.accountRegister(this.date, this.section)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.message ??
'Could not load the account register.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.rows = res?.accounts ?? [];
this.openedCount = Number(res?.openedCount ?? 0);
this.closedCount = Number(res?.closedCount ?? 0);
this.totalBalanceOnDate = Number(res?.totalBalanceOnDate ?? 0);
this.totalCurrentBalance = Number(res?.totalCurrentBalance ?? 0);
});
}
shiftDate(days: number): void {
const [
y,
m,
d
] = this.date.split('-').map(Number);
const dt = new Date(y, m - 1, d + days);
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
this.load();
}
goToday(): void {
this.date = this.today();
this.load();
}
/** Free-text narrowing over the rows already fetched — no round trip per keystroke. */
get filteredRows(): RegisterRow[] {
const base = this.movementOnly ? this.rows.filter((r) => r.openedOnDate || r.closedOnDate) : this.rows;
const q = this.search.trim().toLowerCase();
if (!q) return base;
return base.filter((r) =>
[
r.accountNo,
r.clientName,
r.productName,
r.status,
r.section
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
);
}
/** Totals for what is on screen, so a filter or search still reconciles. */
get shownBalanceOnDate(): number {
return this.filteredRows.reduce((s, r) => s + Number(r.balanceOnDate ?? 0), 0);
}
get shownCurrentBalance(): number {
return this.filteredRows.reduce((s, r) => s + Number(r.currentBalance ?? 0), 0);
}
statusClass(status: string): string {
switch (status) {
case 'ACTIVE':
return 'db-badge db-badge--ok';
case 'CLOSED':
case 'PREMATURELY CLOSED':
case 'REJECTED':
case 'WITHDRAWN':
return 'db-badge db-badge--rejected';
case 'SUBMITTED':
case 'APPROVED':
return 'db-badge db-badge--pending';
default:
return 'db-badge';
}
}
exportCsv(): void {
const header = [
'Account No',
'Section',
'Client',
'Product',
'Status',
'Opened On',
'Closed On',
`Balance on ${this.date}`,
'Current Balance'
];
const lines = this.filteredRows.map((r) =>
[
r.accountNo,
r.section,
r.clientName ?? '',
r.productName ?? '',
r.status,
r.openedOn ?? '',
r.closedOn ?? '',
r.balanceOnDate == null ? '' : String(r.balanceOnDate),
String(r.currentBalance ?? 0)
]
// Quote every field and double embedded quotes: client names routinely contain commas.
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
const csv = [
header.join(','),
...lines
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `account-register-${this.date}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/** Hands the page to the browser's print dialog; print styles drop the toolbar and filters. */
printRegister(): void {
window.print();
}
}
@@ -1,114 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">BRANCH OPERATIONS · BRANCH CASH POSITION</div>
<h1 class="pg-title">Branch Cash Position</h1>
<p class="pg-sub">
The notes and coins the branch holds, denomination by denomination: the vault plus every cashier's drawer. Cash
moving between the vault and a cashier nets out; deposits, withdrawals and exchanges change it.
</p>
</div>
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>As of Date</mat-label>
<input matInput type="date" [value]="asOfDate" (change)="onDateChange($any($event.target).value)" />
</mat-form-field>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Refresh
</button>
</div>
@if (errorMsg) {
<div class="pg-empty bcp-error">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (position; as p) {
<div class="bcp-totals">
<div class="bcp-total">
<span>Branch</span><strong>{{ p.officeName }}</strong>
</div>
<div class="bcp-total">
<span>Vault</span><strong>₹{{ p.vaultAmount | number: '1.2-2' }}</strong>
</div>
<div class="bcp-total">
<span>Cashiers' drawers</span><strong>₹{{ p.cashiersAmount | number: '1.2-2' }}</strong>
</div>
<div class="bcp-total bcp-total--main">
<span>Bank total</span><strong>₹{{ p.totalAmount | number: '1.2-2' }}</strong>
</div>
</div>
@if (!p.vaultHasBreakdown) {
<div class="pg-empty bcp-note">
No vault denomination breakdown was recorded on or before {{ p.asOfDate }}, so the vault column reads 0. Open
the business day with a note/coin count to start tracking it.
</div>
}
<ng-template #table let-rows let-label="label" let-coin="coin">
@if (rows.length > 0) {
<div class="pos-section-label" [class.pos-section-label--coin]="coin">{{ label }}</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Vault</th>
<th class="num">Cashiers</th>
<th class="num">Bank total (count)</th>
<th class="num">Bank total (₹)</th>
<th class="num">Exchange today</th>
</tr>
</thead>
<tbody>
@for (d of rows; track d.denominationId) {
<tr>
<td>
<span class="pg-chip" [class.pg-chip--coin]="coin">₹{{ d.denominationValue }}</span>
</td>
<td class="num">{{ d.vaultCount }}</td>
<td class="num">{{ d.cashierCount }}</td>
<td class="num bcp-strong">{{ d.totalCount }}</td>
<td class="num">₹{{ d.totalAmount | number: '1.2-2' }}</td>
<td
class="num bcp-move"
[class.bcp-move--in]="d.exchangeNet > 0"
[class.bcp-move--out]="d.exchangeNet < 0"
>
{{ movement(d.exchangeNet) }}
</td>
</tr>
}
</tbody>
</table>
}
</ng-template>
<div class="pg-card bcp-card">
<ng-container
*ngTemplateOutlet="table; context: { $implicit: notes(), label: 'Notes', coin: false }"
></ng-container>
<ng-container
*ngTemplateOutlet="table; context: { $implicit: coins(), label: 'Coins', coin: true }"
></ng-container>
</div>
<p class="bcp-foot">
<strong>Exchange today</strong> is the notes taken in less the notes given out by cash exchanges on
{{ p.asOfDate }}. It is already part of the Cashiers column; it is shown beside the total so a change in the mix
can be seen.
</p>
}
</div>
@@ -1,82 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.bcp-error {
color: #dc2626;
}
.bcp-note {
margin-bottom: 12px;
text-align: left;
}
.bcp-totals {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
.bcp-total {
flex: 1 1 180px;
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
span {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
strong {
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
&--main {
background: #e6f4ee;
border-color: #1f7a4d;
strong {
color: #0f4f3c;
}
}
}
.bcp-card {
margin-bottom: 14px;
}
.bcp-strong {
font-weight: 700;
}
.bcp-move {
font-weight: 700;
color: #94a3b8;
&--in {
color: #16a34a;
}
&--out {
color: #dc2626;
}
}
.bcp-foot {
margin: 0;
font-size: 12px;
color: #64748b;
}
@@ -1,112 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface BranchDenomRow {
denominationId: number;
denominationValue: number;
kind: string;
vaultCount: number;
cashierCount: number;
totalCount: number;
totalAmount: number;
/** Notes taken in less notes given out by exchanges on the day. */
exchangeNet: number;
}
interface BranchPosition {
officeId: number;
officeName: string;
asOfDate: string;
vaultHasBreakdown: boolean;
vaultAmount: number;
cashiersAmount: number;
totalAmount: number;
denominations: BranchDenomRow[];
}
/**
* What the branch holds, denomination by denomination: the vault plus every cashier's drawer. An exchange
* (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) leaves the vault alone and changes a drawer, so
* it only shows up in this total -- +10 on ₹500, -50 on ₹100.
*/
@Component({
selector: 'mifosx-branch-cash-position',
templateUrl: './branch-cash-position.component.html',
styleUrls: [
'../cashier-shared.scss',
'../cashier-position/cashier-position.component.scss',
'./branch-cash-position.component.scss'
],
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule
]
})
export class BranchCashPositionComponent implements OnInit {
private service = inject(CashierService);
asOfDate = '';
loading = false;
errorMsg = '';
position: BranchPosition | null = null;
ngOnInit(): void {
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.position = null;
this.service
.branchCashPosition(this.asOfDate)
.pipe(catchError(() => of(null)))
.subscribe((data: BranchPosition | null) => {
this.loading = false;
if (!data) {
this.errorMsg = 'Could not load the branch cash position.';
return;
}
this.position = data;
});
}
onDateChange(value: string): void {
this.asOfDate = value;
this.load();
}
notes(): BranchDenomRow[] {
return (this.position?.denominations || []).filter((d) => d.kind !== 'coin');
}
coins(): BranchDenomRow[] {
return (this.position?.denominations || []).filter((d) => d.kind === 'coin');
}
/** "+10", "-50", or a dash when the day's exchanges did not touch the denomination. */
movement(net: number): string {
if (!net) return '—';
return net > 0 ? `+${net}` : `${net}`;
}
}
@@ -1,803 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">BRANCH OPERATIONS · {{ missedOnly ? 'MISSED CLOSE' : 'DAY REGISTER' }}</div>
<h1 class="pg-title">{{ missedOnly ? 'Missed Close Register' : 'Day Open / Day Close' }}</h1>
<p class="pg-sub">
@if (missedOnly) {
Business days that were opened and never closed. While a day stays open its closing balance never carries
forward, a back-dated entry can still be posted into it, and any cash difference on that date goes unnoticed.
} @else {
The branch's business day, opened each morning and closed each evening. A day left open never carries its
closing balance forward, and any cash difference on that date goes unnoticed — so the ones still hanging are
listed first.
}
</p>
</div>
<div class="pg-toolbar">
@if (canOpenToday && !missedOnly) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startOpen()">
<mat-icon>lock_open</mat-icon> Open Day
</button>
}
<button type="button" class="pg-btn" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="bd-success">{{ successMsg }}</div>
}
@if (!canOperate && loaded) {
<div class="ve-note">
<mat-icon>info</mat-icon>
You can see the register but not open or close a day — a Clerk, Head Cashier or Bank Manager does that, since a
cashier works inside the day and cannot declare it closed.
</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">
<div class="db-exceptions__head">
<mat-icon>warning</mat-icon>
<strong>{{ missed.length }} business day{{ missed.length === 1 ? '' : 's' }} never closed</strong>
</div>
@for (m of missed; track m.id) {
<div class="bd-missed">
<span class="pg-chip">{{ m.businessDate }}</span>
<strong class="bd-missed__age"> open {{ m.daysOpen }} day{{ m.daysOpen === 1 ? '' : 's' }} </strong>
<span class="db-muted">
opened {{ timeOnly(m.openedAt) }} by {{ m.openedByName }} · opening ₹{{ m.openingCash | number: '1.2-2' }}
</span>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--warn bd-missed__act" (click)="startClose(m)">
Close this day
</button>
}
</div>
}
</div>
}
<!-- On the missed-close screen, nothing to report is itself the answer — and has to be stated,
or the page looks like it failed to load. -->
@if (missedOnly && loaded && missed.length === 0) {
<div class="pg-empty">
<mat-icon>check_circle</mat-icon>
<p>No missed closes. Every business day opened for this branch has been closed.</p>
</div>
}
<!-- Current day -->
@if (!missedOnly && openDay) {
<div class="bd-current">
<div class="bd-current__main">
<span class="db-badge db-badge--pending">DAY OPEN</span>
<strong class="bd-current__date">{{ openDay.businessDate }}</strong>
<span class="db-muted">
opened {{ timeOnly(openDay.openedAt) }} by {{ openDay.openedByName }} · opening cash ₹{{
openDay.openingCash | number: '1.2-2'
}}
</span>
</div>
@if (canOperate) {
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="startClose(openDay)"
[disabled]="interestPending.length > 0"
>
<mat-icon>lock</mat-icon> Close Day
</button>
}
</div>
@if (interestPending.length > 0) {
<div class="bd-interest-alert">
<mat-icon>warning</mat-icon>
<span>
Savings interest posting report is not approved for <strong>{{ interestPending.join(', ') }}</strong
>. Approve it in <a routerLink="/reports/interest-posting">Reports → Interest Posting Reports</a> before
closing the day.
</span>
</div>
}
} @else if (loaded && canOperate) {
<div class="bd-current bd-current--shut">
<div class="bd-current__main">
<span class="db-badge">NO DAY OPEN</span>
<span class="db-muted">Open the business day before any cash transaction is accepted.</span>
</div>
</div>
}
<!-- Open-day form -->
@if (showOpenForm) {
<div class="db-panel bd-panel">
<div class="db-panel__head">
<strong>Open business day</strong>
<button type="button" class="db-icon-btn" (click)="cancelOpen()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="db-panel__body">
<label class="pg-field">
<span>Business date</span>
<input type="date" [(ngModel)]="openDate" />
</label>
<label class="pg-field">
<span>Opening cash (leave blank to carry forward)</span>
<input
type="number"
step="0.01"
[(ngModel)]="openingCash"
[disabled]="openDenomTotal > 0"
placeholder="Carried from last close"
/>
</label>
</div>
<p class="bd-hint">
@if (hasAutoFilledOpenCounts) {
Auto-filled below from yesterday's own closing count (₹{{ carriedForwardTotal | number: '1.2-2' }}) — correct
only the rows where today's physical count differs before opening.
} @else {
Left blank with no notes/coins counted below, yesterday's closing figure carries forward automatically — which
is what keeps the opening balance from drifting. Counting the drawer below is what actually records the
note/coin stock the branch can check a vault handout against later.
}
</p>
@if (hasAutoFilledOpenCounts && openLiveDifference !== null) {
@if (openLiveDifference === 0) {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">vs. yesterday's close</span>
<span class="db-cmp__value">Tallied</span>
</div>
} @else {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">vs. yesterday's close</span>
<span class="db-cmp__value">
{{ openLiveDifference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(openLiveDifference) | number: '1.2-2' }}
</span>
</div>
}
}
<div class="pg-card bd-denom">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="3">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td style="display: flex; flex-direction: column; align-items: center">
<div class="pg-step">
<button
type="button"
(click)="bumpCount(openCounts, d, -1)"
[disabled]="countFor(openCounts, d) === 0"
>
−
</button>
<input
type="number"
min="0"
[value]="countFor(openCounts, d)"
(input)="onCountChange(openCounts, d, $any($event.target).value)"
/>
<button type="button" (click)="bumpCount(openCounts, d, 1)">+</button>
</div>
@if (hasAutoFilledOpenCounts && countFor(openCounts, d) !== autoOpenCountFor(d)) {
<span class="db-muted bd-denom__auto">yesterday closed {{ autoOpenCountFor(d) }}</span>
}
</td>
<td class="num">₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="3">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr>
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td style="display: flex; flex-direction: column; align-items: center">
<div class="pg-step">
<button
type="button"
(click)="bumpCount(openCounts, d, -1)"
[disabled]="countFor(openCounts, d) === 0"
>
−
</button>
<input
type="number"
min="0"
[value]="countFor(openCounts, d)"
(input)="onCountChange(openCounts, d, $any($event.target).value)"
/>
<button type="button" (click)="bumpCount(openCounts, d, 1)">+</button>
</div>
@if (hasAutoFilledOpenCounts && countFor(openCounts, d) !== autoOpenCountFor(d)) {
<span class="db-muted bd-denom__auto">yesterday closed {{ autoOpenCountFor(d) }}</span>
}
</td>
<td class="num">₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
@if (openDenomTotal > 0) {
<p class="bd-hint">
Denomination total: <strong>₹{{ openDenomTotal | number: '1.2-2' }}</strong>
@if (hasAutoFilledOpenCounts) {
<button type="button" class="pg-btn bd-denom__reset" (click)="resetOpenToAutoFill()">
Reset to yesterday's count
</button>
}
</p>
}
@if (openLiveDifference !== null && openLiveDifference !== 0) {
<label class="pg-field bd-panel__wide">
<span>Why does this differ from yesterday's closing count? (required)</span>
<input
type="text"
[(ngModel)]="openDifferenceReason"
placeholder="e.g. notes exchanged for smaller denominations, extra cash added from head office"
/>
</label>
}
<div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitOpen()" [disabled]="!canSubmitOpen">
Open Day
</button>
<button type="button" class="pg-btn" (click)="cancelOpen()">Cancel</button>
</div>
</div>
}
<!-- Close-day form -->
@if (closeTarget) {
<div class="db-panel bd-panel" [class.bd-panel--late]="isLateClose">
<div class="db-panel__head">
<strong>Close business day — {{ closeTarget.businessDate }}</strong>
<button type="button" class="db-icon-btn" (click)="cancelClose()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
@if (needsForceReason) {
<p class="db-panel__warn">
This day is being closed after its own date. It will be recorded as a
<strong>force close</strong>, with the reason below kept permanently against it.
</p>
}
@if (previewLoading) {
<p class="db-muted">Working out the day's figure…</p>
}
<!-- The arithmetic, spelled out. Whoever closes the day can see where the expected figure
comes from instead of being asked to agree with a number they cannot check. -->
@if (preview) {
<div class="bd-calc">
<div class="bd-calc__row">
<span>Opening cash (carried forward)</span>
<span class="bd-calc__amt">₹{{ preview.openingCash | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--in">
<span>+ Cash deposits taken (approved)</span>
<span class="bd-calc__amt">₹{{ preview.deposits | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--out">
<span>− Cash paid out (approved withdrawals)</span>
<span class="bd-calc__amt">₹{{ preview.withdrawals | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--total">
<span>Cash the branch should be holding</span>
<span class="bd-calc__amt">₹{{ preview.expectedCash | number: '1.2-2' }}</span>
</div>
@if (preview.vaultIn > 0 || preview.settledOut > 0) {
<div class="bd-calc__memo">
Vault activity today — moves cash within the branch, not counted in the total above: ₹{{
preview.vaultIn | number: '1.2-2'
}}
handed to a cashier, ₹{{ preview.settledOut | number: '1.2-2' }} settled back to the vault.
</div>
}
</div>
}
<!-- Interest waiting to be posted as this branch closes. Posting runs as part of the close, so
anything unapproved stops it: the branch sees here what it has to approve and where, rather
than finding out from a refusal after filling in the whole count. -->
@if (preview?.fdInterestDue?.length) {
<div class="bd-calc bd-calc--fd">
<div class="bd-calc__head">
<mat-icon>savings</mat-icon>
<strong>Fixed deposit interest due today</strong>
</div>
@for (d of preview.fdInterestDue; track d.productId) {
<div class="bd-calc__row">
<span>
{{ d.productName }} · {{ d.fdType }} · {{ d.accountCount }} deposit(s)
@if (d.reportStatus === 'APPROVED' || d.reportStatus === 'POSTED') {
<span class="bd-fd-ok">{{ d.reportStatus }}</span>
} @else {
<span class="bd-fd-pending">{{ d.reportStatus === 'NONE' ? 'NO REPORT' : d.reportStatus }}</span>
}
</span>
<span class="bd-calc__amt">₹{{ d.totalInterest | number: '1.2-2' }}</span>
</div>
}
</div>
}
@if (preview?.fdInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The day cannot be closed yet.</strong> Fixed deposit interest is not approved for:
{{ preview.fdInterestPending.join(', ') }}. Approve it under Reports → FD Interest Posting Reports, then
close the day. The user who generated a report cannot approve it.
</div>
</div>
}
@if (preview?.savingsInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The day cannot be closed yet.</strong> Savings interest is not approved for:
{{ preview.savingsInterestPending.join(', ') }}. Approve it under Reports → Interest Posting Reports, then
close the day.
</div>
</div>
}
<!-- What every cash point under this branch is actually holding right now, fetched
automatically — so closing the day doesn't mean separately checking Vault Position and
every cashier's own Cashier Position screen by hand. -->
@if (eodLoading) {
<p class="db-muted">Fetching vault and cashier positions…</p>
}
<!-- The question that actually matters at close: does the WHOLE branch tally, not just the
vault. Only shown once both the book preview and every teller/cashier position have
loaded, since it adds them together. -->
@if (branchTotalSummary; as bt) {
<div class="bd-eod bd-eod--total">
<div class="bd-eod__head">
<mat-icon>account_balance_wallet</mat-icon>
<strong>Branch Total (Vault + All Cashiers)</strong>
</div>
<div class="bd-eod__totals">
<span>Vault ₹{{ bt.vaultTotal | number: '1.2-2' }}</span>
<span>+ Cashiers ₹{{ bt.cashierTotal | number: '1.2-2' }}</span>
<span class="bd-eod__totals-sum">= ₹{{ bt.grandTotal | number: '1.2-2' }}</span>
<span class="db-muted">vs. book ₹{{ bt.expected | number: '1.2-2' }}</span>
</div>
@if (bt.difference === 0) {
<span class="db-badge db-badge--ok">Branch Tallied</span>
} @else {
<span class="db-badge db-badge--rejected">
{{ bt.difference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(bt.difference) | number: '1.2-2' }}
</span>
}
</div>
}
@for (t of eodTellers; track t.tellerId) {
<div class="bd-eod">
<div class="bd-eod__head">
<mat-icon>account_balance</mat-icon>
<strong>{{ t.tellerName }}</strong>
<span class="db-muted">
vault holds
{{ t.vault?.hasBreakdown ? '₹' + (t.vault.totalAmount | number: '1.2-2') : 'no breakdown recorded' }}
</span>
</div>
@if (t.vault?.hasBreakdown && t.vault.denominations.length > 0) {
<div class="bd-eod__denoms">
@for (d of t.vault.denominations; track d.denominationId) {
<span class="bd-eod__chip">₹{{ d.denominationValue }} × {{ d.count }}</span>
}
</div>
}
@if (t.cashiers.length === 0) {
<p class="db-muted bd-eod__empty">No cashiers allocated under this vault.</p>
}
@for (c of t.cashiers; track c.cashierId) {
<div class="bd-eod__cashier">
<div class="bd-eod__cashier-head">
<span>{{ c.cashierName }}</span>
<span class="db-muted">
morning ₹{{ c.morningTotal | number: '1.2-2' }} → current ₹{{ c.currentTotal | number: '1.2-2' }} ·
{{ c.txnCount ?? '—' }} txn{{ c.txnCount === 1 ? '' : 's' }} today
</span>
</div>
<div class="bd-eod__status">
@if (c.reconciled === true) {
<span class="db-badge db-badge--ok">Reconciled</span>
} @else if (c.reconciled === false) {
<span class="db-badge db-badge--pending">Not Reconciled</span>
}
@if (c.alreadySettled === true) {
<span class="db-badge db-badge--ok">Settled to Vault</span>
} @else if (c.alreadySettled === false) {
<span class="db-badge">Not Settled</span>
}
</div>
@if (c.denominations.length > 0) {
<div class="bd-eod__denoms">
@for (d of c.denominations; track d.denominationId) {
<span
class="bd-eod__chip"
[class.bd-eod__chip--up]="d.delta > 0"
[class.bd-eod__chip--down]="d.delta < 0"
>
₹{{ d.denominationValue }}: {{ d.morningCount }} → {{ d.count }}
@if (d.delta !== 0) {
({{ d.delta > 0 ? '+' : '' }}{{ d.delta }})
}
</span>
}
</div>
} @else {
<span class="db-muted bd-eod__empty">No balance.</span>
}
</div>
}
</div>
}
<div class="db-panel__body">
<label class="pg-field">
<span>Physical cash counted (or count denominations below)</span>
<input
type="number"
step="0.01"
[(ngModel)]="closingCash"
[disabled]="closeDenomTotal > 0"
placeholder="0.00"
/>
</label>
@if (preview && liveDifference !== null) {
@if (liveDifference === 0) {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">Tallied</span>
</div>
} @else {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">
{{ liveDifference > 0 ? 'Excess' : 'Short' }} ₹{{
liveDifference < 0 ? -liveDifference : (liveDifference | number: '1.2-2')
}}
</span>
</div>
}
}
@if (preview && liveDifference !== 0) {
<button type="button" class="pg-btn" (click)="useExpected()">Use expected figure</button>
}
</div>
<p class="bd-hint">
@if (hasAutoFilledCounts) {
Auto-filled below from the vault and every cashier's position shown above — correct only the rows where the
physical count differs, note by note.
} @else {
Pre-filled with what the books expect — change it only if the drawer counted something different, and that
difference is then recorded against the day. Counting the denominations below overrides the typed figure with
what was actually counted, note by note.
}
</p>
<div class="pg-card bd-denom">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="3">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td style="display: flex; flex-direction: column; align-items: center">
<div class="pg-step">
<button
type="button"
(click)="bumpCount(closeCounts, d, -1)"
[disabled]="countFor(closeCounts, d) === 0"
>
−
</button>
<input
type="number"
min="0"
[value]="countFor(closeCounts, d)"
(input)="onCountChange(closeCounts, d, $any($event.target).value)"
/>
<button type="button" (click)="bumpCount(closeCounts, d, 1)">+</button>
</div>
@if (hasAutoFilledCounts && countFor(closeCounts, d) !== autoCountFor(d)) {
<span class="db-muted bd-denom__auto">system counted {{ autoCountFor(d) }}</span>
}
</td>
<td class="num">₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="3">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr>
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td style="display: flex; flex-direction: column; align-items: center">
<div class="pg-step">
<button
type="button"
(click)="bumpCount(closeCounts, d, -1)"
[disabled]="countFor(closeCounts, d) === 0"
>
−
</button>
<input
type="number"
min="0"
[value]="countFor(closeCounts, d)"
(input)="onCountChange(closeCounts, d, $any($event.target).value)"
/>
<button type="button" (click)="bumpCount(closeCounts, d, 1)">+</button>
</div>
@if (hasAutoFilledCounts && countFor(closeCounts, d) !== autoCountFor(d)) {
<span class="db-muted bd-denom__auto">system counted {{ autoCountFor(d) }}</span>
}
</td>
<td class="num">₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
@if (closeDenomTotal > 0) {
<p class="bd-hint">
Denomination total: <strong>₹{{ closeDenomTotal | number: '1.2-2' }}</strong>
@if (hasAutoFilledCounts) {
<button type="button" class="pg-btn bd-denom__reset" (click)="resetToAutoFill()">
Reset to auto-filled count
</button>
}
</p>
}
<!-- Shown for an unreconciled cashier too, not only a late close. The backend blocks a close
over a cashier who took cash on this date and never reconciled it, and accepts a reason as
the override — but this field only appeared for a late close, so a same-day close in that
state had nowhere to type the reason and could not be completed at all until the date had
passed. -->
@if (isLateClose || hasUnreconciledOrUnsettledCashiers) {
<label class="pg-field bd-panel__wide">
<span>
@if (isLateClose) {
Why was this day left open? (required)
} @else {
Why close over a cashier who has not reconciled? (required)
}
</span>
<input
type="text"
[(ngModel)]="forceReason"
placeholder="e.g. cashier on leave, cash not settled that evening"
/>
</label>
}
<label class="pg-field bd-panel__wide">
<span>Remarks (optional)</span>
<input type="text" [(ngModel)]="closeRemarks" placeholder="Anything worth recording about this day" />
</label>
<p class="bd-hint">
The figure above is stored against the record, so a difference stays readable later even if a transaction is
reversed afterwards.
</p>
@if (hasUnreconciledOrUnsettledCashiers) {
<!-- A wall on the day itself, a warning on a past one. Today the cashier is still here and
the cash can be counted, so nothing excuses closing over it. On a past date it cannot be
counted for that date at all, and blocking would leave a day nobody could ever close —
so it goes through on the reason a late close already requires, with the names recorded
against the day. -->
<div
class="pg-alert bd-reconcile-block"
[class.pg-alert--error]="blocksSameDayClose"
[class.bd-reconcile-block--warn]="!blocksSameDayClose"
>
<mat-icon>{{ blocksSameDayClose ? 'block' : 'warning' }}</mat-icon>
<div>
@if (blocksSameDayClose) {
<strong
>Cannot close today &mdash; cashiers still holding cash have not been reconciled or settled.</strong
>
} @else {
<strong>This day's cash was never reconciled or settled.</strong>
}
<ul class="bd-reconcile-block__list">
@for (detail of unreconciledOrUnsettledCashierDetails; track detail.name) {
<li>
<strong>{{ detail.name }}</strong> —
@if (detail.notReconciled && detail.notSettled) {
Not Reconciled, Not Settled
} @else if (detail.notReconciled) {
Not Reconciled
} @else {
Not Settled
}
</li>
}
</ul>
@if (blocksSameDayClose) {
<span class="db-muted"
>Each cashier must count their drawer, reconcile it, and settle the cash back to the vault. Then this
day can be closed.</span
>
} @else {
<span class="db-muted"
>The cash cannot be counted for a date that has passed. Record why above &mdash; their names are kept
against the record.</span
>
}
</div>
</div>
}
<div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitClose()" [disabled]="!canSubmitClose">
{{ needsForceReason ? 'Force Close Day' : 'Close Day' }}
</button>
<button type="button" class="pg-btn" (click)="cancelClose()">Cancel</button>
</div>
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && days.length === 0 && !missedOnly) {
<div class="pg-empty">
<mat-icon>event_note</mat-icon>
<p>No business day has been opened for this branch yet.</p>
</div>
}
<!-- The register -->
@if (days.length > 0 && !missedOnly) {
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Business Date</th>
<th>Status</th>
<th>Opened By</th>
<th class="num">Opening ₹</th>
<th>Closed By</th>
<th class="num">Closing ₹</th>
<th class="num">Book ₹</th>
<th>Difference</th>
<th>Note</th>
</tr>
</thead>
<tbody>
@for (d of days; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.businessDate }}</span>
</td>
<td>
@if (d.status === 'OPEN') {
<span class="db-badge db-badge--pending">OPEN</span>
} @else if (d.forceClosed) {
<span class="db-badge db-badge--rejected" [title]="d.forceCloseReason ?? ''">FORCE CLOSED</span>
} @else {
<span class="db-badge db-badge--ok">CLOSED</span>
}
</td>
<td>
{{ d.openedByName ?? '—' }}
<span class="db-muted">· {{ timeOnly(d.openedAt) }}</span>
</td>
<td class="num">₹{{ d.openingCash | number: '1.2-2' }}</td>
<td>
{{ d.closedByName ?? '—' }}
@if (d.closedAt) {
<span class="db-muted">· {{ timeOnly(d.closedAt) }}</span>
}
</td>
<td class="num">{{ hasValue(d.closingCash) ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}</td>
<td class="num">{{ hasValue(d.bookCash) ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}</td>
<td>
@if (!hasValue(d.cashDifference)) {
—
} @else if (d.cashDifference === 0) {
<span class="db-badge db-badge--ok">Tallied</span>
} @else {
<span class="db-badge db-badge--rejected">{{ differenceLabel(d.cashDifference) }}</span>
}
</td>
<td class="bd-note">
@if (d.forceCloseReason) {
<strong>Left open:</strong> {{ d.forceCloseReason }}
}
@if (d.remarks) {
<span class="db-muted">{{ d.remarks }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -1,470 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* Page chrome comes from cashier-shared.scss; the badges, panel and comparison tiles are shared
with the day book. Only what is specific to the day register lives here. */
/* Read-only notice for a cashier. Defined here rather than borrowed from the exceptions screen so
this component does not depend on another component's stylesheet. */
.ve-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
/* Blocks Open Day until the branch's first-ever open has gone through Migrate Cash Denomination —
see BranchDayComponent.checkFirstOpenCashMigration(). Same shape as .ve-note, in the error palette
the rest of this screen already uses for a missed close (.bd-missed). */
.bd-locked {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12px;
line-height: 1.5;
mat-icon {
flex: none;
font-size: 18px;
width: 18px;
height: 18px;
}
a {
color: inherit;
font-weight: 600;
text-decoration: underline;
}
}
.bd-success {
padding: 10px 14px;
margin-bottom: 14px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
color: #14532d;
font-size: 13px;
}
/* One row per never-closed day, inside the shared red exception panel. */
.bd-missed {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 7px 0;
font-size: 12px;
border-top: 1px dotted #fecaca;
&__age {
color: #b91c1c;
}
/* Pushed right so the date and age read before the action. */
&__act {
margin-left: auto;
height: 28px;
font-size: 11px;
}
}
/* The branch's current day — the one thing someone opening this screen is looking for. */
.bd-current {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 14px 16px;
margin-bottom: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #f59e0b;
border-radius: 8px;
/* No day open at all: nothing is running, so the amber "in progress" edge would mislead. */
&--shut {
border-left-color: #94a3b8;
background: #f8fafc;
}
&__main {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
&__date {
font-size: 15px;
color: #0d1b2a;
}
.pg-btn {
margin-left: auto;
}
}
.bd-panel {
margin-bottom: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
/* A late close is a different act from an ordinary one, and looks like it. */
&--late {
border-left: 4px solid #dc2626;
}
&__wide {
display: block;
margin-top: 12px;
max-width: 640px;
}
}
/* The closing figure's arithmetic, laid out as a running sum so each line can be checked against
the screen it came from. */
.bd-calc {
max-width: 520px;
margin-bottom: 14px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 5px 0;
color: #475569;
&--in .bd-calc__amt {
color: #15803d;
}
&--out .bd-calc__amt {
color: #b91c1c;
}
&--total {
margin-top: 4px;
padding-top: 8px;
border-top: 2px solid #e2e8f0;
font-weight: 800;
font-size: 14px;
color: #0d1b2a;
}
}
&__amt {
font-variant-numeric: tabular-nums;
font-weight: 700;
white-space: nowrap;
}
&__memo {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
font-size: 11px;
color: #94a3b8;
}
/* Names the block, for the interest panels that sit beside the cash arithmetic. */
&__head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
color: #0d1b2a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #1d4ed8;
}
}
}
/* Interest is money leaving as the day closes, not cash counted in the drawer. */
.bd-calc--fd {
border-left: 3px solid #1d4ed8;
}
.bd-fd-ok,
.bd-fd-pending {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
}
.bd-fd-ok {
background: #dcfce7;
color: #15803d;
}
.bd-fd-pending {
background: #fef3c7;
color: #b45309;
}
/* Why the close will be refused, said before the count is filled in rather than after. */
.bd-warn {
display: flex;
align-items: flex-start;
gap: 8px;
max-width: 640px;
margin-bottom: 14px;
padding: 10px 14px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
color: #78350f;
mat-icon {
flex-shrink: 0;
font-size: 20px;
width: 20px;
height: 20px;
color: #b45309;
}
}
.bd-hint {
margin: 10px 0 0;
font-size: 11px;
color: #94a3b8;
}
.bd-note {
max-width: 280px;
font-size: 11px;
line-height: 1.4;
}
/* The denomination grid inside the open/close panels — same table chrome as pg-card elsewhere,
just tightened to sit inside the panel instead of spanning the page. */
.bd-denom {
margin: 4px 0 0;
max-width: 520px;
/* Flags a row where the hand-count no longer matches what the vault/cashier positions reported. */
&__auto {
display: block;
margin-top: 2px;
font-size: 10px;
white-space: nowrap;
}
&__reset {
margin-left: 10px;
height: 24px;
padding: 0 10px;
font-size: 11px;
}
}
/* Auto-fetched EOD summary: what the vault and every cashier under it currently hold. */
.bd-eod {
margin: 0 0 12px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #0ea5e9;
}
}
&__denoms {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 4px 0 8px;
}
&__chip {
padding: 2px 8px;
background: #f1f5f9;
border-radius: 999px;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: #334155;
&--up {
background: #dcfce7;
color: #15803d;
}
&--down {
background: #fee2e2;
color: #b91c1c;
}
}
&__cashier {
padding: 6px 0;
border-top: 1px dashed #e2e8f0;
&-head {
display: flex;
justify-content: space-between;
gap: 12px;
font-weight: 600;
color: #0d1b2a;
margin-bottom: 2px;
}
}
&__empty {
font-size: 11px;
}
&__status {
display: flex;
gap: 6px;
margin: 2px 0 6px;
}
&--total {
border-left: 4px solid #0ea5e9;
background: #f0f9ff;
}
&__totals {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px;
margin-bottom: 6px;
font-size: 13px;
font-variant-numeric: tabular-nums;
&-sum {
font-weight: 800;
font-size: 15px;
color: #0d1b2a;
}
}
}
/* Alert shown when cashiers holding cash have not been reconciled or settled — blocks day close. */
.bd-reconcile-block {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 14px 0;
padding: 12px 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 8px;
color: #991b1b;
font-size: 12px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #dc2626;
flex-shrink: 0;
margin-top: 1px;
}
&__list {
margin: 6px 0 6px 16px;
padding: 0;
list-style: disc;
font-size: 12px;
color: #b91c1c;
line-height: 1.6;
}
}
/* Amber, not red: an unreconciled cashier is now a reason to record why the day is being closed
rather than a refusal to close it at all. Red said "you cannot", which was true when the button
was disabled and misleading once a force close became possible. */
.bd-reconcile-block--warn {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
color: #92400e;
mat-icon {
color: #d97706;
}
}
// Shown under the open day while a savings interest posting report due today is unapproved.
.bd-interest-alert {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 8px 0 12px;
padding: 10px 14px;
border: 1px solid #f59e0b;
border-radius: 8px;
background: #fef3c7;
color: #92400e;
font-size: 13px;
mat-icon {
flex-shrink: 0;
font-size: 20px;
width: 20px;
height: 20px;
}
a {
color: inherit;
font-weight: 600;
text-decoration: underline;
}
}
@@ -1,735 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
import { CashierService } from '../cashier.service';
interface Denom {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
interface BranchDay {
id: number;
officeId: number;
officeName: string | null;
businessDate: string;
openedByName: string | null;
openedAt: string | null;
openingCash: number | null;
closedByName: string | null;
closedAt: string | null;
closingCash: number | null;
bookCash: number | null;
cashDifference: number | null;
forceClosed: boolean;
forceCloseReason: string | null;
remarks: string | null;
status: 'OPEN' | 'CLOSED';
/** Only present on the missed-close list. */
daysOpen?: number;
}
/**
* Branch day register — opening and closing the branch's business day, and the list of days that
* were opened and never closed.
*
* A day left open is not a tidiness problem: its closing balance never carries forward, a
* back-dated entry can still land in it, and any cash difference on that date goes unnoticed.
*/
@Component({
selector: 'mifosx-branch-day',
templateUrl: './branch-day.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./branch-day.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
RouterLink
]
})
export class BranchDayComponent implements OnInit {
private service = inject(CashierService);
private route = inject(ActivatedRoute);
/**
* Set from the route: the missed-close register is the same data as the day register, shown with
* only the never-closed days. Two menu entries used to point at one screen, so choosing "Missed
* Close Register" looked like nothing had happened whenever the branch was up to date.
*/
missedOnly = false;
days: BranchDay[] = [];
missed: BranchDay[] = [];
openDay: any = null;
today = '';
/** Set by the backend from the caller's roles — a cashier may read the register but not act on it. */
canOperate = false;
loading = false;
loaded = false;
errorMsg = '';
successMsg = '';
/** True only for a branch that has never opened a business day and whose Cash at Vault / Cash at
* Teller GL account has never had anything posted to it — Open Day here would let day-to-day cash
* tracking begin while the ledger silently stays wherever it already was. The backend enforces
* this independently too (see BranchDayRegisterApiResource#requireCashMigrationBeforeFirstEverOpen);
* this flag only steers the user to the right screen before they hit that rejection. */
firstOpenNeedsCashMigration = false;
// Open-day form
showOpenForm = false;
openDate = '';
openingCash: number | null = null;
/** Required only when the counted/typed opening figure differs from yesterday's own closing
* figure — the backend rejects the open without it in that case. */
openDifferenceReason = '';
opening = false;
// Close-day form. The date is fixed by which day is being closed, never typed.
closeTarget: BranchDay | null = null;
closingCash: number | null = null;
closeRemarks = '';
forceReason = '';
closing = false;
// Denomination breakdown, shared by both the open and close forms — a physical note/coin count
// is the true source of the lump-sum figure, not a typed-in guess. Counted separately per form
// since opening and closing are two independent physical counts.
notes: Denom[] = [];
coins: Denom[] = [];
openCounts: Record<number, number> = {};
closeCounts: Record<number, number> = {};
/** What yesterday's own close counted, note by note — the open form starts from this instead of
* zero. The backend already carries this figure forward on its own when nothing is submitted for
* the day being opened; showing it here is only about letting whoever opens the day see and
* correct it, not a second place the carry-forward logic lives. */
openCountsAuto: Record<number, number> = {};
/** What the vault + every cashier's live position added up to when the close form opened — the
* physical-count table starts from this instead of zero, since the EOD summary already fetched
* it. Kept separately from closeCounts so a manual recount can be told apart from it and undone. */
closeCountsAuto: Record<number, number> = {};
get allDenoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
ngOnInit(): void {
this.missedOnly = !!this.route.snapshot.data['missedOnly'];
this.load();
this.service.getActiveDenominations().subscribe({
next: (list) => {
const rows: Denom[] = (list || []).map((x: any) => ({
id: x.id,
value: x.denomination,
kind: x.kind,
label: '₹' + x.denomination
}));
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
},
error: () => {
this.notes = [];
this.coins = [];
}
});
}
countFor(counts: Record<number, number>, d: Denom): number {
return counts[d.id] || 0;
}
subtotalFor(counts: Record<number, number>, d: Denom): number {
return this.countFor(counts, d) * d.value;
}
onCountChange(counts: Record<number, number>, d: Denom, raw: any): void {
counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
}
bumpCount(counts: Record<number, number>, d: Denom, delta: number): void {
counts[d.id] = Math.max(0, (counts[d.id] || 0) + delta);
}
totalOf(counts: Record<number, number>): number {
return this.allDenoms.reduce((s, d) => s + this.subtotalFor(counts, d), 0);
}
/** Denominations with a positive count, shaped for the API payload. */
private denominationPayload(counts: Record<number, number>) {
return this.allDenoms
.filter((d) => this.countFor(counts, d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(counts, d) }));
}
get openDenomTotal(): number {
return this.totalOf(this.openCounts);
}
get closeDenomTotal(): number {
return this.totalOf(this.closeCounts);
}
/**
* Savings products whose interest posting report is due today and not yet approved. The close is
* refused by the backend while any is pending; this only says so before the user tries.
*/
interestPending: string[] = [];
private checkInterestReports(): void {
if (!this.openDay?.businessDate) {
this.interestPending = [];
return;
}
this.service
.interestDayCloseCheck(this.openDay.businessDate, this.openDay.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res) => (this.interestPending = res?.pendingProducts ?? []));
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.service
.branchDayRegister()
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day register.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.days = res?.days ?? [];
this.openDay = res?.openDay ?? null;
this.today = res?.today ?? '';
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
this.checkFirstOpenCashMigration();
this.checkInterestReports();
});
this.service
.branchDayMissed()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.missed = res?.missed ?? [];
});
}
/**
* Only relevant the moment this branch has never opened a business day before — every later
* open/close on this screen is untouched, so there is nothing to re-check once history exists.
*/
private checkFirstOpenCashMigration(): void {
if (this.days.length > 0 || this.openDay) {
this.firstOpenNeedsCashMigration = false;
return;
}
this.service.cashGlStatus().subscribe({
next: (status: any) => {
// configured === false means this tenant has no Cash at Vault / Cash at Teller account
// mapped at all — nothing for this check to reconcile against, so it does not block.
this.firstOpenNeedsCashMigration = status?.configured !== false && !status?.hasBalance;
},
error: () => {
this.firstOpenNeedsCashMigration = false;
}
});
}
/** True when the branch has no open day at all, so today can be opened. */
get canOpenToday(): boolean {
return this.canOperate && !this.openDay && !this.firstOpenNeedsCashMigration;
}
/**
* A still-open day's closing/book figures are dropped from the API response entirely (the
* backend's Gson instance omits a null map value rather than sending it as JSON null), so the
* field arrives as undefined, not null. Checking only `!== null` against that — which a stricter
* pass of this file made the mistake of doing — leaves undefined on the "has a value" branch,
* which then renders `'₹' + null` (the number pipe's own null output, string-concatenated) as the
* literal text "₹null". This checks for either, the way an open day actually shows up.
*/
hasValue(value: number | null | undefined): boolean {
return value !== null && value !== undefined;
}
startOpen(): void {
this.showOpenForm = true;
this.openDate = this.today;
// Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure
// over it is how a carried balance drifts.
this.openingCash = null;
this.openCounts = {};
this.openCountsAuto = {};
this.openDifferenceReason = '';
this.successMsg = '';
this.errorMsg = '';
this.loadCarriedForwardDenominations();
}
cancelOpen(): void {
this.showOpenForm = false;
this.openCountsAuto = {};
}
/**
* Pulls yesterday's own closing note/coin count so the open form shows what is about to be
* carried forward instead of a blank table the caller has to trust blindly. Only the most
* recently closed day is used — the same day the backend itself falls back to when the caller
* submits no denominations and no lump sum.
*/
private loadCarriedForwardDenominations(): void {
const lastClosed = this.days.find((d) => d.status === 'CLOSED' && d.closingCash != null);
if (!lastClosed) return;
this.service
.branchDayDenominations(lastClosed.officeId, lastClosed.businessDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
const closing = res?.closing || [];
if (!closing.length) return;
const totals: Record<number, number> = {};
for (const d of closing) {
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
}
this.openCountsAuto = totals;
// Only overwrite the table if nothing has been typed/counted since the form opened.
if (this.openDenomTotal === 0 && this.openingCash == null) {
this.openCounts = { ...totals };
}
});
}
get hasAutoFilledOpenCounts(): boolean {
return Object.keys(this.openCountsAuto).length > 0;
}
autoOpenCountFor(d: Denom): number {
return this.openCountsAuto[d.id] || 0;
}
resetOpenToAutoFill(): void {
this.openCounts = { ...this.openCountsAuto };
}
get carriedForwardTotal(): number {
return this.totalOf(this.openCountsAuto);
}
/** The figure the form would actually submit right now — the denomination total when any note/coin
* has been counted, otherwise the plain typed-in amount. Mirrors {@link effectiveClosingFigure}. */
get effectiveOpeningFigure(): number | null {
return this.openDenomTotal > 0 ? this.openDenomTotal : this.openingCash;
}
/** Difference between what is about to be opened with and what yesterday actually closed with —
* visible before saving, the same way the close form shows a live difference against the book
* figure, so a short/excess opening count isn't discovered only after the day is already running. */
get openLiveDifference(): number | null {
if (!this.hasAutoFilledOpenCounts || this.effectiveOpeningFigure == null) return null;
return this.effectiveOpeningFigure - this.carriedForwardTotal;
}
/** True once a reason is needed but hasn't been typed — the same "explain the gap before it's
* allowed" gate the close form already applies to a late close. */
get needsOpenDifferenceReason(): boolean {
return this.openLiveDifference != null && this.openLiveDifference !== 0 && !this.openDifferenceReason.trim();
}
get canSubmitOpen(): boolean {
return !!this.openDate && !this.opening && !this.needsOpenDifferenceReason;
}
submitOpen(): void {
if (!this.openDate || this.needsOpenDifferenceReason) return;
this.opening = true;
this.errorMsg = '';
const denominations = this.denominationPayload(this.openCounts);
this.service
.openBranchDay(
this.openDate,
this.openingCash ?? undefined,
undefined,
denominations,
this.openDifferenceReason.trim() || undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not open the day.';
return of(null);
})
)
.subscribe((res: any) => {
this.opening = false;
if (res) {
this.showOpenForm = false;
this.successMsg = `Business day ${res.businessDate} opened.`;
this.load();
}
});
}
/** The day's expected figure and the working behind it, fetched when the close form opens. */
preview: any = null;
previewLoading = false;
startClose(day: BranchDay): void {
this.closeTarget = day;
this.closingCash = null;
this.closeCounts = {};
this.closeCountsAuto = {};
this.closeRemarks = '';
this.forceReason = '';
this.successMsg = '';
this.errorMsg = '';
this.preview = null;
// Pre-fill the counted figure with what the books expect. It stays editable — the day is still
// closed on what was physically counted — but in the ordinary case where the drawer tallies
// there is nothing to type, and no chance of a slip of the keyboard inventing a difference.
this.previewLoading = true;
this.service
.branchDayClosePreview(day.businessDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.previewLoading = false;
this.preview = res;
if (res?.expectedCash != null && this.closingCash == null) {
this.closingCash = Number(res.expectedCash);
}
});
this.loadEodSummary(day);
}
// ── EOD denomination summary: what's in the vault, and what every cashier under it is
// currently holding, fetched automatically so closing the day doesn't require separately
// checking Vault Position and every cashier's own Cashier Position screen by hand. ──
eodLoading = false;
eodTellers: any[] = [];
/**
* Vault + every cashier's current holding, added up across the whole branch and checked against
* the book figure — the question a manager actually needs answered at close: does the BRANCH
* tally, not just the vault alone. Computed on read rather than stored, so it never has to be
* kept in sync across the two independent loads (the book preview and the EOD summary) that feed
* it.
*/
get branchTotalSummary(): {
vaultTotal: number;
cashierTotal: number;
grandTotal: number;
expected: number;
difference: number;
} | null {
if (!this.preview || this.eodTellers.length === 0) return null;
let vaultTotal = 0;
let cashierTotal = 0;
for (const t of this.eodTellers) {
vaultTotal += Number(t.vault?.totalAmount ?? 0);
for (const c of t.cashiers) {
cashierTotal += Number(c.currentTotal ?? 0);
}
}
const grandTotal = vaultTotal + cashierTotal;
const expected = Number(this.preview.expectedCash ?? 0);
return { vaultTotal, cashierTotal, grandTotal, expected, difference: grandTotal - expected };
}
private loadEodSummary(day: BranchDay): void {
this.eodLoading = true;
this.eodTellers = [];
this.service
.getTellers(day.officeId)
.pipe(
switchMap((tellers) => {
const list = tellers || [];
if (!list.length) return of([]);
return forkJoin(list.map((t: any) => this.loadTellerSummary(t, day.businessDate)));
}),
catchError(() => of([]))
)
.subscribe((results: any[]) => {
this.eodLoading = false;
this.eodTellers = results;
this.autoFillCloseCounts();
});
}
/**
* Seeds the physical-count table from what the vault and every cashier are already shown to be
* holding, instead of leaving it at zero and making whoever closes the day re-key numbers that
* are already on screen above. It stays a starting point, not a lock — any row can still be
* corrected against what was actually counted, and {@link resetToAutoFill} undoes a correction.
*/
private autoFillCloseCounts(): void {
const totals: Record<number, number> = {};
for (const t of this.eodTellers) {
for (const d of t.vault?.denominations || []) {
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
}
for (const c of t.cashiers || []) {
for (const d of c.denominations || []) {
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
}
}
}
if (Object.keys(totals).length === 0) return;
this.closeCountsAuto = totals;
this.closeCounts = { ...totals };
}
get hasAutoFilledCounts(): boolean {
return Object.keys(this.closeCountsAuto).length > 0;
}
/** The auto-fetched count for one denomination, so a row that was hand-edited away from it can
* still show what the vault + cashier positions said. */
autoCountFor(d: Denom): number {
return this.closeCountsAuto[d.id] || 0;
}
resetToAutoFill(): void {
this.closeCounts = { ...this.closeCountsAuto };
}
private loadTellerSummary(teller: any, date: string) {
return forkJoin({
vault: this.service.vaultPosition(teller.id, date).pipe(catchError(() => of(null))),
cashiers: this.service.getCashiersForTeller(teller.id).pipe(catchError(() => of({ cashiers: [] })))
}).pipe(
switchMap(({ vault, cashiers }) => {
const cashierList = cashiers?.cashiers || [];
if (!cashierList.length) {
return of({ tellerId: teller.id, tellerName: teller.name, vault, cashiers: [] });
}
return forkJoin(cashierList.map((c: any) => this.loadCashierSummary(c, date))).pipe(
map((cashierSummaries: any[]) => ({
tellerId: teller.id,
tellerName: teller.name,
vault,
cashiers: cashierSummaries
}))
);
})
);
}
private loadCashierSummary(cashier: any, date: string) {
return forkJoin({
position: this.service.cashierPositionReport(cashier.id, date).pipe(catchError(() => of(null))),
morning: this.service.getCashierMorningBalance(cashier.id, date).pipe(catchError(() => of(null))),
reconciliation: this.service.checkReconciliation(cashier.id, date).pipe(catchError(() => of(null))),
settlement: this.service.checkSettlement(cashier.id, date).pipe(catchError(() => of(null))),
glEntries: this.service.getGlEntries(date, cashier.id, undefined, 0, 1).pipe(catchError(() => of(null)))
}).pipe(
map(({ position, morning, reconciliation, settlement, glEntries }: any) => {
const morningMap = new Map<number, number>();
(morning?.denominations || []).forEach((d: any) => morningMap.set(d.denominationId, d.morningCount ?? 0));
return {
cashierId: cashier.id,
cashierName: cashier.staffName,
morningTotal: Number(morning?.totalMorning ?? 0),
currentTotal: Number(position?.totalAmount ?? 0),
reconciled: reconciliation?.reconciled ?? null,
alreadySettled: settlement?.alreadySettled ?? null,
txnCount: glEntries?.totalFilteredRecords ?? null,
denominations: ((position?.denominations || []) as any[]).map((d: any) => ({
...d,
morningCount: morningMap.get(d.denominationId) ?? 0,
delta: d.count - (morningMap.get(d.denominationId) ?? 0)
}))
};
})
);
}
/** The counted figure the form would submit right now — the denomination total when any note/coin
* has been counted, otherwise the plain typed-in amount. */
get effectiveClosingFigure(): number | null {
return this.closeDenomTotal > 0 ? this.closeDenomTotal : this.closingCash;
}
/** Difference the form would record right now, so it is visible before saving. */
get liveDifference(): number | null {
if (!this.preview || this.effectiveClosingFigure == null) return null;
return Number(this.effectiveClosingFigure) - Number(this.preview.expectedCash ?? 0);
}
/** Puts the expected figure back after an edit. */
useExpected(): void {
if (this.preview?.expectedCash != null) {
this.closingCash = Number(this.preview.expectedCash);
}
}
cancelClose(): void {
this.closeTarget = null;
this.eodTellers = [];
this.closeCountsAuto = {};
}
/** A date earlier than today is a late close, and has to say why it was left open. */
get isLateClose(): boolean {
return !!this.closeTarget && this.closeTarget.businessDate < this.today;
}
/**
* True when any cashier who was given cash (currentTotal > 0) is still NOT reconciled
* or NOT settled — meaning outstanding cash hasn't been accounted for yet.
*/
get hasUnreconciledOrUnsettledCashiers(): boolean {
for (const t of this.eodTellers) {
for (const c of t.cashiers) {
if (c.currentTotal > 0 && (c.reconciled === false || c.alreadySettled === false)) {
return true;
}
}
}
return false;
}
/** Names of cashiers blocking the close, for the error message. */
get unreconciledOrUnsettledCashierDetails(): { name: string; notReconciled: boolean; notSettled: boolean }[] {
const result: { name: string; notReconciled: boolean; notSettled: boolean }[] = [];
for (const t of this.eodTellers) {
for (const c of t.cashiers) {
if (c.currentTotal > 0 && (c.reconciled === false || c.alreadySettled === false)) {
result.push({
name: c.cashierName,
notReconciled: c.reconciled === false,
notSettled: c.alreadySettled === false
});
}
}
}
return result;
}
/**
* Whether a reason has to be given before the day can close.
*
* Only for a late close. Cash still sitting with a cashier is not something a reason excuses —
* see canSubmitClose.
*/
get needsForceReason(): boolean {
return this.isLateClose;
}
get canSubmitClose(): boolean {
const hasFigure = this.closingCash != null || this.closeDenomTotal > 0;
if (!this.closeTarget || !hasFigure || this.closing) return false;
// Block while cashier/vault data is still loading — the reconciliation check can't run yet.
if (this.eodLoading || this.previewLoading) return false;
// An unreconciled cashier is a reason-required condition, not an absolute block: the backend
// accepts the close once a reason is recorded (and appends the cashiers' names to it), and
// refusing outright left a day that could never be closed at all — a cashier who reconciled on
// the wrong date, went on leave, or left mid-shift would strand that day and every day after it.
if (this.isLateClose || this.hasUnreconciledOrUnsettledCashiers) {
return !!this.forceReason.trim();
}
return true;
}
submitClose(): void {
if (this.eodLoading || this.previewLoading) {
this.errorMsg = 'Still loading the cashier and vault positions — try again in a moment.';
return;
}
// Safety net mirroring canSubmitClose: never send a close that the backend will refuse for a
// missing override reason.
if ((this.isLateClose || this.hasUnreconciledOrUnsettledCashiers) && !this.forceReason.trim()) {
this.errorMsg = 'Record why this day is being closed before submitting.';
return;
}
const day = this.closeTarget;
const denominations = this.denominationPayload(this.closeCounts);
const figure = denominations.length ? this.closeDenomTotal : this.closingCash;
if (!day || figure == null) return;
this.closing = true;
this.errorMsg = '';
this.service
.closeBranchDay(
day.businessDate,
Number(figure),
this.closeRemarks.trim() || undefined,
// Sent whenever one was typed, not only on a late close: it is also the override the backend
// needs for a same-day close over an unreconciled cashier, and gating it on isLateClose
// dropped it on exactly the request that could not succeed without it.
this.forceReason.trim() || undefined,
undefined,
denominations
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the day.';
return of(null);
})
)
.subscribe((res: any) => {
this.closing = false;
if (res) {
this.closeTarget = null;
const diff = Number(res.cashDifference ?? 0);
this.successMsg =
diff === 0
? `Business day ${res.businessDate} closed — cash tallied.`
: `Business day ${res.businessDate} closed with a difference of ₹${diff.toFixed(2)}.`;
this.load();
}
});
}
absValue(n: number): number {
return Math.abs(n);
}
/** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */
differenceLabel(d: number | null): string {
if (d == null) return '—';
const n = Number(d);
if (n === 0) return 'Tallied';
return n > 0 ? `Excess ₹${Math.abs(n).toFixed(2)}` : `Short ₹${Math.abs(n).toFixed(2)}`;
}
stamp(iso: string | null): string {
if (!iso) return '—';
const [
d,
t
] = iso.split('T');
return t ? `${d} ${t.slice(0, 5)}` : d;
}
timeOnly(iso: string | null): string {
if (!iso) return '—';
const t = iso.split('T')[1];
return t ? t.slice(0, 5) : '—';
}
}
@@ -11,7 +11,6 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
import { businessToday } from 'app/core/utils/business-today';
interface SummaryRow {
code: string;
@@ -1317,7 +1316,7 @@ export class CashClearingTransferSummaryComponent {
private dateTag = '2607';
constructor() {
const d = businessToday();
const d = new Date();
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
@@ -22,255 +22,50 @@
</div>
</header>
<!-- ── Cashier: whose drawer takes the customer's notes and gives the change ── -->
@if (cashier) {
<section class="cx-cashier">
<div class="cx-avatar cx-avatar--sm">{{ cashierInitials }}</div>
<div class="cx-cashier__id">
<div class="cx-cashier__label">Cashier at the counter</div>
<div class="cx-cashier__name">{{ cashier.name || '—' }}</div>
<div class="cx-cashier__flow">
<span><mat-icon>south</mat-icon> Receives from customer</span>
<span><mat-icon>north</mat-icon> Gives to customer</span>
</div>
</div>
<div class="cx-cashier__stats">
<div>
<span>Counter</span><strong>{{ cashier.tellerName || '—' }}</strong>
</div>
<div>
<span>Branch</span><strong>{{ cashier.officeName || '—' }}</strong>
</div>
<div>
<span>Allocated today</span
><strong>{{
cashier.allocatedAmount === null ? '—' : '₹' + (cashier.allocatedAmount | number: '1.2-2')
}}</strong>
</div>
<div>
<span>Cash in hand</span><strong>₹{{ cashier.totalAmount | number: '1.2-2' }}</strong>
</div>
</div>
</section>
} @else if (noCashier) {
<section class="cx-cashier cx-cashier--none">
<mat-icon>person_off</mat-icon>
<div>
<div class="cx-cashier__name">Your login is not linked to a cashier</div>
<div class="cx-cashier__label">
A cash exchange moves notes in and out of a cashier's drawer, so it can only be recorded by a cashier. Log in
as a cashier to continue.
</div>
</div>
</section>
}
<!-- ── Customer: an existing client (details from their record) or a walk-in identified at the counter ── -->
<section class="cx-cust">
<header class="cx-cust__head">
<div class="cx-cust__title">
<span class="cx-cust__title-icon"><mat-icon>groups</mat-icon></span>
<div>
<div class="cx-cust__title-text">Customer</div>
<div class="cx-cust__title-sub">Who is exchanging cash at the counter</div>
</div>
</div>
<div class="cx-cust__modes" role="tablist" aria-label="Customer type">
<button
type="button"
role="tab"
class="cx-cust__mode"
[class.cx-cust__mode--active]="customerMode === 'existing'"
[attr.aria-selected]="customerMode === 'existing'"
(click)="setCustomerMode('existing')"
>
<mat-icon>badge</mat-icon> Existing Customer
</button>
<button
type="button"
role="tab"
class="cx-cust__mode"
[class.cx-cust__mode--active]="customerMode === 'walkin'"
[attr.aria-selected]="customerMode === 'walkin'"
(click)="setCustomerMode('walkin')"
>
<mat-icon>person_outline</mat-icon> Walk-in Customer
</button>
</div>
</header>
<div class="cx-cust__body">
<!-- Who -->
<div class="cx-cust__who">
@if (customerMode === 'existing') {
@if (loadingCustomer) {
<div class="cx-cust__empty">
<mat-icon class="cx-cust__empty-icon cx-spin">autorenew</mat-icon>
<div class="cx-cust__empty-title">Loading customer details…</div>
</div>
} @else if (!selectedCustomer) {
<div class="cx-cust__empty">
<mat-icon class="cx-cust__empty-icon">person_search</mat-icon>
<div class="cx-cust__empty-title">No customer selected</div>
<div class="cx-cust__empty-sub">Search by name, account no., PAN or Aadhaar</div>
<button type="button" class="cx-btn cx-btn--primary" (click)="openCustomerSearch()">
<mat-icon>search</mat-icon> Search Customer
</button>
</div>
} @else {
<div class="cx-cust__card">
<div class="cx-cust__card-top">
<div class="cx-avatar">{{ customerInitials }}</div>
<div class="cx-cust__card-id">
<div class="cx-cust__name">{{ selectedCustomer.displayName }}</div>
<div class="cx-cust__badges">
<span
class="cx-kyc"
[class.cx-kyc--ok]="selectedCustomer.kycStatus === 'Verified'"
[class.cx-kyc--warn]="selectedCustomer.kycStatus !== 'Verified'"
>
<mat-icon>{{
selectedCustomer.kycStatus === 'Verified' ? 'verified_user' : 'gpp_maybe'
}}</mat-icon>
KYC: {{ selectedCustomer.kycStatus }}
</span>
@if (selectedCustomer.accountType) {
<span class="cx-chip-tag">{{ selectedCustomer.accountType }}</span>
}
</div>
</div>
<div class="cx-cust__card-actions">
<button type="button" class="cx-link-btn" (click)="openCustomerSearch()">
<mat-icon>swap_horiz</mat-icon> Change
</button>
<button type="button" class="cx-link-btn cx-link-btn--danger" (click)="clearCustomer()">
<mat-icon>close</mat-icon> Remove
</button>
</div>
</div>
<div class="cx-cust__grid">
<div>
<span>CIF No.</span><strong>{{ selectedCustomer.cifNumber || '—' }}</strong>
</div>
<div>
<span>Client ID</span><strong>{{ selectedCustomer.clientId }}</strong>
</div>
<div>
<span>Mobile</span><strong>{{ selectedCustomer.mobileNo || '—' }}</strong>
</div>
<div>
<span>Branch</span><strong>{{ selectedCustomer.officeName || '—' }}</strong>
</div>
<div>
<span>Account</span><strong>{{ selectedCustomer.accountNo || 'No account' }}</strong>
</div>
<div>
<span>PAN on record</span>
<strong>
{{ selectedCustomer.kycPan || 'Not on record' }}
@if (selectedCustomer.kycPan) {
<span
class="cx-kyc cx-kyc--sm"
[class.cx-kyc--ok]="selectedCustomer.panVerified"
[class.cx-kyc--warn]="!selectedCustomer.panVerified"
>{{ selectedCustomer.panVerified ? 'Verified' : 'Not verified' }}</span
>
}
</strong>
</div>
</div>
</div>
}
} @else {
<div class="cx-cust__walkin">
<label class="cx-field">
<span>Customer Name{{ needsPan ? ' *' : '' }}</span>
<input
type="text"
[value]="walkInName"
(input)="walkInName = $any($event.target).value"
placeholder="Full name as on ID"
maxlength="150"
autocomplete="off"
/>
</label>
<label class="cx-field">
<span>Mobile</span>
<input
type="tel"
inputmode="numeric"
[value]="walkInMobile"
(input)="walkInMobile = $any($event.target).value"
placeholder="10-digit mobile number"
maxlength="15"
autocomplete="off"
/>
</label>
</div>
}
</div>
<!-- PAN -->
<div
class="cx-pan"
[class.cx-pan--required]="panMissing"
[class.cx-pan--invalid]="panFormatInvalid"
[class.cx-pan--valid]="panValue && !panFormatInvalid"
>
<div class="cx-pan__head">
<mat-icon>credit_card</mat-icon>
<span class="cx-pan__label">PAN Card</span>
<span class="cx-pan__tag" [class.cx-pan__tag--on]="needsPan">{{ needsPan ? 'Mandatory' : 'Optional' }}</span>
</div>
<div class="cx-pan__input">
<input
type="text"
[value]="panNumber"
(input)="panNumber = $any($event.target).value.toUpperCase()"
placeholder="ABCDE1234F"
maxlength="10"
autocomplete="off"
spellcheck="false"
aria-label="PAN number"
/>
@if (panValue && !panFormatInvalid) {
<mat-icon class="cx-pan__state cx-pan__state--ok">check_circle</mat-icon>
} @else if (panFormatInvalid) {
<mat-icon class="cx-pan__state cx-pan__state--bad">error</mat-icon>
}
</div>
<div class="cx-pan__hint">
@if (panFormatInvalid) {
PAN is 5 letters, 4 digits, 1 letter (e.g. ABCDE1234F).
} @else if (panFromKyc) {
<mat-icon>link</mat-icon> Fetched from the customer's KYC record.
} @else if (panMissing) {
Required: this exchange is above ₹50,000 (Income Tax Rule 114B / RBI).
} @else {
Mandatory for exchanges above ₹50,000.
}
</div>
</div>
<!-- ── Customer Info Bar ── -->
<div class="cx-customer-bar">
<div
class="cx-customer-toggle"
[class.cx-customer-toggle--active]="isAccountHolder"
(click)="toggleAccountHolder()"
>
<mat-icon>{{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>
<span>Account Holder</span>
</div>
</section>
@if (isAccountHolder) {
<label class="cx-field cx-field--inline">
<span>Account No.</span>
<input
type="text"
[value]="accountNo"
(input)="accountNo = $any($event.target).value"
placeholder="e.g. 000101234567"
/>
</label>
} @else {
<span class="cx-walk-in"><mat-icon>person_outline</mat-icon> Walk-in Customer</span>
}
@if (needsIdProof) {
<div class="cx-pan-required">
<mat-icon>warning</mat-icon>
<span>Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI)</span>
</div>
<label class="cx-field cx-field--inline cx-field--pan">
<span>PAN / Aadhaar *</span>
<input
type="text"
[value]="customerIdProof"
(input)="customerIdProof = $any($event.target).value"
placeholder="ABCDE1234F"
maxlength="20"
/>
</label>
}
</div>
<div class="cx-cols">
<!-- The cashier's count of a denomination and what this exchange does to it, updated as counts are typed. -->
<ng-template #drawerCell let-d="d">
@if (availableFor(d) === null) {
—
} @else {
In drawer <strong>{{ availableFor(d) }}</strong>
@if (netFor(d) !== 0) {
<span class="cx-after">
<mat-icon>{{ netFor(d) > 0 ? 'trending_up' : 'trending_down' }}</mat-icon>
<strong>{{ drawerAfter(d) }}</strong>
<small>({{ netFor(d) > 0 ? '+' : '' }}{{ netFor(d) }})</small>
</span>
}
}
</ng-template>
<!-- RECEIVED -->
<section class="cx-col cx-col--received">
<header class="cx-col-head">
@@ -283,7 +78,7 @@
<div class="cx-subhead">Bank Notes</div>
}
@for (d of notes; track d.id) {
<div class="cx-row cx-row--stock">
<div class="cx-row">
<span class="cx-chip">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
@@ -295,9 +90,6 @@
/>
<button type="button" (click)="bump('r', d, 1)">+</button>
</div>
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
>
@@ -307,7 +99,7 @@
<div class="cx-subhead">Coins</div>
}
@for (d of coins; track d.id) {
<div class="cx-row cx-row--stock">
<div class="cx-row">
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
@@ -319,9 +111,6 @@
/>
<button type="button" (click)="bump('r', d, 1)">+</button>
</div>
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
>
@@ -342,7 +131,7 @@
<div class="cx-subhead">Bank Notes</div>
}
@for (d of notes; track d.id) {
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
<div class="cx-row">
<span class="cx-chip">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
@@ -352,16 +141,8 @@
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
<button type="button" (click)="bump('g', d, 1)">+</button>
</div>
<span
class="cx-avail"
[class.cx-avail--up]="netFor(d) > 0"
[class.cx-avail--down]="netFor(d) < 0"
[class.cx-avail--over]="exceedsAvailable(d)"
>
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
>
@@ -371,7 +152,7 @@
<div class="cx-subhead">Coins</div>
}
@for (d of coins; track d.id) {
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
<div class="cx-row">
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
@@ -381,16 +162,8 @@
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
<button type="button" (click)="bump('g', d, 1)">+</button>
</div>
<span
class="cx-avail"
[class.cx-avail--up]="netFor(d) > 0"
[class.cx-avail--down]="netFor(d) < 0"
[class.cx-avail--over]="exceedsAvailable(d)"
>
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
>
@@ -400,30 +173,6 @@
</section>
</div>
<!-- What this exchange does to the cashier's drawer, denomination by denomination. -->
@if (impactRows.length) {
<div class="cx-impact">
<div class="cx-impact__title"><mat-icon>inventory_2</mat-icon> Drawer after this exchange</div>
<div class="cx-impact__chips">
@for (r of impactRows; track r.id) {
<span
class="cx-impact__chip"
[class.cx-impact__chip--up]="r.net > 0"
[class.cx-impact__chip--down]="r.net < 0"
>
<b>{{ r.label }}</b>
{{ r.before }} → {{ r.after }}
<em>({{ r.net > 0 ? '+' : '' }}{{ r.net }})</em>
</span>
}
</div>
<div class="cx-impact__note">
The branch's total for each denomination moves by the same amount; the vault is not touched (Branch Ops → Branch
Cash Position).
</div>
</div>
}
<div class="cx-summary">
<div class="cx-sum">
<span>Received</span><strong>₹{{ receivedTotal | number: '1.2-2' }}</strong>
@@ -464,15 +213,9 @@
</div>
</div>
}
@if (panMissing) {
@if (needsIdProof && !customerIdProof.trim()) {
<div class="cx-warn cx-warn--pan">
<mat-icon>badge</mat-icon> Enter the customer's PAN to proceed (amount above ₹50,000).
</div>
}
@if (!customerIdentified && !loadingCustomer) {
<div class="cx-warn cx-warn--pan">
<mat-icon>person_search</mat-icon>
{{ customerMode === 'existing' ? 'Search and select the customer.' : "Enter the walk-in customer's name." }}
<mat-icon>badge</mat-icon> Enter customer PAN / Aadhaar to proceed (amount ≥ ₹50,000).
</div>
}
@@ -73,584 +73,99 @@
}
}
/* ── Customer: existing client or walk-in, plus PAN ── */
.cx-cust {
background: #fff;
/* ── Customer info bar ── */
.cx-customer-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
overflow: hidden;
border-radius: 10px;
flex-wrap: wrap;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 12px 16px;
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
border-bottom: 1px solid #e2e8f0;
}
.cx-customer-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #475569;
cursor: pointer;
user-select: none;
&__title {
display: flex;
align-items: center;
gap: 10px;
}
&__title-icon {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: #e6f4ee;
color: #0f4f3c;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
&__title-text {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__title-sub {
font-size: 12px;
color: #64748b;
}
&__modes {
display: inline-flex;
padding: 3px;
background: #e2e8f0;
border-radius: 10px;
}
&__mode {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 13px;
font-weight: 600;
color: #475569;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
color: #0f172a;
}
&--active,
&--active:hover {
background: #fff;
color: #0f4f3c;
box-shadow: 0 1px 3px rgb(15 23 42 / 15%);
}
}
&__body {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr);
gap: 16px;
padding: 16px;
}
&__who {
min-width: 0;
}
/* Nothing picked yet, or loading */
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 150px;
padding: 20px;
text-align: center;
background: #f8fafc;
border: 1.5px dashed #cbd5e1;
border-radius: 12px;
}
&__empty-icon {
font-size: 36px;
width: 36px;
height: 36px;
mat-icon {
font-size: 20px;
height: 20px;
width: 20px;
color: #94a3b8;
}
&__empty-title {
font-size: 14px;
font-weight: 600;
color: #334155;
}
&--active {
color: #1e40af;
&__empty-sub {
margin-bottom: 8px;
font-size: 12px;
color: #64748b;
}
/* A picked client */
&__card {
padding: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__card-top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
&__card-id {
flex: 1 1 180px;
min-width: 0;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #0f172a;
overflow-wrap: anywhere;
}
&__badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
&__card-actions {
display: flex;
gap: 4px;
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
span {
display: block;
margin-bottom: 2px;
font-size: 11px;
color: #64748b;
mat-icon {
color: #1e40af;
}
strong {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
}
/* A walk-in, identified at the counter */
&__walkin {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.cx-field {
font-size: 12px;
}
.cx-field input {
width: 100%;
height: 40px;
box-sizing: border-box;
}
}
.cx-field input:focus {
outline: none;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
}
}
.cx-avatar {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #1f7a4d, #0f4f3c);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
color: #fff;
&--sm {
width: 40px;
height: 40px;
font-size: 14px;
}
}
/* The cashier at the counter, or why there is none */
.cx-cashier {
.cx-walk-in {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px 20px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #1f7a4d;
border-radius: 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
&__id {
flex: 1 1 220px;
min-width: 0;
}
&__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__flow {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
margin-top: 2px;
font-size: 12px;
color: #475569;
span {
display: inline-flex;
align-items: center;
gap: 2px;
}
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
color: #94a3b8;
}
}
&__stats {
display: flex;
flex-wrap: wrap;
gap: 10px 28px;
span {
display: block;
font-size: 11px;
color: #64748b;
}
strong {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
}
&--none {
background: #fffbeb;
border-color: #fcd34d;
border-left-color: #d97706;
mat-icon {
color: #d97706;
}
.cx-cashier__label {
font-weight: 500;
letter-spacing: 0;
text-transform: none;
color: #92400e;
}
}
}
.cx-kyc {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
font-size: 12px;
color: #64748b;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&--ok {
background: #dcfce7;
color: #166534;
}
&--warn {
background: #fef3c7;
color: #92400e;
}
&--sm {
margin-left: 6px;
padding: 1px 6px;
font-size: 10px;
font-size: 16px;
height: 16px;
width: 16px;
}
}
.cx-chip-tag {
padding: 2px 8px;
border-radius: 6px;
background: #eef2ff;
font-size: 11px;
font-weight: 600;
color: #3730a3;
}
.cx-link-btn {
display: inline-flex;
.cx-pan-required {
display: flex;
align-items: center;
gap: 4px;
height: 30px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: transparent;
gap: 6px;
padding: 4px 10px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: #1e40af;
cursor: pointer;
color: #92400e;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
background: #eff6ff;
}
&--danger {
color: #b91c1c;
&:hover {
background: #fef2f2;
}
width: 16px;
color: #d97706;
}
}
/* PAN: neutral until needed, amber when required and missing, red when malformed, green when valid */
.cx-pan {
display: flex;
flex-direction: column;
align-self: start;
.cx-field--inline {
flex-direction: row;
align-items: center;
gap: 8px;
padding: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
&__head {
display: flex;
align-items: center;
gap: 8px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #475569;
}
span {
white-space: nowrap;
}
&__label {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
&__tag {
margin-left: auto;
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 600;
color: #475569;
&--on {
background: #fef3c7;
color: #92400e;
}
}
&__input {
position: relative;
input {
width: 100%;
height: 44px;
box-sizing: border-box;
padding: 0 40px 0 12px;
background: #fff;
border: 1.5px solid #cbd5e1;
border-radius: 10px;
font-family: ui-monospace, sfmono-regular, menlo, consolas, monospace;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #0f172a;
&::placeholder {
font-weight: 500;
color: #cbd5e1;
}
&:focus {
outline: none;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
}
}
}
&__state {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
font-size: 22px;
width: 22px;
height: 22px;
&--ok {
color: #16a34a;
}
&--bad {
color: #dc2626;
}
}
&__hint {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #64748b;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
&--required {
background: #fffbeb;
border-color: #fcd34d;
.cx-pan__input input {
border-color: #f59e0b;
}
.cx-pan__hint {
font-weight: 600;
color: #92400e;
}
}
&--invalid {
background: #fef2f2;
border-color: #fecaca;
.cx-pan__input input {
border-color: #ef4444;
}
.cx-pan__hint {
font-weight: 600;
color: #b91c1c;
}
}
&--valid {
background: #f0fdf4;
border-color: #bbf7d0;
.cx-pan__input input {
border-color: #22c55e;
}
.cx-pan__hint {
color: #166534;
}
input {
width: 180px;
}
}
.cx-spin {
animation: cx-spin 1s linear infinite;
}
.cx-field--pan input {
width: 160px;
border-color: #fcd34d;
@keyframes cx-spin {
to {
transform: rotate(360deg);
}
}
@media (width <= 900px) {
.cx-cust__body {
grid-template-columns: 1fr;
}
}
@media (width <= 600px) {
.cx-cust__walkin {
grid-template-columns: 1fr;
&:focus {
outline: none;
border-color: #d97706;
}
}
@@ -830,129 +345,6 @@
border-bottom: 1px dotted #eef2f7;
}
/* Both sides: the cashier's count of each denomination sits between the counter and the amount. */
.cx-row--stock {
grid-template-columns: 64px 120px 1fr 96px;
}
.cx-row--over {
background: #fef2f2;
}
.cx-avail {
padding-left: 6px;
font-size: 12px;
color: #64748b;
strong {
font-weight: 700;
color: #0f172a;
}
/* the count after this exchange: green when it goes up, red when it goes down */
.cx-after {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
font-weight: 600;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
small {
font-size: 11px;
}
}
&--up .cx-after,
&--up .cx-after strong {
color: #15803d;
}
&--down .cx-after,
&--down .cx-after strong {
color: #b45309;
}
&--over,
&--over strong,
&--over .cx-after,
&--over .cx-after strong {
color: #b91c1c;
}
}
/* The drawer after the whole exchange, one chip per denomination it changes */
.cx-impact {
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
&__title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #475569;
}
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 8px;
background: #f1f5f9;
font-size: 12px;
color: #334155;
b {
font-weight: 700;
}
em {
font-style: normal;
font-weight: 700;
}
&--up {
background: #dcfce7;
color: #166534;
}
&--down {
background: #fef3c7;
color: #92400e;
}
}
&__note {
margin-top: 8px;
font-size: 11px;
color: #64748b;
}
}
.cx-chip {
display: inline-flex;
align-items: center;
@@ -11,16 +11,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { ClientsService } from 'app/clients/clients.service';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -29,47 +20,6 @@ interface Denom {
label: string;
}
/** An existing client picked through Customer Search, with what the counter needs to identify them. */
interface SelectedCustomer {
clientId: number;
displayName: string;
cifNumber: string | null;
mobileNo: string | null;
officeName: string | null;
accountNo: string | null;
accountType: string | null;
/** PAN on the client's KYC record, if any. */
kycPan: string | null;
panVerified: boolean;
kycStatus: string;
}
/** The cashier at the counter: the one who takes the customer's notes and hands over the change. */
interface CashierInfo {
cashierId: number;
name: string;
tellerName: string | null;
officeName: string | null;
/** Cash the vault allocated to them for the day, once known. */
allocatedAmount: number | null;
/** Cash they hold now. */
totalAmount: number;
}
/** Same rule as ClientKycApiConstants.PAN_REGEX on the server: 5 letters, 4 digits, 1 letter. */
const PAN_PATTERN = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
/** Income Tax Rule 114B / RBI: a cash exchange above this needs the customer's PAN. */
const PAN_MANDATORY_ABOVE = 50000;
const KYC_STATUS: Record<number, string> = {
100: 'Pending',
200: 'In Progress',
300: 'Verified',
400: 'Rejected',
500: 'Expired'
};
/** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */
@Component({
selector: 'mifosx-cash-exchange-entry',
@@ -80,14 +30,11 @@ const KYC_STATUS: Record<number, string> = {
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatDialogModule
MatSnackBarModule
]
})
export class CashExchangeEntryComponent implements OnInit {
private service = inject(CashierService);
private clientsService = inject(ClientsService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
notes: Denom[] = [];
@@ -99,17 +46,11 @@ export class CashExchangeEntryComponent implements OnInit {
saving = false;
// ── Customer info ──
/** An existing client found through Customer Search, or a walk-in identified at the counter. */
customerMode: 'existing' | 'walkin' = 'walkin';
selectedCustomer: SelectedCustomer | null = null;
loadingCustomer = false;
walkInName = '';
walkInMobile = '';
panNumber = '';
isAccountHolder = false;
accountNo = '';
customerIdProof = '';
// ── Cashier at the counter, and their position (for the denomination overdraft check) ──
/** The cashier linked to the logged-in user; null when the login is not a cashier's. */
cashier: CashierInfo | null = null;
// ── Cashier position (for denomination overdraft check) ──
private availableMap = new Map<number, number>();
positionLoaded = false;
@@ -122,7 +63,7 @@ export class CashExchangeEntryComponent implements OnInit {
}
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -146,19 +87,7 @@ export class CashExchangeEntryComponent implements OnInit {
((res?.denominations || []) as any[]).forEach((d: any) => {
if (d.denominationId) this.availableMap.set(Number(d.denominationId), Number(d.count ?? 0));
});
const cashierId = res?.cashierId ? Number(res.cashierId) : null;
this.cashier = cashierId
? {
cashierId,
name: res.cashierName || '',
tellerName: null,
officeName: null,
allocatedAmount: null,
totalAmount: Number(res.totalAmount ?? 0)
}
: null;
this.positionLoaded = true;
if (cashierId) this.loadCashierDetails(cashierId);
},
error: () => {
this.positionLoaded = true;
@@ -166,31 +95,6 @@ export class CashExchangeEntryComponent implements OnInit {
});
}
/** The counter, branch and today's allocation; the card still shows name and cash in hand without them. */
private loadCashierDetails(cashierId: number): void {
this.service
.getInterCashierCashierSummary(cashierId, this.exchangeDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
if (!r || this.cashier?.cashierId !== cashierId) return;
this.cashier = {
...this.cashier,
tellerName: r.tellerName || null,
officeName: r.officeName || null,
allocatedAmount: Number(r.allocatedAmount ?? 0)
};
});
}
/** Up to two letters for the cashier's avatar. */
get cashierInitials(): string {
const words = (this.cashier?.name || '').trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
const first = words[0].charAt(0);
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
return (first + last).toUpperCase();
}
countFor(side: 'r' | 'g', d: Denom): number {
return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0;
}
@@ -224,102 +128,14 @@ export class CashExchangeEntryComponent implements OnInit {
return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal;
}
/** Above ₹50,000 the customer's PAN is mandatory (Income Tax Rule 114B, followed under RBI guidelines). */
get needsPan(): boolean {
return this.receivedTotal > PAN_MANDATORY_ABOVE;
}
/** Up to two letters for the avatar: first and last word of the customer's name. */
get customerInitials(): string {
const words = (this.selectedCustomer?.displayName || '').trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
const first = words[0].charAt(0);
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
return (first + last).toUpperCase();
}
get panValue(): string {
return this.panNumber.trim().toUpperCase();
}
/** A PAN that is entered must be well-formed, required or not. */
get panFormatInvalid(): boolean {
return !!this.panValue && !PAN_PATTERN.test(this.panValue);
}
get panMissing(): boolean {
return this.needsPan && !this.panValue;
}
/** The PAN comes from the client's KYC record, so the cashier confirms it rather than types it. */
get panFromKyc(): boolean {
return !!this.selectedCustomer?.kycPan && this.panValue === this.selectedCustomer.kycPan.toUpperCase();
}
/** Who the customer is has to be known: a picked client, or a walk-in's name once PAN is required. */
get customerIdentified(): boolean {
if (this.customerMode === 'existing') return !!this.selectedCustomer;
return !this.needsPan || this.walkInName.trim().length > 0;
}
/** A login with no cashier behind it has no drawer to take notes into or give change from. */
get noCashier(): boolean {
return this.positionLoaded && !this.cashier;
}
/** How many of the denomination the cashier holds, or null while that is not known (loading, or no cashier). */
availableFor(d: Denom): number | null {
if (!this.positionLoaded || !this.cashier) return null;
return this.availableMap.get(d.id) ?? 0;
}
/** The cashier is giving more of this denomination than they hold. */
exceedsAvailable(d: Denom): boolean {
const available = this.availableFor(d);
return available !== null && this.countFor('g', d) > available;
}
/** The + on the given side stops at what the cashier holds; a larger typed number is still caught and shown. */
canGiveMore(d: Denom): boolean {
const available = this.availableFor(d);
return available === null || this.countFor('g', d) < available;
}
/** What this exchange does to the cashier's count of the denomination: notes taken in less notes given out. */
netFor(d: Denom): number {
return this.countFor('r', d) - this.countFor('g', d);
}
/** The cashier's count of the denomination once this exchange is recorded, or null while it is not known. */
drawerAfter(d: Denom): number | null {
const available = this.availableFor(d);
return available === null ? null : available + this.netFor(d);
}
/**
* The denominations this exchange changes, with the drawer's count before and after, so the effect shows
* while the counts are being typed. The branch's total for each denomination moves by the same amount, as
* the customer's notes go into the drawer and the change comes out of it; the vault is not involved.
*/
get impactRows(): Array<{ id: number; label: string; kind: string; net: number; before: number; after: number }> {
if (!this.positionLoaded || !this.cashier) return [];
return this.denoms
.filter((d) => this.netFor(d) !== 0)
.map((d) => ({
id: d.id,
label: d.label,
kind: d.kind,
net: this.netFor(d),
before: this.availableFor(d) ?? 0,
after: this.drawerAfter(d) ?? 0
}));
/** Above ₹50,000 PAN/ID is mandatory per RBI guideline */
get needsIdProof(): boolean {
return this.receivedTotal >= 50000;
}
/** Denominations where cashier is giving more than currently available. */
get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> {
// Without a cashier every denomination would read as "0 available"; the missing cashier is the
// thing to tell the user, not a stock shortage.
if (!this.positionLoaded || !this.cashier) return [];
if (!this.positionLoaded) return [];
return this.denoms
.map((d) => ({ label: d.label, available: this.availableMap.get(d.id) ?? 0, requested: this.countFor('g', d) }))
.filter((item) => item.requested > 0 && item.requested > item.available);
@@ -330,77 +146,21 @@ export class CashExchangeEntryComponent implements OnInit {
}
get valid(): boolean {
return (
this.matches &&
!!this.cashier &&
!this.hasGivenOverdraft &&
!this.panFormatInvalid &&
!this.panMissing &&
this.customerIdentified &&
!this.loadingCustomer
);
return this.matches && (!this.needsIdProof || this.customerIdProof.trim().length > 0) && !this.hasGivenOverdraft;
}
setCustomerMode(mode: 'existing' | 'walkin'): void {
if (this.customerMode === mode) return;
this.customerMode = mode;
this.selectedCustomer = null;
this.walkInName = '';
this.walkInMobile = '';
this.panNumber = '';
}
/** Pick an existing client through Customer Search, then load their details and KYC (PAN). */
openCustomerSearch(): void {
this.dialog
// An existing customer without an account is still a known customer, so the account is not required.
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
.afterClosed()
.subscribe((result: CustomerSearchResult | undefined) => {
if (!result?.clientId) return;
this.loadCustomer(result);
});
}
private loadCustomer(result: CustomerSearchResult): void {
this.loadingCustomer = true;
const id = String(result.clientId);
forkJoin({
client: this.clientsService.getClientData(id, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(id, true).pipe(catchError(() => of(null)))
}).subscribe(({ client, kyc }: { client: any; kyc: any }) => {
const kycPan: string | null = kyc?.panNumber || result.panNumber || null;
this.selectedCustomer = {
clientId: result.clientId,
displayName: client?.displayName || result.displayName,
cifNumber: client?.cifNumber || null,
mobileNo: client?.mobileNo || result.mobileNo || null,
officeName: client?.officeName || result.officeName || null,
accountNo: result.accountNo || null,
accountType: result.accountType || null,
kycPan,
panVerified: !!kyc?.panVerified,
kycStatus: KYC_STATUS[Number(kyc?.kycStatus?.id ?? kyc?.kycStatus)] || 'Not on record'
};
this.panNumber = kycPan ? kycPan.toUpperCase() : '';
this.loadingCustomer = false;
});
}
clearCustomer(): void {
this.selectedCustomer = null;
this.panNumber = '';
toggleAccountHolder(): void {
this.isAccountHolder = !this.isAccountHolder;
if (!this.isAccountHolder) this.accountNo = '';
}
clear(): void {
this.receivedCounts = {};
this.givenCounts = {};
this.note = '';
this.customerMode = 'walkin';
this.selectedCustomer = null;
this.walkInName = '';
this.walkInMobile = '';
this.panNumber = '';
this.accountNo = '';
this.customerIdProof = '';
this.isAccountHolder = false;
}
submit(): void {
@@ -410,17 +170,11 @@ export class CashExchangeEntryComponent implements OnInit {
this.denoms
.filter((d) => this.countFor(side, d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) }));
const customer = this.customerMode === 'existing' ? this.selectedCustomer : null;
const pan = this.panValue || null;
const payload: any = {
exchangeDate: this.exchangeDate,
note: this.note || null,
clientId: customer?.clientId ?? null,
accountNo: customer?.accountNo ?? null,
customerName: customer ? customer.displayName : this.walkInName.trim() || null,
customerMobile: customer ? customer.mobileNo : this.walkInMobile.trim() || null,
panNumber: pan,
customerIdProof: pan,
customerIdProof: this.customerIdProof.trim() || null,
accountNo: this.isAccountHolder && this.accountNo.trim() ? this.accountNo.trim() : null,
received: build('r'),
given: build('g')
};
@@ -11,11 +11,25 @@
<div>
<div class="pg-eyebrow">CASHIER · HISTORY</div>
<h1 class="pg-title">Cash Movement History</h1>
<p class="pg-sub">All transfers, settlements, inter-cashier movements and cash exchanges.</p>
<p class="pg-sub">All transfers, settlements, and inter-cashier movements. Reverse any active transaction.</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="load()"><mat-icon>refresh</mat-icon> Refresh</button>
</header>
<!-- Reversal confirm panel -->
@if (reversingId !== null) {
<div class="reverse-panel">
<mat-icon class="reverse-icon">warning</mat-icon>
<span
>Reverse <strong>#{{ reversingId }}</strong
>? This cannot be undone.</span
>
<input class="reverse-reason" type="text" [(ngModel)]="reverseReason" placeholder="Reason (optional)" />
<button type="button" class="pg-btn pg-btn--danger" (click)="confirmReverse()">Confirm Reversal</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelReverse()">Cancel</button>
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@@ -120,6 +134,11 @@
>
<mat-icon>visibility</mat-icon>
</button>
@if (!r.isReversed) {
<button type="button" class="pg-btn pg-btn--ghost btn-sm" title="Reverse" (click)="startReverse(r)">
<mat-icon>undo</mat-icon>
</button>
}
</div>
</td>
</tr>
@@ -168,12 +187,6 @@
<span class="detail-meta__label">Date</span>
<span class="detail-meta__val">{{ detail.row.date }}</span>
</div>
@if (detail.row.type === 'exchange') {
<div class="detail-meta__item">
<span class="detail-meta__label">Customer PAN</span>
<span class="detail-meta__val">{{ detail.row.panNumber || '—' }}</span>
</div>
}
<div class="detail-meta__item">
<span class="detail-meta__label">Total Amount</span>
<span class="detail-meta__val detail-meta__val--amount">₹{{ detail.row.amount | number: '1.2-2' }}</span>
@@ -47,6 +47,38 @@
}
}
.reverse-panel {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 10px;
flex-wrap: wrap;
font-size: 13px;
color: #92400e;
.reverse-icon {
color: #f59e0b;
font-size: 20px;
height: 20px;
width: 20px;
}
.reverse-reason {
flex: 1 1 200px;
height: 34px;
padding: 0 10px;
border: 1px solid #fcd34d;
border-radius: 7px;
font-size: 13px;
font-family: inherit;
background: #fff;
color: #1a1a1a;
}
}
/* ── Action buttons cell ── */
.action-btns {
display: flex;
@@ -8,7 +8,9 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { forkJoin, of } from 'rxjs';
@@ -29,8 +31,6 @@ interface TxnRow {
reversedBy: number | null;
reversedAt: string | null;
reverseReason: string | null;
/** Customer PAN recorded on a cash exchange. */
panNumber?: string | null;
type: 'transfer' | 'settlement' | 'inter' | 'exchange';
}
@@ -52,10 +52,7 @@ interface DetailView {
loading: boolean;
}
/**
* Unified history of all cash movements with a detail view. Read-only: cash movements cannot be reversed
* from here (an earlier reversal still shows as Reversed, with who, when and why).
*/
/** Unified history of all cash movements with reversal and detail view support. */
@Component({
selector: 'mifosx-cash-history',
templateUrl: './cash-history.component.html',
@@ -66,13 +63,16 @@ interface DetailView {
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatTabsModule,
MatPaginatorModule
]
})
export class CashHistoryComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
transfers: TxnRow[] = [];
settlements: TxnRow[] = [];
@@ -99,6 +99,10 @@ export class CashHistoryComponent implements OnInit {
private denomKindMap = new Map<number, 'note' | 'coin'>();
reversingId: number | null = null;
reversingType: string | null = null;
reverseReason = '';
detail: DetailView | null = null;
ngOnInit(): void {
@@ -208,8 +212,7 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (exchangeItems as any[]).map((e) => ({
id: e.id,
date: e.exchangeDate,
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
@@ -307,8 +310,7 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (res.pageItems ?? []).map((e: any) => ({
id: e.id,
date: e.exchangeDate,
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
@@ -390,12 +392,39 @@ export class CashHistoryComponent implements OnInit {
this.detail = null;
}
/** Who the exchange was with: the customer's name (and account, if one), else the account, else a walk-in. */
private exchangeParty(e: any): string {
if (e.customerName) {
return e.accountNo ? `${e.customerName} (A/C ${e.accountNo})` : e.customerName;
}
return e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer';
startReverse(row: TxnRow): void {
this.reversingId = row.id;
this.reversingType = row.type;
this.reverseReason = '';
}
cancelReverse(): void {
this.reversingId = null;
this.reversingType = null;
this.reverseReason = '';
}
confirmReverse(): void {
if (!this.reversingId || !this.reversingType) return;
const id = this.reversingId;
const type = this.reversingType;
const reason = this.reverseReason;
this.cancelReverse();
let obs$;
if (type === 'transfer') obs$ = this.service.reverseCashTransfer(id, reason);
else if (type === 'settlement') obs$ = this.service.reverseCashSettlement(id, reason);
else if (type === 'exchange') obs$ = this.service.reverseCashExchange(id, reason);
else obs$ = this.service.reverseInterCashierTransfer(id, reason);
obs$.pipe(catchError(() => of(null))).subscribe((res: any) => {
if (res?.reversed) {
this.snackBar.open('Transaction reversed successfully', 'Close', { duration: 3000 });
this.load();
} else {
this.snackBar.open('Reversal failed', 'Close', { duration: 3000 });
}
});
}
typeLabel(type: string): string {
@@ -18,7 +18,6 @@ import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
@@ -91,7 +90,7 @@ export class CashSettlementComponent implements OnInit {
saving = false;
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -1,229 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cv-tab-content cta-tab">
<!-- ── TITLE BAR ── -->
<div class="cta-action-bar">
<div class="cta-action-bar__left">
<mat-icon class="cta-action-bar__icon">swap_horiz</mat-icon>
<div>
<div class="cta-action-bar__title">Cash Transfers</div>
<div class="cta-action-bar__sub">
Approve or reject an allocation of cash from the vault to a cashier. The cash leaves the vault only when it is
approved.
</div>
</div>
</div>
<div class="cta-counts">
<span class="cta-count"
><strong>{{ all.length }}</strong> waiting</span
>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="cta-filter-bar">
<div class="cta-search-box">
<mat-icon>search</mat-icon>
<input type="text" placeholder="Search by cashier, vault, user or note" [(ngModel)]="searchTerm" />
@if (searchTerm) {
<button type="button" class="cta-search-box__clear" (click)="searchTerm = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
<button type="button" class="cta-btn cta-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading && !loaded) {
<div class="cta-empty"><p class="cta-empty__title">Loading…</p></div>
} @else if (rows.length === 0) {
<div class="cta-empty">
<mat-icon class="cta-empty__icon">inbox</mat-icon>
<p class="cta-empty__title">No cash transfer waiting</p>
<p class="cta-empty__text">There is no cash allocation waiting for approval.</p>
</div>
} @else {
<div class="cta-table-wrap">
<table class="cta-table">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>To (Cashier)</th>
<th class="num">Amount</th>
<th>Note</th>
<th>Raised By</th>
<th>Raised At</th>
<th class="cta-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.id) {
<tr class="cta-row">
<td class="cta-dt">{{ r.transfer_date || '—' }}</td>
<td>{{ r.from_party || '—' }}</td>
<td class="cta-strong">{{ r.to_party || '—' }}</td>
<td class="num cta-amount">₹{{ r.total_amount | number: '1.2-2' }}</td>
<td class="cta-reason" [title]="r.note || ''">{{ r.note || '—' }}</td>
<td>{{ r.maker_name || '—' }}</td>
<td class="cta-dt">{{ formatDateTime(r.maker_at) }}</td>
<td class="cta-action-cell">
<button type="button" class="cta-btn cta-btn--approve" (click)="review(r)">
<mat-icon>rate_review</mat-icon> Review
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: review a request, every note and coin it asks for ───────────── -->
<ng-template #reviewDialog>
@if (reviewing) {
<div class="cta-dlg">
<header class="cta-dlg__head">
<mat-icon class="cta-dlg__ico cta-dlg__ico--ok">swap_horiz</mat-icon>
<div class="cta-dlg__titles">
<div class="cta-dlg__title">Review cash transfer</div>
<div class="cta-dlg__sub">
Raised by {{ reviewing.maker_name || '—' }} · {{ formatDateTime(reviewing.maker_at) }}
</div>
</div>
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="closeReview()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="cta-dlg__body">
<div class="cta-total">
<span>Amount to release</span>
<strong>₹{{ reviewing.total_amount | number: '1.2-2' }}</strong>
</div>
<div class="cta-grid">
<div class="cta-cell">
<div class="cta-label">Transfer date</div>
<div class="cta-value">{{ reviewing.transfer_date || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">From</div>
<div class="cta-value">{{ reviewing.from_party || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">To (cashier)</div>
<div class="cta-value">{{ reviewing.to_party || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">Notes and coins</div>
<div class="cta-value">
{{ piecesOf(reviewing) }} pieces · {{ linesOf(reviewing).length }} denominations
</div>
</div>
@if (reviewing.note) {
<div class="cta-cell cta-cell--wide">
<div class="cta-label">Note</div>
<div class="cta-value">{{ reviewing.note }}</div>
</div>
}
</div>
<div class="cta-label">Denomination breakdown</div>
<table class="cta-mini">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (l of linesOf(reviewing); track l.value) {
<tr>
<td>₹{{ l.value }}</td>
<td class="num">{{ l.count }}</td>
<td class="num">₹{{ l.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td class="num">{{ piecesOf(reviewing) }}</td>
<td class="num">₹{{ reviewing.total_amount | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
<footer class="cta-dlg__foot">
<button type="button" class="cta-btn cta-btn--ghost" (click)="closeReview()">Close</button>
<button
type="button"
class="cta-btn cta-btn--reject"
[disabled]="busyId === reviewing.id"
(click)="rejectFromReview(reviewing)"
>
<mat-icon>close</mat-icon> Reject
</button>
<button
type="button"
class="cta-btn cta-btn--approve"
[disabled]="busyId === reviewing.id"
(click)="approve(reviewing)"
>
<mat-icon>check</mat-icon> Approve
</button>
</footer>
</div>
}
</ng-template>
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
<ng-template #rejectDialog>
@if (rejecting) {
<div class="cta-dlg">
<header class="cta-dlg__head">
<mat-icon class="cta-dlg__ico">block</mat-icon>
<div class="cta-dlg__titles">
<div class="cta-dlg__title">Reject cash transfer</div>
<div class="cta-dlg__sub">₹{{ rejecting.total_amount | number: '1.2-2' }} to {{ rejecting.to_party }}</div>
</div>
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="cancelReject()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="cta-dlg__body">
<label class="cta-label" for="cta-reason">Reason (required)</label>
<textarea
id="cta-reason"
rows="3"
placeholder="Why is this being rejected?"
[(ngModel)]="rejectReason"
></textarea>
</div>
<footer class="cta-dlg__foot">
<button type="button" class="cta-btn cta-btn--ghost" (click)="cancelReject()">Cancel</button>
<button
type="button"
class="cta-btn cta-btn--reject-solid"
[disabled]="!rejectReason.trim() || busyId === rejecting.id"
(click)="confirmReject()"
>
Confirm reject
</button>
</footer>
</div>
}
</ng-template>
@@ -1,573 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
:host {
display: block;
}
.cta-tab {
padding: 0;
}
.cta-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #0f4f3c;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
max-width: 640px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
.cta-counts {
display: flex;
gap: 8px;
}
.cta-count {
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
color: #475569;
strong {
color: #0f4f3c;
}
}
.cta-filter-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.cta-fb-sep {
flex-shrink: 0;
width: 1px;
height: 22px;
margin: 0 4px;
background: #e2e8f0;
}
.cta-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
min-width: 240px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
mat-icon {
flex-shrink: 0;
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: #94a3b8;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.cta-filter-select {
height: 30px;
min-width: 140px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
// ── Buttons ─────────────────────────────────────────────────────────────────
.cta-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
font-family: inherit;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&--refresh {
height: 30px;
width: 30px;
justify-content: center;
padding: 0;
margin-left: auto;
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
&--approve {
background: #0f4f3c;
color: #fff;
&:hover:not(:disabled) {
background: #0a3d2e;
}
}
&--reject {
border-color: #fecaca;
background: #fff;
color: #dc2626;
&:hover:not(:disabled) {
background: #fef2f2;
}
}
&--reject-solid {
background: #dc2626;
color: #fff;
}
&--ghost {
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.cta-table-wrap {
overflow-x: auto;
background: #fff;
}
.cta-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
padding: 7px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
color: #0d1b2a;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.cta-th-action {
text-align: right;
}
}
.cta-row:hover td {
background: #f8fafc;
}
.cta-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.cta-voucher {
font-weight: 700;
color: #0f4f3c;
}
.cta-strong {
font-weight: 700;
}
.cta-sub {
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
.cta-none {
color: #cbd5e1;
}
.cta-amount {
font-weight: 700;
}
.cta-reason {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cta-dt {
white-space: nowrap;
color: #475569;
}
.cta-kind {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&--unpass {
background: #fef3c7;
color: #92400e;
}
&--correction {
background: #dbeafe;
color: #1e40af;
}
}
.cta-action-cell {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
// ── Empty ───────────────────────────────────────────────────────────────────
.cta-empty {
padding: 28px 16px;
text-align: center;
color: #94a3b8;
&__icon {
display: block;
margin: 0 auto 6px;
font-size: 30px;
width: 30px;
height: 30px;
opacity: 0.6;
}
&__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
&__text {
margin: 3px 0 0;
font-size: 11px;
}
}
// ── Reject popup ────────────────────────────────────────────────────────────
.cta-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #fef2f2;
color: #dc2626;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
padding: 12px 14px;
max-height: 64vh;
overflow-y: auto;
textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.cta-label {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.cta-dlg__ico--ok {
background: #f0fdf4;
color: #1f7a4d;
}
.cta-total {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 12px;
font-weight: 600;
strong {
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
}
.cta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 6px;
margin-bottom: 12px;
}
.cta-cell {
padding: 6px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f7;
&--wide {
grid-column: 1 / -1;
}
}
.cta-value {
font-size: 12px;
font-weight: 600;
color: #0f172a;
word-break: break-word;
}
.cta-mini {
width: 100%;
margin-top: 4px;
border-collapse: collapse;
font-size: 12px;
th {
padding: 6px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.05em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tfoot td {
border-top: 2px solid #e2e8f0;
border-bottom: 0;
font-weight: 800;
background: #f8fafc;
}
}
@@ -1,190 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
/** A cash transfer request waiting for a checker, as the server keeps it (snake_case columns). */
interface CashTransferRequest {
id: number;
transfer_date: string | null;
from_party: string | null;
to_party: string | null;
note: string | null;
total_amount: number;
maker_name: string | null;
maker_at: string | null;
denominations: { denominationValue: number; count: number }[];
}
/**
* Checker side of Cash Transfer: approves or rejects an allocation of cash from the vault to a cashier. The cash
* leaves the vault only when it is approved, by someone other than the person who raised it.
*/
@Component({
selector: 'mifosx-cash-transfer-approvals',
templateUrl: './cash-transfer-approvals.component.html',
styleUrls: ['./cash-transfer-approvals.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule,
MatSnackBarModule
]
})
export class CashTransferApprovalsComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
@ViewChild('reviewDialog') reviewDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
private reviewRef: MatDialogRef<unknown> | null = null;
/** The request open in the review popup, with every note and coin it asks for. */
reviewing: CashTransferRequest | null = null;
all: CashTransferRequest[] = [];
loading = false;
loaded = false;
busyId: number | null = null;
searchTerm = '';
rejecting: CashTransferRequest | null = null;
rejectReason = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getCashTransferRequests('PENDING')
.pipe(catchError(() => of([] as CashTransferRequest[])))
.subscribe((rows) => {
this.all = rows as CashTransferRequest[];
this.loading = false;
this.loaded = true;
});
}
get rows(): CashTransferRequest[] {
const term = this.searchTerm.trim().toLowerCase();
if (!term) return this.all;
return this.all.filter((r) => [
r.from_party,
r.to_party,
r.maker_name,
r.note
].some((v) => (v ?? '').toLowerCase().includes(term)));
}
/** One line per denomination asked for, largest first, with what each line adds up to. */
linesOf(r: CashTransferRequest): { value: number; count: number; amount: number }[] {
return [...(r.denominations ?? [])]
.filter((d) => d.count > 0)
.sort((a, b) => b.denominationValue - a.denominationValue)
.map((d) => ({ value: d.denominationValue, count: d.count, amount: d.denominationValue * d.count }));
}
/** How many notes and coins the request asks for in all. */
piecesOf(r: CashTransferRequest): number {
return this.linesOf(r).reduce((sum, l) => sum + l.count, 0);
}
review(r: CashTransferRequest): void {
this.reviewing = r;
this.reviewRef?.close();
this.reviewRef = this.dialog.open(this.reviewDialog, { width: '560px', maxWidth: '95vw', autoFocus: false });
this.reviewRef.afterClosed().subscribe(() => (this.reviewing = null));
}
closeReview(): void {
this.reviewRef?.close();
}
/** Rejecting from the review popup: the review closes, the reason popup opens for the same request. */
rejectFromReview(r: CashTransferRequest): void {
this.reviewRef?.close();
this.startReject(r);
}
formatDateTime(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
return isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
approve(r: CashTransferRequest): void {
if (this.busyId) return;
this.busyId = r.id;
this.service.approveCashTransferRequest(r.id).subscribe({
next: (res: any) => {
this.busyId = null;
this.reviewRef?.close();
this.snackBar.open(
res?.glWarning ? `Approved (GL warning: ${res.glWarning})` : 'Approved — cash allocated to the cashier',
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not approve');
}
});
}
startReject(r: CashTransferRequest): void {
this.rejecting = r;
this.rejectReason = '';
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
}
cancelReject(): void {
this.dialogRef?.close();
}
confirmReject(): void {
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
const id = this.rejecting.id;
this.busyId = id;
this.service.rejectCashTransferRequest(id, this.rejectReason.trim()).subscribe({
next: () => {
this.busyId = null;
this.dialogRef?.close();
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not reject');
}
});
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 8000 });
}
}
@@ -13,15 +13,7 @@
<h1 class="pg-title">Cash Transfer</h1>
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
</div>
<div class="ct-head-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="openRequests()">
<mat-icon>pending_actions</mat-icon> My Requests
@if (pendingCount) {
<span class="ct-badge">{{ pendingCount }}</span>
}
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</header>
<div class="pg-toolbar pg-toolbar--mat">
@@ -58,7 +50,7 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label>
<input matInput type="date" [value]="transferDate" (change)="onDateChange($any($event.target).value)" />
<input matInput type="date" [value]="transferDate" (change)="transferDate = $any($event.target).value" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
@@ -67,28 +59,11 @@
</mat-form-field>
</div>
<!-- What the vault actually has, before anyone starts counting a handout it can't cover. -->
@if (fromTellerId && !loadingVault) {
@if (vaultHasBreakdown) {
<div class="ct-vault-banner">
<mat-icon>account_balance</mat-icon>
Vault available: <strong>₹{{ vaultTotalAvailable | number: '1.2-2' }}</strong>
</div>
} @else {
<div class="ct-vault-banner ct-vault-banner--warn">
<mat-icon>warning</mat-icon>
No denomination breakdown recorded for this vault — per-note availability can't be checked, only the total
opening balance.
</div>
}
}
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Available</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
</tr>
@@ -96,17 +71,14 @@
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="4">Bank Notes</td>
<td colspan="3">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr [class.ct-row--over]="exceedsAvailable(d)">
<tr>
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td class="num" [class.ct-available--over]="exceedsAvailable(d)">
{{ vaultHasBreakdown ? availableFor(d) : '—' }}
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -119,17 +91,14 @@
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="4">Coins</td>
<td colspan="3">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr [class.ct-row--over]="exceedsAvailable(d)">
<tr>
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td class="num" [class.ct-available--over]="exceedsAvailable(d)">
{{ vaultHasBreakdown ? availableFor(d) : '—' }}
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -142,77 +111,17 @@
}
@if (denoms.length === 0) {
<tr>
<td colspan="4" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
</tr>
}
</tbody>
</table>
</div>
@if (anyDenomExceedsAvailable) {
<div class="ct-limit-warn">
One or more notes/coins above are counted higher than the vault currently holds — reduce them before recording the
transfer.
</div>
} @else if (exceedsVaultTotal) {
<div class="ct-limit-warn">
Total exceeds what the vault has available (₹{{ vaultTotalAvailable | number: '1.2-2' }}).
</div>
}
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
<mat-icon>send</mat-icon> Send for approval
<mat-icon>swap_horiz</mat-icon> Record Transfer
</button>
</div>
</div>
<!-- Popup: this person's own cash transfer requests, and where each stands. The cash moves only once a manager approves. -->
<ng-template #requestsDialog>
<div class="ct-dlg">
<header class="ct-dlg__head">
<mat-icon class="ct-dlg__ico">pending_actions</mat-icon>
<div class="ct-dlg__titles">
<div class="ct-dlg__title">My transfer requests</div>
<div class="ct-dlg__sub">Cash leaves the vault only after a manager approves</div>
</div>
<button type="button" class="ct-dlg__x" aria-label="Close" (click)="closeRequests()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="ct-dlg__body">
@if (myRequests.length) {
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>To</th>
<th class="num">Amount</th>
<th>Status</th>
<th>Approved by</th>
</tr>
</thead>
<tbody>
@for (r of myRequests; track r.id) {
<tr>
<td>{{ r.transfer_date || '—' }}</td>
<td>{{ r.to_party || '—' }}</td>
<td class="num">₹{{ r.total_amount | number: '1.2-2' }}</td>
<td>
<span class="ct-status" [class]="'ct-status ct-status--' + r.status">{{ r.status }}</span>
@if (r.rejection_reason) {
<div class="ct-requests__note">{{ r.rejection_reason }}</div>
}
</td>
<td>{{ r.checker_name || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="pg-empty">You have not raised any cash transfer request yet.</div>
}
</div>
</div>
</ng-template>
@@ -1,179 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.ct-vault-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
margin-bottom: 10px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
font-size: 12px;
color: #1e40af;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
}
.ct-row--over {
background: #fef2f2;
}
.ct-available--over {
color: #b91c1c;
font-weight: 700;
}
.ct-limit-warn {
margin: 8px 0;
padding: 8px 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 12px;
color: #991b1b;
}
.ct-requests {
padding: 0;
&__title {
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__note {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
}
.ct-status {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
&--PENDING {
background: #fef3c7;
color: #92400e;
}
&--APPROVED {
background: #d1fae5;
color: #065f46;
}
&--REJECTED {
background: #fee2e2;
color: #991b1b;
}
}
.ct-head-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.ct-badge {
min-width: 18px;
padding: 0 6px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.ct-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #f0fdf4;
color: #1f7a4d;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
max-height: 60vh;
overflow-y: auto;
}
}
@@ -6,10 +6,9 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -18,7 +17,6 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -31,16 +29,12 @@ interface Denom {
@Component({
selector: 'mifosx-cash-transfer',
templateUrl: './cash-transfer.component.html',
styleUrls: [
'../cashier-shared.scss',
'./cash-transfer.component.scss'
],
styleUrls: ['../cashier-shared.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
@@ -50,10 +44,6 @@ interface Denom {
export class CashTransferComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
private requestsRef: MatDialogRef<unknown> | null = null;
notes: Denom[] = [];
coins: Denom[] = [];
@@ -80,43 +70,8 @@ export class CashTransferComponent implements OnInit {
];
}
/** What the selected vault currently holds, denomination-wise — fetched so a clerk can see
* before counting whether the vault even has enough of a given note, instead of finding out
* only after the backend rejects the transfer. */
vaultAvailable: Record<number, number> = {};
vaultTotalAvailable = 0;
vaultHasBreakdown = true;
loadingVault = false;
/** The caller's own transfer requests, newest first, so a request can be followed to its approval. */
myRequests: any[] = [];
loadMyRequests(): void {
this.service
.getCashTransferRequests(undefined, true)
.pipe(catchError(() => of([] as any[])))
.subscribe((rows) => (this.myRequests = rows.slice(0, 20)));
}
/** How many of this person's requests are still waiting for a manager. */
get pendingCount(): number {
return this.myRequests.filter((r) => r.status === 'PENDING').length;
}
/** Opens the list of this person's requests, freshly read, so a request can be followed to its approval. */
openRequests(): void {
this.loadMyRequests();
this.requestsRef?.close();
this.requestsRef = this.dialog.open(this.requestsDialog, { width: '720px', maxWidth: '95vw', autoFocus: false });
}
closeRequests(): void {
this.requestsRef?.close();
}
ngOnInit(): void {
this.loadMyRequests();
const d = businessToday();
const d = new Date();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -146,8 +101,7 @@ export class CashTransferComponent implements OnInit {
});
}
/** When a From-teller is chosen, load ONLY that teller's cashiers into the To dropdown, and the
* vault's own current denomination-wise stock so it can be shown against every count field. */
/** When a From-teller is chosen, load ONLY that teller's cashiers into the To dropdown. */
onTellerSelect(tellerId: number): void {
this.fromTellerId = tellerId;
const teller = this.tellers.find((t) => t.id === tellerId);
@@ -155,7 +109,6 @@ export class CashTransferComponent implements OnInit {
this.toParty = '';
this.toCashierId = null;
this.cashiers = [];
this.loadVaultAvailability();
if (!tellerId) {
return;
}
@@ -172,45 +125,6 @@ export class CashTransferComponent implements OnInit {
});
}
/** Refetched whenever the vault or the transfer date changes — the vault's stock is only ever
* meaningful as of a specific business date. */
loadVaultAvailability(): void {
this.vaultAvailable = {};
this.vaultTotalAvailable = 0;
this.vaultHasBreakdown = true;
if (!this.fromTellerId) return;
this.loadingVault = true;
this.service
.vaultPosition(this.fromTellerId, this.transferDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.loadingVault = false;
this.vaultHasBreakdown = !!res?.hasBreakdown;
this.vaultTotalAvailable = Number(res?.totalAmount ?? 0);
const map: Record<number, number> = {};
((res?.denominations || []) as any[]).forEach((d: any) => (map[d.denominationId] = d.count));
this.vaultAvailable = map;
});
}
onDateChange(value: string): void {
this.transferDate = value;
this.loadVaultAvailability();
}
/** How many of this denomination the vault currently holds — 0, not undefined, when the vault's
* breakdown is known but simply doesn't list this denomination (i.e. it truly has none left). */
availableFor(d: Denom): number {
return this.vaultAvailable[d.id] ?? 0;
}
/** Whether the count entered for this denomination is more than the vault is known to hold —
* only flagged once the vault's breakdown has actually loaded, so an empty in-flight state
* doesn't flash every row as over-limit. */
exceedsAvailable(d: Denom): boolean {
return this.vaultHasBreakdown && this.countFor(d) > this.availableFor(d);
}
/** When a To-cashier is chosen, capture its id and name. */
onCashierSelect(cashierId: number): void {
this.toCashierId = cashierId;
@@ -238,24 +152,8 @@ export class CashTransferComponent implements OnInit {
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
}
/** True once any counted denomination is over what the vault holds — checked client-side purely
* so the clerk sees it before submitting; the backend enforces this too regardless. */
get anyDenomExceedsAvailable(): boolean {
return this.denoms.some((d) => this.exceedsAvailable(d));
}
get exceedsVaultTotal(): boolean {
return this.vaultHasBreakdown && this.total > this.vaultTotalAvailable && this.total > 0;
}
get valid(): boolean {
return (
this.total > 0 &&
!!this.fromTellerId &&
!!this.toCashierId &&
!this.anyDenomExceedsAvailable &&
!this.exceedsVaultTotal
);
return this.total > 0 && !!this.fromTellerId && !!this.toCashierId;
}
clear(): void {
@@ -278,11 +176,16 @@ export class CashTransferComponent implements OnInit {
.filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
};
this.service.createCashTransferRequest(payload).subscribe({
next: () => {
// Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault.
this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 });
this.loadMyRequests();
this.service.createCashTransfer(payload).subscribe({
next: (res: any) => {
if (res?.glWarning) {
this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', {
duration: 8000,
panelClass: ['snack-warn']
});
} else {
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
}
this.clear();
this.note = '';
this.fromParty = '';
@@ -17,7 +17,6 @@ import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -72,7 +71,7 @@ export class CashierPositionComponent implements OnInit {
isCashierMode = false;
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.isCashierMode = this.authService.isCashierUser();
+7 -185
View File
@@ -19,14 +19,9 @@ import { InterCashierTransferComponent } from './inter-cashier-transfer/inter-ca
import { CashierPositionComponent } from './cashier-position/cashier-position.component';
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { CashHistoryComponent } from './cash-history/cash-history.component';
import { DayBookComponent } from './day-book/day-book.component';
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
import { BranchDayComponent } from './branch-day/branch-day.component';
import { VaultPositionComponent } from './vault-position/vault-position.component';
import { BranchCashPositionComponent } from './branch-cash-position/branch-cash-position.component';
import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
const routes: Routes = [
Route.withShell([
@@ -65,7 +60,7 @@ const routes: Routes = [
import('../clerk/my-disbursement-requests/my-disbursement-requests.component').then(
(m) => m.MyDisbursementRequestsComponent
),
data: { title: 'Disbursements & Transfer', breadcrumb: 'Disbursements & Transfer' }
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
},
{
path: 'share-withdrawal-request',
@@ -83,21 +78,6 @@ const routes: Routes = [
),
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
},
{
// The same page as the cashier's, reached from the clerk dashboard: a clerk unpasses vouchers here.
path: 'voucher-correction',
loadComponent: () =>
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
},
{
path: 'closure-requests',
loadComponent: () =>
import('../clerk/my-closure-requests/my-closure-requests.component').then(
(m) => m.MyClosureRequestsComponent
),
data: { title: 'My Closure Requests', breadcrumb: 'My Closure Requests' }
},
{
path: 'account-facilities',
loadComponent: () =>
@@ -124,59 +104,6 @@ const routes: Routes = [
import('../clerk/photo-signature/photo-signature.component').then((m) => m.PhotoSignatureComponent),
data: { title: 'Photo & Signature', breadcrumb: 'Photo & Signature' }
},
// The same register the cashier screens use, without their cash-only filter: a clerk raises
// book entries — a closure settled to savings, a share redemption, an interest posting the
// system made on their approval — and cash-only would hide exactly those.
{
path: 'received-report',
component: VoucherReportComponent,
data: {
title: 'Received Vouchers',
breadcrumb: 'Received Vouchers',
side: 'CR',
cashOnly: false,
cashiersOnly: true
}
},
{
path: 'payment-report',
component: VoucherReportComponent,
data: {
title: 'Payment Vouchers',
breadcrumb: 'Payment Vouchers',
side: 'DR',
cashOnly: false,
cashiersOnly: true
}
},
// Both sides in one list, scoped to whoever is signed in — the "what did I do today"
// question the two registers above cannot answer, because an approval raises no voucher.
{
path: 'my-work',
loadComponent: () => import('../cashier/my-work/my-work.component').then((m) => m.MyWorkComponent),
data: { title: 'My Vouchers', breadcrumb: 'My Vouchers' }
},
{
path: 'end-of-day',
loadComponent: () =>
import('../cashier/end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent),
data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' }
},
// The system's own postings — interest, charges, escheats, journals. Both sides in one
// list: a night's run credits and debits, and splitting it across two screens would hide
// half of it from whichever one you opened.
{
path: 'system-vouchers',
component: VoucherReportComponent,
data: {
title: 'System Vouchers',
breadcrumb: 'System Vouchers',
side: 'ALL',
cashOnly: false,
cashiersOnly: false,
systemOnly: true
}
},
{
path: 'standing-instructions',
loadComponent: () =>
@@ -201,64 +128,6 @@ const routes: Routes = [
}
]
},
{
// Branch-level operations, kept out of the cashier tree on purpose: opening and closing the
// day governs the whole branch, and is not something a cashier does.
path: 'branch-ops',
data: { title: 'Branch Operations', breadcrumb: 'Branch Operations' },
children: [
{
path: 'day-register',
component: BranchDayComponent,
data: { title: 'Day Open / Day Close', breadcrumb: 'Day Register' }
},
{
// Same screen, narrowed to the days that were never closed. A separate route rather than
// a second link to the register, so choosing it visibly does something.
path: 'missed-close',
component: BranchDayComponent,
data: { title: 'Missed Close Register', breadcrumb: 'Missed Close', missedOnly: true }
},
{
// Lives here alongside the day register because both answer "what did this branch look
// like on that date"; it is linked from the Reports menu, where people go to find it.
path: 'account-register',
component: AccountRegisterComponent,
data: { title: 'Account Register', breadcrumb: 'Account Register' }
},
{
// Same branch-level scope as the day register: this is the vault's own stock, not any
// one cashier's float, so it sits with the other branch-wide screens.
path: 'vault-position',
component: VaultPositionComponent,
data: { title: 'Vault Position', breadcrumb: 'Vault Position' }
},
{
// What the whole branch holds per denomination: the vault plus every cashier's drawer, so an
// exchange (which leaves the vault alone) is visible in the bank's total.
path: 'branch-position',
component: BranchCashPositionComponent,
data: { title: 'Branch Cash Position', breadcrumb: 'Branch Cash Position' }
},
{
// The same day book the clerk and cashier read, reached from where a principal officer
// signing the day off actually looks. It carries the Officer Verification Sheet — each
// person's own credit, debit and net — which is the branch-level reading of the day, not
// a counter one.
path: 'day-book',
component: DayBookComponent,
data: { title: 'Day Book & Verification', breadcrumb: 'Day Book' }
},
// The one sheet the day is signed off on: the transaction summary, the drawer counted
// against the books, and the notes that say why it cannot be closed yet.
{
path: 'end-of-day',
loadComponent: () =>
import('./end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent),
data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' }
}
]
},
{
path: 'cashier',
data: { title: 'Cashier', breadcrumb: 'Cashier' },
@@ -298,73 +167,26 @@ const routes: Routes = [
component: ReconciliationComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
},
{
path: 'voucher-correction',
loadComponent: () =>
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
},
{
path: 'cash-history',
component: CashHistoryComponent,
data: { title: 'Cash History', breadcrumb: 'Cash History' }
},
{
path: 'day-book',
component: DayBookComponent,
data: { title: 'Day Book', breadcrumb: 'Day Book' }
},
// The register is read one side at a time: Received is the CR side, Payment the DR side.
// Same component both times — the side it shows comes from `data.side`.
//
// `cashOnly` is what makes these the cashier's own registers rather than the whole voucher
// book: a cashier deals in notes and coins, so only vouchers that actually moved cash
// belong here, and the two reports then reconcile against the drawer and the closing
// report. A back-office screen wanting every voucher points at the same component without
// the flag.
{
path: 'received-report',
component: VoucherReportComponent,
data: {
title: 'Received Report',
breadcrumb: 'Received Report',
side: 'CR',
cashOnly: true,
cashiersOnly: true
}
},
{
path: 'payment-report',
component: VoucherReportComponent,
data: {
title: 'Payment Report',
breadcrumb: 'Payment Report',
side: 'DR',
cashOnly: true,
cashiersOnly: true
}
},
{
path: 'my-work',
loadComponent: () => import('./my-work/my-work.component').then((m) => m.MyWorkComponent),
data: { title: 'My Vouchers', breadcrumb: 'My Vouchers' }
},
{
path: 'closing-report',
component: ClosingCashReportComponent,
data: { title: 'Closing Cash Report', breadcrumb: 'Closing Cash Report' }
},
{
path: 'end-of-day',
loadComponent: () =>
import('./end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent),
data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' }
},
{
path: 'excess-report',
component: ExcessTransactionReportComponent,
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
},
{
path: 'share-collection',
component: ShareCollectionComponent,
data: { title: 'Share Collection', breadcrumb: 'Share Collection' }
},
{
path: 'transaction-entry',
canActivate: [CashierOnlyGuard],
-54
View File
@@ -206,7 +206,6 @@
border-radius: 6px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
background: linear-gradient(135deg, #ecfeff, #cffafe);
color: #155e75;
border: 1px solid #a5f3fc;
@@ -312,47 +311,6 @@
font-variant-numeric: tabular-nums;
color: #0f172a;
}
// Names the part of a total that has no row in the table below, so the figures do not read as
// failing to add up.
&__note {
margin-top: 2px;
font-size: 10px;
line-height: 1.4;
color: #64748b;
}
// The drawer strip sits under the counter totals and is the figure a physical count is checked
// against, so it is marked out as the one to read rather than a second row of the same thing.
&--balance {
margin-top: 10px;
border-color: #cbd5e1;
box-shadow: 0 1px 3px rgb(15 23 42 / 8%);
}
}
.closing-note {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.5;
color: #64748b;
}
// Says what a row without a denomination is, next to its chip.
.closing-row__hint {
margin-left: 6px;
font-size: 11px;
color: #64748b;
}
// A totals row, marked off from the rows it sums. Defined here rather than only in the day book's
// own stylesheet, which the other cashier screens do not load.
.pg-table tfoot td {
background: #f8fafc;
font-weight: 800;
font-size: 12px;
color: #334155;
border-top: 1px solid #e2e8f0;
}
.pg-step {
@@ -714,15 +672,3 @@
}
}
}
/* Names a figure strip before the reader meets it. A run of tiles ending in a total reads as
arithmetic to somebody who already knows the screen and as five unrelated numbers to somebody
who does not. */
.closing-lede {
margin: 0 0 -4px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #475569;
}
+3 -1
View File
@@ -20,6 +20,7 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
import { CashHistoryComponent } from './cash-history/cash-history.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
@NgModule({
imports: [
@@ -34,7 +35,8 @@ import { ExcessTransactionReportComponent } from './excess-transaction-report/ex
ReconciliationComponent,
CashHistoryComponent,
ClosingCashReportComponent,
ExcessTransactionReportComponent
ExcessTransactionReportComponent,
ShareCollectionComponent
]
})
export class CashierModule {}
+22 -304
View File
@@ -32,12 +32,13 @@ export class CashierService {
getCashExchange(id: number): Observable<any> {
return this.http.get(`/cashexchange/${id}`);
}
reverseCashExchange(id: number, reason: string): Observable<any> {
return this.http.post(`/cashexchange/${id}/reverse`, { reason });
}
// ── Tellers / Cashiers ──
getTellers(officeId?: number): Observable<any[]> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<any[]>('/tellers', { params });
getTellers(): Observable<any[]> {
return this.http.get<any[]>('/tellers');
}
getAllCashiers(): Observable<any[]> {
return this.http.get<any[]>('/cashiers');
@@ -47,21 +48,8 @@ export class CashierService {
}
// ── Cash Transfer ──
/** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */
createCashTransferRequest(data: any): Observable<any> {
return this.http.post('/cashtransfer/requests', data);
}
/** Requests by status (comma separated); mine narrows them to the ones the caller raised. */
getCashTransferRequests(status?: string, mine = false): Observable<any[]> {
let params = new HttpParams().set('mine', String(mine));
if (status) params = params.set('status', status);
return this.http.get<any[]>('/cashtransfer/requests', { params });
}
approveCashTransferRequest(id: number): Observable<any> {
return this.http.post(`/cashtransfer/requests/${id}/approve`, {});
}
rejectCashTransferRequest(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/requests/${id}/reject`, { reason });
createCashTransfer(data: any): Observable<any> {
return this.http.post('/cashtransfer', data);
}
getCashTransfers(offset = 0, limit = 20): Observable<any> {
const params = new HttpParams().set('offset', offset).set('limit', limit);
@@ -70,6 +58,9 @@ export class CashierService {
getCashTransferById(id: number): Observable<any> {
return this.http.get(`/cashtransfer/${id}`);
}
reverseCashTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
}
// ── Cash Settlement ──
createCashSettlement(data: any): Observable<any> {
@@ -82,6 +73,9 @@ export class CashierService {
getCashSettlementById(id: number): Observable<any> {
return this.http.get(`/cashsettlement/${id}`);
}
reverseCashSettlement(id: number, reason: string): Observable<any> {
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
}
// ── Inter-cashier Transfer ──
createInterCashierTransfer(data: any): Observable<any> {
@@ -94,10 +88,8 @@ export class CashierService {
getInterCashierById(id: number): Observable<any> {
return this.http.get(`/cashintercashier/${id}`);
}
/** One cashier's details, today's vault allocation and denomination-wise cash, for either side of a transfer. */
getInterCashierCashierSummary(cashierId: number, date: string): Observable<any> {
const params = new HttpParams().set('cashierId', cashierId).set('date', date);
return this.http.get<any>('/cashintercashier/cashier-summary', { params });
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
}
// ── Reconciliation ──
@@ -121,219 +113,6 @@ export class CashierService {
const params = new HttpParams().set('date', date);
return this.http.get('/cashreports/closing', { params });
}
/**
* Every voucher raised on one date — deposits, withdrawals, disbursements, collections and
* bank transfers in a single list, with the day's credit and debit totals.
*/
dayBook(date: string, side?: 'CR' | 'DR', status?: string): Observable<any> {
let params = new HttpParams().set('date', date);
if (side) params = params.set('side', side);
if (status) params = params.set('status', status);
return this.http.get('/cashreports/day-book', { params });
}
/**
* The vouchers raised over a period, one row each, with what was debited and what credited.
*
* Who appears is decided by the server from the caller's own roles — a cashier sees only their
* own work whatever they ask for — so `username` is a filter for those allowed to use it, not a
* way to choose whose report this is.
*/
voucherReport(
from: string,
to: string,
username?: string,
voucherType?: string,
cashOnly?: boolean,
cashiersOnly?: boolean,
systemOnly?: boolean
): Observable<any> {
let params = new HttpParams().set('from', from).set('to', to);
if (username) {
params = params.set('username', username);
}
if (voucherType) {
params = params.set('voucherType', voucherType);
}
// Only sent when asked for: the server treats a missing flag as the full register, so the
// cashier screens opt in to cash and the back-office ones keep seeing everything.
if (cashOnly) {
params = params.set('cashOnly', 'true');
}
// Opt-in the same way: the counter registers ask for the drawer-holders only, the back-office
// ones keep seeing whoever raised a voucher.
if (cashiersOnly) {
params = params.set('cashiersOnly', 'true');
}
// The postings nobody filed a request for: interest a nightly run credited, a charge taken from
// a balance, an escheat, a journal keyed to the ledger. Nowhere else shows them — My Vouchers is
// the reader's own work and a scheduled job is nobody's.
if (systemOnly) {
params = params.set('systemOnly', 'true');
}
return this.http.get('/vouchers/report', { params });
}
/**
* The signed-in user's own day: every voucher they raised and every one they verified, receipts
* and payments in one list. Always scoped to the caller by the server — there is no username
* parameter, because "my work" for somebody else is the register's job, not this screen's.
*/
myVoucherWork(from: string, to: string): Observable<any> {
const params = new HttpParams().set('from', from).set('to', to);
return this.http.get('/vouchers/my-work', { params });
}
/** One voucher and every debit/credit line it is made of — the full slip behind a summarised
* row (a "+2 more" badge in a register only ever shows the first side of each). */
voucherDetail(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/detail', { params });
}
/**
* Account register for one date: what opened and closed that day, each account's balance as it
* stood then, and what it holds now.
*/
accountRegister(date: string, section?: string): Observable<any> {
let params = new HttpParams().set('date', date);
if (section && section !== 'ALL') params = params.set('section', section);
return this.http.get('/accountregister', { params });
}
// ── Branch day register ──
/** The branch's day register, plus which day is currently open. Defaults to the caller's office. */
branchDayRegister(officeId?: number, limit?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
if (limit) params = params.set('limit', String(limit));
return this.http.get('/branchday', { params });
}
/**
* The savings products whose interest posting report is due for the date and not yet approved —
* the backend refuses to close any branch's day while one is pending.
*/
interestDayCloseCheck(
date?: string,
officeId?: number
): Observable<{ businessDate: string; canClose: boolean; pendingProducts: string[] }> {
let params = new HttpParams();
if (date) params = params.set('date', date);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<{ businessDate: string; canClose: boolean; pendingProducts: string[] }>(
'/savingsinterestpostingreports/day-close-check',
{ params }
);
}
/** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/branchday/missed', { params });
}
/**
* What the day should close at, and the arithmetic behind it, so the closing figure does not
* have to be worked out by hand.
*/
branchDayClosePreview(businessDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (businessDate) params = params.set('businessDate', businessDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/branchday/close-preview', { params });
}
/** openingDifferenceReason is required when the opening figure differs from yesterday's closing. */
openBranchDay(
businessDate: string,
openingCash?: number,
officeId?: number,
denominations?: { denominationId: number; denominationValue: number; count: number }[],
openingDifferenceReason?: string
): Observable<any> {
return this.http.post('/branchday/open', {
businessDate,
openingCash: openingCash != null ? String(openingCash) : null,
officeId: officeId ?? null,
denominations: denominations?.length ? denominations : null,
openingDifferenceReason: openingDifferenceReason ?? null,
locale: 'en'
});
}
/** forceCloseReason is required when closing a date earlier than today. */
closeBranchDay(
businessDate: string,
closingCash: number,
remarks?: string,
forceCloseReason?: string,
officeId?: number,
denominations?: { denominationId: number; denominationValue: number; count: number }[]
): Observable<any> {
return this.http.post('/branchday/close', {
businessDate,
closingCash: String(closingCash),
remarks: remarks ?? null,
forceCloseReason: forceCloseReason ?? null,
officeId: officeId ?? null,
denominations: denominations?.length ? denominations : null,
locale: 'en'
});
}
/** The opening/closing denomination breakdown recorded for one business day, if any was captured. */
branchDayDenominations(officeId?: number, businessDate?: string): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
if (businessDate) params = params.set('businessDate', businessDate);
return this.http.get('/branchday/denominations', { params });
}
/** What the office's cash-at-vault GL account currently holds — zero means the count itself will
* define its opening balance; non-zero means the count must match it exactly. */
cashGlStatus(officeId?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/branchday/cash-gl-status', { params });
}
/** One-step new-institution path: defines (or reconciles against) the cash GL's opening balance
* from a counted denomination breakdown, then opens the business day with that same count. */
migrateCashDenomination(
officeId: number,
businessDate: string,
denominations: { denominationId: number; denominationValue: number; count: number }[],
openingDifferenceReason?: string
): Observable<any> {
return this.http.post('/branchday/migrate-cash-denomination', {
officeId,
businessDate,
denominations,
openingDifferenceReason: openingDifferenceReason ?? null,
locale: 'en'
});
}
/** Denomination-wise stock a vault (teller) currently holds — "how much of what does the bank have". */
vaultPosition(tellerId?: number, asOfDate?: string): Observable<any> {
let params = new HttpParams();
if (tellerId) params = params.set('tellerId', String(tellerId));
if (asOfDate) params = params.set('asOfDate', asOfDate);
return this.http.get('/cashreports/vault-position', { params });
}
/**
* Denomination-wise cash the whole branch holds: its vault(s) plus every cashier's drawer, with the day's
* exchange movement beside it. Not for a cashier -- the server refuses it.
*/
branchCashPosition(asOfDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (asOfDate) params = params.set('asOfDate', asOfDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/cashreports/branch-position', { params });
}
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
@@ -397,77 +176,16 @@ export class CashierService {
return this.http.get<any[]>('/paymenttypes');
}
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
// The voucher travels as a query parameter, not a path segment: numbers are formatted
// CR/001/2108/0001, and Spring Security's strict firewall rejects the encoded slashes
// (%2F) outright with a 400 before the request ever reaches the resource.
// clientId lets the backend adopt a cashier deposit made straight to a GL account, which
// carries no member of its own. glAccountNo is required — which GL account to search in is
// always chosen by hand, never resolved from a share product's accounting setup, so a broken
// or missing product mapping can never make a genuine voucher unreachable.
let params = new HttpParams().set('voucherNo', voucherNo).set('glAccountNo', glAccountNo);
if (clientId) {
params = params.set('clientId', String(clientId));
}
return this.http.get('/shareadvancecollection/by-voucher', { params });
// ── Share Collection ──
getShareAccountForCollection(accountId: string): Observable<any> {
return this.http.get(`/accounts/share/${accountId}`);
}
/** GL accounts selectable when searching for a voucher, for the picker on the Apply Collection screen. */
getShareAdvanceGlAccounts(): Observable<any> {
return this.http.get('/shareadvancecollection/glaccounts');
collectSharePayment(data: any): Observable<any> {
return this.http.post('/cashier/sharecollection', data);
}
/** Share value + any outstanding entry fee for one share account, shown before a voucher search. */
getShareAdvanceRequirement(shareAccountId: number): Observable<any> {
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
}
/**
* Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
* ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
* deposits and receipts linked to another member — which attach by voucher instead.
*
* One request rather than two: both lists come from the same table under the same filters, and
* splitting them across endpoints only meant the same rows were fetched twice and, where they
* overlapped, drawn twice on the same screen.
*/
getShareCollectionReceipts(clientId?: number): Observable<any> {
const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined;
return this.http.get('/shareadvancecollection/unattributed', { params });
}
/** Moves a receipt out of Sundry into share capital for an activated account. */
applyShareAdvance(collectionId: number, data: any): Observable<any> {
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
}
/** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
* which is unrelated screen state that can name a different account than this receipt sits in. */
getShareAdvanceGlEntries(collectionId: number): Observable<any> {
return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
}
/** Every additional-share tranche awaiting approval, across every share account. */
getShareAdditionRequests(): Observable<any> {
return this.http.get('/shareadditionrequests');
}
/**
* Full detail for one pending tranche — the list row plus route-specific proof of funding: the
* voucher(s) behind a cash tranche, or the savings account already debited for a savings tranche.
*/
getShareAdditionRequest(transactionId: number): Observable<any> {
return this.http.get(`/shareadditionrequests/${transactionId}`);
}
/**
* Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
* "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
* without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
* options should still be usable for every other field.
*/
getEntryTypeOptions(): Observable<any[]> {
return this.http.get<any[]>('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
getShareCollectionStatus(shareAccountId: number): Observable<any> {
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
}
}
@@ -1,80 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
/** HTTP service for the Clerk Closure Request maker-checker workflow (Cash-only savings account
* closure). Mirrors ClerkWithdrawalService's shape against the /clerk/closure-requests endpoints. */
@Injectable({ providedIn: 'root' })
export class ClerkClosureRequestService {
private http = inject(HttpClient);
submit(data: any): Observable<any> {
return this.http.post('/clerk/closure-requests', data);
}
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
if (status) params = params.set('status', status);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/clerk/closure-requests/my', { params });
}
getPendingRequests(
status = 'PENDING',
offset = 0,
limit = 20,
fromDate?: string,
toDate?: string,
search?: string
): Observable<any> {
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (search) params = params.set('search', search);
return this.http.get('/clerk/closure-requests', { params });
}
getById(id: number): Observable<any> {
return this.http.get(`/clerk/closure-requests/${id}`);
}
/** Posts every rupee of interest the member has earned up to today straight onto the account —
* the same posting close() itself performs — and returns the resulting real balance. Not a dry
* run: ticking "Close this account" is meant to credit the interest immediately, not estimate it. */
postClosureInterest(savingsAccountId: number): Observable<any> {
return this.http.post(`/clerk/closure-requests/post-interest/${savingsAccountId}`, {});
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/closure-requests/${id}/approve`, {});
}
reject(id: number, reason: string): Observable<any> {
return this.http.post(`/clerk/closure-requests/${id}/reject`, { reason });
}
// Silenced: onTokNoBlur() probes this expecting a possible legitimate 404 (token not from this
// pool, could be a withdrawal/dividend claim's instead) and shows its own tailored message either way.
getByToken(token: string): Observable<any> {
return this.http.get(`/clerk/closure-requests/by-token/${encodeURIComponent(token)}`, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable<any> {
return this.http.post(`/clerk/closure-requests/${id}/disburse`, {
denominationData,
transactionAmount: transactionAmount != null ? String(transactionAmount) : null
});
}
}
@@ -28,18 +28,10 @@ export class ClerkShareWithdrawalService {
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
}
getPendingRequests(
status = 'PENDING',
offset = 0,
limit = 20,
fromDate?: string,
toDate?: string,
search?: string
): Observable<any> {
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (search) params = params.set('search', search);
return this.http.get('/clerk/share-withdrawal-requests', { params });
}
@@ -51,12 +43,6 @@ export class ClerkShareWithdrawalService {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
}
/** Runs the exact same checklist approve() will run, against a draft that hasn't been submitted
* yet — so the clerk sees it in the confirm dialog, before committing to the request. */
previewValidate(data: any): Observable<any> {
return this.http.post('/clerk/share-withdrawal-requests/preview-validate', data);
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
}
+1 -9
View File
@@ -32,18 +32,10 @@ export class ClerkWithdrawalService {
return this.http.get('/clerk/withdrawal-requests/my', { params });
}
getPendingRequests(
status = 'PENDING',
offset = 0,
limit = 20,
fromDate?: string,
toDate?: string,
search?: string
): Observable<any> {
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (search) params = params.set('search', search);
return this.http.get('/clerk/withdrawal-requests', { params });
}
@@ -9,8 +9,9 @@
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · REPORT</div>
<h1 class="pg-title">Closing Cash Report</h1>
<p class="pg-sub">How much cash you started with, what came in and went out, and how much you must hand back.</p>
<p class="pg-sub">Denomination-wise deposit, withdrawal and net cash position for the selected day.</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
@@ -26,92 +27,44 @@
</div>
</header>
<!-- The day's own cash, leg by leg, ending in what should physically be in the drawer.
A cashier carries no balance: they draw from the vault in the morning and hand back at close,
so this is the figure a physical count is checked against.
This used to sit under a second strip reading Deposits − Withdrawals = Net Counter Movement.
That strip stated the same two figures again and then a net the cashier has no use for: it
excludes the vault legs, so on a day the vault gave ₹600 and customer business cancelled out
it read ₹0 while the drawer held ₹600. Two panels, four repeated figures, and the one that
came first was the one to ignore. -->
@if (!showGl && scopedToCashier && hasTodayLegs) {
<!-- Says what the strip is before the reader meets five figures and a running sum. Somebody
opening this screen for the first time has no way to know that the last tile is the one
that matters. -->
<p class="closing-lede">Your cash for the day, step by step</p>
<div class="closing-summary closing-summary--balance">
@if (!showGl && rows.length > 0) {
<!-- Summary totals bar -->
<div class="closing-summary">
<div class="closing-summary__item closing-summary__item--in">
<span class="closing-summary__label">Cash you got this morning</span>
<span class="closing-summary__value">₹{{ todayVaultIn | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">+</div>
<div class="closing-summary__item closing-summary__item--in">
<span class="closing-summary__label">Cash customers gave you</span>
<span class="closing-summary__value">₹{{ depositTotalWithDd | number: '1.2-2' }}</span>
@if (ddCashTotal > 0) {
<!-- Named on the tile as well as in the table: this part of the total carries no
denomination, so without saying so the figures read as failing to add up. -->
<span class="closing-summary__note">
₹{{ ddCashTotal | number: '1.2-2' }} of this was for {{ ddCashCount }} draft{{
ddCashCount === 1 ? '' : 's'
}}, notes not listed
</span>
}
<span class="closing-summary__label">Total Deposits (IN)</span>
<span class="closing-summary__value">₹{{ depositTotal | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">−</div>
<div class="closing-summary__item closing-summary__item--out">
<span class="closing-summary__label">Cash you gave customers</span>
<span class="closing-summary__label">Total Withdrawals (OUT)</span>
<span class="closing-summary__value">₹{{ withdrawalTotal | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">−</div>
<div class="closing-summary__item closing-summary__item--out">
<span class="closing-summary__label">Cash already returned</span>
<span class="closing-summary__value">₹{{ todaySettledOut | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">=</div>
<!-- The figure the day ends on. Named for what the cashier does with it — hand this back to
the vault — rather than "closing balance", which reads as something they keep. -->
<div
class="closing-summary__item"
[class.closing-summary__item--net-pos]="expectedInDrawer >= 0"
[class.closing-summary__item--net-neg]="expectedInDrawer < 0"
[class.closing-summary__item--net-pos]="netTotal >= 0"
[class.closing-summary__item--net-neg]="netTotal < 0"
>
<span class="closing-summary__label">Cash left with you now</span>
<span class="closing-summary__value">₹{{ expectedInDrawer | number: '1.2-2' }}</span>
<span class="closing-summary__label">Net Closing Cash</span>
<span class="closing-summary__value">₹{{ netTotal | number: '1.2-2' }}</span>
</div>
</div>
<p class="closing-note">
Count your cash now &mdash; it should come to ₹{{ expectedInDrawer | number: '1.2-2' }}. Hand that amount back and
your day is done.
</p>
<!-- Cashier-to-cashier hand-overs are left out of the row above to keep it to the four legs a
teller sees, and stated here when they happened rather than silently folded in. -->
@if ((todayInterCashierIn ?? 0) > 0 || (todayInterCashierOut ?? 0) > 0) {
<p class="closing-note">
This already counts cash swapped with another cashier &mdash; ₹{{ todayInterCashierIn | number: '1.2-2' }} taken
from them, ₹{{ todayInterCashierOut | number: '1.2-2' }} given to them.
</p>
}
}
@if (!showGl) {
<div class="pg-card">
@if (noteRows.length > 0 || ddCashTotal > 0) {
@if (noteRows.length > 0) {
<div class="pos-section-label">Notes</div>
<table class="pg-table">
<thead>
<tr>
<th>Note</th>
<!-- Counted in pieces, because that is what a teller does with notes. Nine columns of
pieces-and-amount for every leg was a reconciliation nobody could read at a
counter; these five are the sentence the day actually makes: this many came in
from the vault, this many over the counter, this many went out, this many are
left, and that is worth this much. -->
<th class="num col-in">Came in</th>
<th class="num col-out">Went out</th>
<th class="num col-net">Left with you</th>
<th class="num col-net">Worth</th>
<th>Denomination</th>
<th class="num col-in">Deposit Pcs</th>
<th class="num col-in">Deposit Amt</th>
<th class="num col-out">Withdrawal Pcs</th>
<th class="num col-out">Withdrawal Amt</th>
<th class="num col-net">Net Pcs</th>
<th class="num col-net">Net Amount</th>
</tr>
</thead>
<tbody>
@@ -120,16 +73,15 @@
<td>
<span class="pg-chip">₹{{ r.denominationValue }}</span>
</td>
<!-- Vault and counter added together: to a teller both are notes arriving, and
splitting them made the reader do the sum. A dash rather than a zero where
nothing happened, so the eye passes over it. -->
<td class="num col-in">{{ r.vaultInCount + r.depositCount || '—' }}</td>
<td class="num col-out">{{ r.withdrawalCount + r.settledOutCount || '—' }}</td>
<td class="num col-in">{{ r.depositCount }}</td>
<td class="num col-in">₹{{ r.depositAmount | number: '1.2-2' }}</td>
<td class="num col-out">{{ r.withdrawalCount }}</td>
<td class="num col-out">₹{{ r.withdrawalAmount | number: '1.2-2' }}</td>
<td
class="num col-net"
[style.color]="r.netCount > 0 ? '#16a34a' : r.netCount < 0 ? '#dc2626' : '#94a3b8'"
>
{{ r.netCount }}
{{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }}
</td>
<td
class="num col-net"
@@ -141,38 +93,7 @@
</td>
</tr>
}
<!-- Cash taken for a Demand Draft, on the deposit side where it belongs. It has no
denomination to sit against -- the notes were never itemised -- so the piece
counts show a dash rather than a zero, which would read as "no notes". -->
@if (ddCashTotal > 0) {
<tr>
<td>
<span class="pg-chip">Notes not listed</span>
<span class="closing-row__hint"> {{ ddCashCount }} cash DD{{ ddCashCount === 1 ? '' : 's' }} </span>
</td>
<td class="num col-in">—</td>
<td class="num col-out">—</td>
<td class="num col-net">—</td>
<td class="num col-net" style="color: #16a34a">₹{{ ddCashTotal | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<!-- Totals, so the table reconciles against the summary above rather than having to be
added up by hand — and so the deposit column visibly accounts for the DD row. -->
<tfoot>
<tr>
<td>Total</td>
<td class="num col-in">{{ noteVaultInCount + noteDepositCount || '—' }}</td>
<td class="num col-out">{{ noteWithdrawalCount + noteSettledOutCount || '—' }}</td>
<td class="num col-net">{{ noteNetCount }}</td>
<td class="num col-net">
{{ noteNetAmount >= 0 ? '' : '−' }}₹{{
(noteNetAmount < 0 ? -noteNetAmount : noteNetAmount) | number: '1.2-2'
}}
</td>
</tr>
</tfoot>
</table>
}
@@ -181,11 +102,13 @@
<table class="pg-table">
<thead>
<tr>
<th>Coin</th>
<th class="num col-in">Came in</th>
<th class="num col-out">Went out</th>
<th class="num col-net">Left with you</th>
<th class="num col-net">Worth</th>
<th>Denomination</th>
<th class="num col-in">Deposit Pcs</th>
<th class="num col-in">Deposit Amt</th>
<th class="num col-out">Withdrawal Pcs</th>
<th class="num col-out">Withdrawal Amt</th>
<th class="num col-net">Net Pcs</th>
<th class="num col-net">Net Amount</th>
</tr>
</thead>
<tbody>
@@ -194,16 +117,15 @@
<td>
<span class="pg-chip pg-chip--coin">₹{{ r.denominationValue }}</span>
</td>
<!-- Vault and counter added together: to a teller both are notes arriving, and
splitting them made the reader do the sum. A dash rather than a zero where
nothing happened, so the eye passes over it. -->
<td class="num col-in">{{ r.vaultInCount + r.depositCount || '—' }}</td>
<td class="num col-out">{{ r.withdrawalCount + r.settledOutCount || '—' }}</td>
<td class="num col-in">{{ r.depositCount }}</td>
<td class="num col-in">₹{{ r.depositAmount | number: '1.2-2' }}</td>
<td class="num col-out">{{ r.withdrawalCount }}</td>
<td class="num col-out">₹{{ r.withdrawalAmount | number: '1.2-2' }}</td>
<td
class="num col-net"
[style.color]="r.netCount > 0 ? '#16a34a' : r.netCount < 0 ? '#dc2626' : '#94a3b8'"
>
{{ r.netCount }}
{{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }}
</td>
<td
class="num col-net"
@@ -219,20 +141,8 @@
</table>
}
<!-- Not shown when a Demand Draft row is on the table: there is something to read then, and
an "empty" notice under it contradicts it. -->
@if (loaded && rows.length === 0 && exchangeRows.length === 0 && ddCashTotal === 0) {
<!-- A day with no counter work is not an empty report: the cashier is still holding
whatever they held yesterday, and that balance is shown in the strip above. Saying
only "no data" here read as though the drawer were empty. -->
<div class="pg-empty">
@if (scopedToCashier && hasTodayLegs && expectedInDrawer !== 0) {
No cash crossed the counter on this date. The drawer should hold ₹{{ expectedInDrawer | number: '1.2-2' }}
from the day's vault and settlement legs.
} @else {
No cash denomination data for this date.
}
</div>
@if (loaded && rows.length === 0 && exchangeRows.length === 0) {
<div class="pg-empty">No cash denomination data for this date.</div>
}
</div>
}
@@ -303,8 +213,8 @@
<tr>
<th class="gl-th--voucher">Voucher No</th>
<th class="gl-th--date">Date</th>
<th>Debit</th>
<th>Credit</th>
<th>A/C No</th>
<th>A/C Name</th>
<th class="gl-th--pt">Payment Type</th>
<th class="gl-th--teller">Teller</th>
<th class="num gl-th--dr">DR (₹)</th>
@@ -317,8 +227,9 @@
<td class="gl-voucher">
@if (r.txnSource === 'GL') {
<span class="gl-badge">GL</span>
} @else {
{{ r.voucherNo }}
}
{{ r.voucherNo }}
</td>
<td class="gl-date">
<div>{{ r.txnDate }}</div>
@@ -326,25 +237,8 @@
<div class="gl-time">{{ r.txnTime }}</div>
}
</td>
<!-- Both legs of the voucher, the same pair My Vouchers shows — not just the one
side this row's own denomination breakdown happens to know about. Falls back
to that one side (acNo/acName) when the voucher predates m_voucher_line. -->
<td class="gl-acname">
<div>{{ r.debitParty || r.acName }}</div>
@if (r.debitAccount || r.acNo) {
<div class="gl-acno">{{ r.debitAccount || r.acNo }}</div>
}
</td>
<td class="gl-acname">
@if (r.creditParty) {
<div>{{ r.creditParty }}</div>
@if (r.creditAccount) {
<div class="gl-acno">{{ r.creditAccount }}</div>
}
} @else {
<span class="gl-pt-none">—</span>
}
</td>
<td class="gl-acno">{{ r.acNo }}</td>
<td class="gl-acname">{{ r.acName }}</td>
<td class="gl-pt">
@if (r.paymentType) {
<span class="gl-pt-badge">{{ r.paymentType }}</span>
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/** Day-wise denomination-wise closing cash collected from deposits. */
@Component({
@@ -39,51 +38,6 @@ export class ClosingCashReportComponent implements OnInit {
loading = false;
loaded = false;
/**
* Cash taken at the counter for a Demand Draft.
*
* A cash DD asks for a denomination breakdown but does not require one, and this report is built
* entirely from breakdowns — so that cash was missing from the drawer figure even though the
* ledger held it and the cashier held the notes. It is counted from the request itself and shown
* on its own line, because the denomination rows below cannot account for it.
*/
ddCashTotal = 0;
ddCashCount = 0;
/** Deposits including the DD cash — the figure the drawer is actually checked against. */
depositTotalWithDd = 0;
/**
* The drawer, opening to closing.
*
* `netTotal` above is only the day's counter movement — deposits less withdrawals. It is not what
* the cashier is holding, because it carries nothing forward from yesterday and ignores vault
* allocations and inter-cashier legs. The closing balance is the figure that has to match a
* physical count at the end of the day, so it is reported separately rather than inferred from
* the counter totals.
*
* Null when the server did not scope the report to one cashier: a branch-wide opening balance is
* the branch day register's business, not this report's.
*/
openingBalance: number | null = null;
closingBalance: number | null = null;
/**
* The day's own cash legs, which is what a cashier reconciles against the drawer.
*
* A cashier carries no balance: they draw from the vault in the morning and hand back at close.
* The cumulative opening/closing figures answer a question nobody at the counter asks, and one
* carrying years of history answers it with a number that cannot be counted against anything on
* the desk.
*/
todayVaultIn: number | null = null;
todaySettledOut: number | null = null;
todayInterCashierIn: number | null = null;
todayInterCashierOut: number | null = null;
/** Every leg of the day's cash movement, which is wider than deposits less withdrawals. */
cashMovement: number | null = null;
/** Whether the figures above are one cashier's drawer or the whole branch's counter work. */
scopedToCashier = false;
// GL View
showGl = false;
glRows: any[] = [];
@@ -109,91 +63,9 @@ export class ClosingCashReportComponent implements OnInit {
selectedTellerId: number | null = null;
selectedCashierId: number | null = null;
/**
* What should be in the drawer at the end of the day, from the day's own legs.
*
* Drawn from the vault, plus anything taken over the counter and from another cashier, less what
* went back out. This is the figure a cashier counts against — it does not carry a balance
* forward, because a cashier does not.
*/
get expectedInDrawer(): number {
return (
Number(this.todayVaultIn ?? 0) +
Number(this.todayInterCashierIn ?? 0) +
Number(this.depositTotalWithDd ?? 0) -
Number(this.withdrawalTotal ?? 0) -
Number(this.todaySettledOut ?? 0) -
Number(this.todayInterCashierOut ?? 0)
);
}
/** True when any of the day's own legs carries a figure, so the panel is worth showing. */
get hasTodayLegs(): boolean {
return this.todayVaultIn !== null;
}
get noteRows(): any[] {
return this.rows.filter((r) => r.kind !== 'coin');
}
/**
* Totals for the notes table, including the un-itemised Demand Draft cash.
*
* The DD row carries an amount but no piece count, so pieces and amounts are summed separately
* rather than one being derived from the other — a denomination times a count would not reach the
* DD's figure, and adding a fictional count to make it balance would misstate the drawer.
*/
get noteDepositCount(): number {
return this.noteRows.reduce((s, r) => s + Number(r.depositCount ?? 0), 0);
}
get noteDepositAmount(): number {
return this.noteRows.reduce((s, r) => s + Number(r.depositAmount ?? 0), 0) + this.ddCashTotal;
}
get noteWithdrawalCount(): number {
return this.noteRows.reduce((s, r) => s + Number(r.withdrawalCount ?? 0), 0);
}
get noteWithdrawalAmount(): number {
return this.noteRows.reduce((s, r) => s + Number(r.withdrawalAmount ?? 0), 0);
}
get noteVaultInCount(): number {
return this.noteRows.reduce((s, r) => s + Number(r.vaultInCount ?? 0), 0);
}
get noteVaultInAmount(): number {
return this.noteRows.reduce((s, r) => s + Number(r.vaultInAmount ?? 0), 0);
}
get noteSettledOutCount(): number {
return this.noteRows.reduce((s, r) => s + Number(r.settledOutCount ?? 0), 0);
}
get noteSettledOutAmount(): number {
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
}
/**
* Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount`
* already carries forward what a cashier was holding from before today (`openingCount`), for the
* force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/
* withdrawal/settledOut dropped that carry-forward and put the Total row back into negative
* numbers a cashier had already reconciled.
*/
get noteNetCount(): number {
return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0);
}
/**
* What is left in the drawer in notes: everything that came in, less everything that went out —
* including cash carried forward from before today. See `noteNetCount` for why this sums each
* row's own `netAmount` rather than re-deriving it from today's legs.
*/
get noteNetAmount(): number {
return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0);
}
get coinRows(): any[] {
return this.rows.filter((r) => r.kind === 'coin');
}
@@ -205,13 +77,14 @@ export class ClosingCashReportComponent implements OnInit {
}
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
}
load(): void {
this.showGl = false;
this.loading = true;
this.service.closingCashReport(this.date).subscribe({
next: (res: any) => {
@@ -220,19 +93,6 @@ export class ClosingCashReportComponent implements OnInit {
this.depositTotal = Number(res?.depositTotal || 0);
this.withdrawalTotal = Number(res?.withdrawalTotal || 0);
this.netTotal = Number(res?.netTotal || 0);
this.ddCashTotal = Number(res?.ddCashTotal || 0);
this.ddCashCount = Number(res?.ddCashCount || 0);
this.depositTotalWithDd = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0);
this.scopedToCashier = res?.scopedToCashier === true;
// Sent only for a cashier-scoped call, and read as absent rather than zero when missing —
// a zero opening balance and no opening balance at all are different statements.
this.openingBalance = res?.openingBalance == null ? null : Number(res.openingBalance);
this.closingBalance = res?.closingBalance == null ? null : Number(res.closingBalance);
this.cashMovement = res?.cashMovement == null ? null : Number(res.cashMovement);
this.todayVaultIn = res?.todayVaultIn == null ? null : Number(res.todayVaultIn);
this.todaySettledOut = res?.todaySettledOut == null ? null : Number(res.todaySettledOut);
this.todayInterCashierIn = res?.todayInterCashierIn == null ? null : Number(res.todayInterCashierIn);
this.todayInterCashierOut = res?.todayInterCashierOut == null ? null : Number(res.todayInterCashierOut);
this.loading = false;
this.loaded = true;
},
@@ -242,34 +102,10 @@ export class ClosingCashReportComponent implements OnInit {
this.depositTotal = 0;
this.withdrawalTotal = 0;
this.netTotal = 0;
this.ddCashTotal = 0;
this.ddCashCount = 0;
this.depositTotalWithDd = 0;
this.scopedToCashier = false;
this.openingBalance = null;
this.closingBalance = null;
this.cashMovement = null;
this.todayVaultIn = null;
this.todaySettledOut = null;
this.todayInterCashierIn = null;
this.todayInterCashierOut = null;
this.loading = false;
this.loaded = true;
}
});
// GL Entries is scoped to `this.date` too, but was never re-fetched here -- changing the date and
// clicking Load left glLoaded=true from whatever date was open before, so a stale, wrong-date GL
// table stayed on screen under a header that had already relabelled itself to the new date. If
// the panel was open, refresh it for the date just loaded; otherwise just invalidate it so the
// next time it's opened it fetches fresh instead of showing leftover rows.
this.glOffset = 0;
this.glLoaded = false;
if (this.showGl) {
this.loadGl();
} else {
this.glRows = [];
}
}
toggleGl(): void {
@@ -1,283 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<h1 class="pg-title">{{ title }}</h1>
<p class="pg-sub">
Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections, interest and
journals, with each person's own credit, debit and net.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
<mat-icon>chevron_left</mat-icon>
</button>
<label class="pg-field">
<span>Date</span>
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
</label>
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
<mat-icon>chevron_right</mat-icon>
</button>
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
<!-- Filters -->
<div class="db-filters">
<label class="pg-field">
<span>Side</span>
<select [(ngModel)]="side" (change)="load()">
<option value="">All</option>
<option value="CR">CR · Credit (money in)</option>
<option value="DR">DR · Debit (money out)</option>
</select>
</label>
<label class="pg-field">
<span>Status</span>
<select [(ngModel)]="status" (change)="load()">
<option value="">All</option>
<option value="APPROVED">Approved</option>
<option value="PENDING">Pending</option>
<option value="REJECTED">Rejected</option>
</select>
</label>
<label class="pg-field db-filters__search">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Voucher no, account, party, payment type, narration…" />
</label>
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
<mat-icon>download</mat-icon> CSV
</button>
</div>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && rows.length === 0) {
<div class="pg-empty">
<mat-icon>receipt_long</mat-icon>
<p>No vouchers were raised on {{ date }}.</p>
</div>
}
@if (!loading && filteredRows.length > 0) {
<!-- Totals bar. Reflects what is on screen, so a search or side filter still reconciles. -->
<div class="closing-summary">
<div class="closing-summary__item closing-summary__item--in">
<span class="closing-summary__label">Credit · CR (money in)</span>
<span class="closing-summary__value">₹{{ shownCredit | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">−</div>
<div class="closing-summary__item closing-summary__item--out">
<span class="closing-summary__label">Debit · DR (money out)</span>
<span class="closing-summary__value">₹{{ shownDebit | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">=</div>
<div
class="closing-summary__item"
[class.closing-summary__item--net-pos]="shownCredit - shownDebit >= 0"
[class.closing-summary__item--net-neg]="shownCredit - shownDebit < 0"
>
<span class="closing-summary__label">Net</span>
<span class="closing-summary__value">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</span>
</div>
</div>
<p class="db-note">
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
</p>
<!-- The day per person, for the officer who signs the book off. The sections below trace one
voucher at a time; this is the other reading a branch needs — what each member of staff
handled, and whether their column adds up. Built from whoever appears in the day's rows, so
one person working alone gets one line and five people get five. -->
@if (byUser.length > 0) {
<div class="pg-card db-section db-byuser">
<div class="db-section__head">
<strong>Officer Verification Sheet</strong>
<span class="db-muted">Each person's own credit, debit and net for the day</span>
<span class="db-section__count"> {{ byUser.length }} {{ byUser.length === 1 ? 'person' : 'people' }} </span>
</div>
<div class="pg-table-wrap">
<table class="pg-table db-table">
<thead>
<tr>
<th>Handled by</th>
<th class="num">Items</th>
<th class="num">Credit (CR)</th>
<th class="num">Debit (DR)</th>
<th class="num">Net</th>
<th class="num">Pending</th>
</tr>
</thead>
<tbody>
@for (u of byUser; track u.name) {
<tr>
<td class="db-byuser__name">{{ u.name }}</td>
<td class="num">{{ u.items }}</td>
<td class="num db-byuser__cr">₹{{ u.credit | number: '1.2-2' }}</td>
<td class="num db-byuser__dr">₹{{ u.debit | number: '1.2-2' }}</td>
<td
class="num db-byuser__net"
[class.db-byuser__net--pos]="u.net >= 0"
[class.db-byuser__net--neg]="u.net < 0"
>
₹{{ u.net | number: '1.2-2' }}
</td>
<td class="num">
@if (u.pending > 0) {
<span class="db-badge db-badge--pending">{{ u.pending }}</span>
} @else {
—
}
</td>
</tr>
}
</tbody>
<!-- The sheet's own total, which must equal the day's totals above. That equality is
what makes the sheet verifiable rather than just informative. -->
<tfoot>
<tr class="db-byuser__total">
<td>All staff</td>
<td class="num">{{ byUserTotals.items }}</td>
<td class="num db-byuser__cr">₹{{ byUserTotals.credit | number: '1.2-2' }}</td>
<td class="num db-byuser__dr">₹{{ byUserTotals.debit | number: '1.2-2' }}</td>
<td
class="num db-byuser__net"
[class.db-byuser__net--pos]="byUserTotals.net >= 0"
[class.db-byuser__net--neg]="byUserTotals.net < 0"
>
₹{{ byUserTotals.net | number: '1.2-2' }}
</td>
<td class="num">
@if (byUserTotals.pending > 0) {
{{ byUserTotals.pending }}
} @else {
—
}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
}
<!-- One table per source register rather than one long list. The two read differently: the
cashier's rows are cash that actually crossed the counter, the clerk's are requests nobody
has actioned yet. Mixed together they invited the wrong sum. -->
@for (s of sections; track s.key) {
<!-- The section's own colour on the left edge: cash that actually crossed the counter, work
still waiting to be actioned, and postings the system made are three different things to
a person closing the day. -->
<div [class]="'pg-card db-section db-section--' + s.key">
<div class="db-section__head">
<strong>{{ s.label }}</strong>
<span class="db-muted">{{ s.hint }}</span>
<span class="db-section__count">{{ s.rows.length }} row{{ s.rows.length === 1 ? '' : 's' }}</span>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Dr/Cr</th>
<th>Type</th>
<th>Account</th>
<th>Party</th>
<th class="num">Amount</th>
<th>Payment</th>
<th>Status</th>
<th>Correction</th>
<th>By</th>
<th>Approved By</th>
<th>Time</th>
</tr>
</thead>
<tbody>
@for (r of s.rows; track r.voucherNo ?? r.section + r.txnId) {
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
<td>
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
</td>
<td>
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
{{ r.side }}
</span>
</td>
<td>{{ r.kind }}</td>
<td>
{{ r.accountNo ?? '—' }}
@if (r.accountType) {
<span class="db-muted">· {{ r.accountType }}</span>
}
</td>
<td>{{ r.party ?? '—' }}</td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
<td>{{ r.paymentTypeName ?? '—' }}</td>
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<mifosx-voucher-status
[status]="r.voucherStatus"
[revokedBy]="r.revokedByVoucherNo"
[replaces]="r.replacesVoucherNo"
[note]="r.correctionNote"
></mifosx-voucher-status>
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
<span class="db-muted">—</span>
}
</td>
<td>{{ r.handledBy ?? '—' }}</td>
<td>
{{ r.approvedBy ?? '—' }}
@if (r.approvedBy && r.actionedAt) {
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
}
</td>
<td>{{ timeOf(r.submittedAt) }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="5">{{ s.label }} subtotal</td>
<td class="num">
@if (s.credit > 0 && s.debit > 0) {
₹{{ s.credit | number: '1.2-2' }} CR · ₹{{ s.debit | number: '1.2-2' }} DR
} @else if (s.credit > 0) {
₹{{ s.credit | number: '1.2-2' }} CR
} @else if (s.debit > 0) {
₹{{ s.debit | number: '1.2-2' }} DR
} @else {
—
}
</td>
<td colspan="6"></td>
</tr>
</tfoot>
</table>
</div>
}
}
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
<div class="pg-empty">No voucher matches “{{ search }}”.</div>
}
</div>
@@ -1,441 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* Layout and page chrome come from cashier-shared.scss; only what is specific to the day book
lives here. */
.db-filters {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
margin-bottom: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
/* The search box takes the slack so the two selects keep their natural width. */
&__search {
flex: 1 1 260px;
}
}
.db-note {
margin: 8px 0 12px;
font-size: 11px;
color: #94a3b8;
}
/* One card per source register. The heading says which register, and why its rows read differently
from the others, so the three are not added up as though they were one list. */
.db-section {
margin-bottom: 14px;
border-left: 3px solid #cbd5e1;
/* Cash that actually crossed the counter — the section a drawer is checked against, so it
carries the darker green. */
&--CASHIER {
border-left-color: #15803d;
.db-section__head {
background: #f0fdf4;
}
}
/* Raised but not actioned: no cash has moved yet, so the lighter shade of the same green. */
&--CLERK {
border-left-color: #86efac;
.db-section__head {
background: #f7fef9;
}
}
/* Postings with no counter behind them — interest, charges, journals. Not the branch's cash at
all, so a different colour rather than a third green that would read as a third cash state. */
&--SYSTEM {
border-left-color: #6366f1;
.db-section__head {
background: #f5f5ff;
}
}
&__head {
display: flex;
align-items: baseline;
gap: 10px;
padding: 10px 14px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 12px;
strong {
font-size: 13px;
color: #0d1b2a;
}
}
&__count {
margin-left: auto;
font-size: 11px;
font-weight: 700;
color: #64748b;
}
}
/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
.db-side {
display: inline-block;
min-width: 28px;
text-align: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
background: #f1f5f9;
color: #475569;
&--cr {
background: #dcfce7;
color: #15803d;
}
&--dr {
background: #fee2e2;
color: #b91c1c;
}
}
/* A hairline on the leading edge, tinted by side: readable without colouring the whole row,
which would fight the table's own hover state. */
.db-row--cr td:first-child {
box-shadow: inset 3px 0 0 #22c55e;
}
.db-row--dr td:first-child {
box-shadow: inset 3px 0 0 #ef4444;
}
.db-muted {
color: #94a3b8;
font-size: 11px;
}
.db-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f1f5f9;
color: #475569;
/* Passed, but nothing handed over yet — the lighter green. */
&--ok {
background: #dcfce7;
color: #15803d;
}
/* Cash has actually left the counter. Filled and dark, so the final state is the one the eye
lands on: on this page the difference between "approved" and "paid out" is the whole question. */
&--done {
background: #15803d;
color: #fff;
}
&--pending {
background: #fef3c7;
color: #b45309;
}
&--rejected {
background: #fee2e2;
color: #b91c1c;
}
}
/* Not defined in cashier-shared.scss, so the day book carries its own. */
.pg-alert {
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 6px;
font-size: 13px;
&--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
}
}
/* Exception summary, above the table: the day's flagged vouchers listed in full so nobody has to
hunt for a badge. */
.db-exceptions {
margin-bottom: 14px;
padding: 12px 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 8px;
&__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
color: #b91c1c;
font-size: 13px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
/* Pushed to the right so the count reads first. */
.pg-btn {
margin-left: auto;
height: 30px;
font-size: 12px;
}
}
&__item {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 6px 0;
font-size: 12px;
border-top: 1px dotted #fecaca;
}
&__amt {
font-variant-numeric: tabular-nums;
color: #334155;
}
&__diff {
color: #b91c1c;
}
&__why {
color: #0d1b2a;
font-weight: 600;
}
}
/* Raising an exception is a deliberate, consequential act — styled to read that way without
looking destructive, since nothing is deleted. */
.pg-btn--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
&:hover {
background: #fef3c7;
}
}
.db-icon-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
color: #475569;
cursor: pointer;
&:hover {
background: #f1f5f9;
color: #0d1b2a;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
/* Attachment count rides on the paperclip so a voucher with a scanned slip reads as different
at a glance, without a separate column. */
&__count {
position: absolute;
top: -5px;
right: -5px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 999px;
background: #2563eb;
color: #fff;
font-size: 9px;
font-weight: 800;
line-height: 14px;
}
}
/* Inline panel, opened under the row it belongs to. */
.db-panel-row td {
background: #f8fafc !important;
padding: 0 !important;
}
.db-panel {
padding: 14px 16px;
border-left: 3px solid #2563eb;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-size: 13px;
color: #0d1b2a;
}
&__body {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
&__remarks {
display: block;
margin-top: 12px;
max-width: 640px;
}
&__warn {
margin: 10px 0 0;
padding: 8px 12px;
border-radius: 6px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 12px;
}
&__actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
}
/* The two amounts being compared, side by side. */
.db-cmp {
padding: 8px 14px;
border-radius: 6px;
background: #fff;
border: 1px solid #e2e8f0;
&__label {
display: block;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
&__value {
display: block;
margin-top: 2px;
font-size: 15px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0d1b2a;
}
&--good {
background: #f0fdf4;
border-color: #bbf7d0;
.db-cmp__value {
color: #15803d;
}
}
&--bad {
background: #fef2f2;
border-color: #fecaca;
.db-cmp__value {
color: #b91c1c;
}
}
}
.pg-table tfoot td {
background: #f8fafc;
font-weight: 800;
font-size: 12px;
color: #334155;
border-top: 1px solid #e2e8f0;
}
/* Officer Verification Sheet — the day totalled per person, for whoever signs the book off.
Numeric cells use the shared `.num` from cashier-shared.scss rather than a class of their own:
that one already right-aligns and tabular-figures both the heading and the cell, which is what
lines a money column up. A local class only styled the cells, leaving every heading left-aligned
over right-aligned figures. */
.db-byuser {
/* Not one of the three sections below, so no section colour: this is the whole day summed by
person, and giving it one of their edges would read as a fourth section. */
border-left-color: #94a3b8;
&__name {
font-weight: 600;
color: #0f172a;
}
/* Received green, paid-out red — the convention a passbook already uses, so the two columns are
told apart without reading the headings. */
&__cr {
color: #15803d;
}
&__dr {
color: #b91c1c;
}
&__net {
font-weight: 600;
&--pos {
color: #15803d;
}
&--neg {
color: #b91c1c;
}
}
/* The sheet's own total sits against a tinted band so the eye lands on the line that has to
agree with the day's figures above. */
&__total {
background: #f8fafc;
font-weight: 600;
td {
border-top: 2px solid #cbd5e1;
}
}
}
@@ -1,386 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
*
* Only counter business appears in the day book. Cash moved between the vault and the drawers is
* the bank shifting its own money, not a customer transaction, and it is read on the Cashier
* Position and Closing Cash Report where the drawer itself is the subject.
*/
type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
interface DayBookRow {
voucherNo: string | null;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
section: DayBookSection;
side: 'CR' | 'DR';
kind: string;
accountNo: string | null;
accountType: string | null;
party: string | null;
amount: number | null;
paymentTypeName: string | null;
description: string | null;
status: string;
handledBy: string | null;
approvedBy: string | null;
txnId: string | null;
submittedAt: string | null;
actionedAt: string | null;
}
/**
* Day book — every voucher raised on one date in a single list: cash deposits, withdrawals,
* disbursements, collections and bank transfers, with the day's credit and debit totals.
*/
@Component({
selector: 'mifosx-day-book',
templateUrl: './day-book.component.html',
styleUrls: [
'../cashier-shared.scss',
'./day-book.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
VoucherStatusComponent
]
})
export class DayBookComponent implements OnInit {
private service = inject(CashierService);
private router = inject(Router);
/**
* The heading, taken from the route the screen was opened by.
*
* One screen serves three menus, and only the Branch Ops reading is a different thing: there it
* is the branch's book with the verification sheet an officer signs off, rather than one desk's
* view of the day.
*/
get title(): string {
return this.router.url.includes('/branch-ops/') ? 'Day Book & Officer Verification' : 'Day Book';
}
date = '';
side: '' | 'CR' | 'DR' = '';
status = '';
search = '';
rows: DayBookRow[] = [];
creditTotal = 0;
debitTotal = 0;
netTotal = 0;
loading = false;
loaded = false;
errorMsg = '';
ngOnInit(): void {
this.date = this.today();
this.load();
}
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
load(): void {
if (!this.date) return;
this.loading = true;
this.errorMsg = '';
this.service
.dayBook(this.date, this.side || undefined, this.status || undefined)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day book.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.rows = res?.rows ?? [];
this.creditTotal = Number(res?.creditTotal ?? 0);
this.debitTotal = Number(res?.debitTotal ?? 0);
this.netTotal = Number(res?.netTotal ?? 0);
});
}
/** Shifts the selected date by whole days, so yesterday is one click rather than a date picker. */
shiftDate(days: number): void {
const [
y,
m,
d
] = this.date.split('-').map(Number);
const dt = new Date(y, m - 1, d + days);
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
this.load();
}
goToday(): void {
this.date = this.today();
this.load();
}
/**
* The day's rows split by where they came from, in the order they are read.
*
* Cashier work first — that is the counter — then the clerk's outstanding requests. An empty
* section is dropped rather than shown as a heading over nothing.
*
* A section is not "who typed it": a clerk raises a withdrawal that a cashier pays out, so the
* heading says which register the entry belongs to, and the By column still names the person.
*/
get sections(): {
key: DayBookSection;
label: string;
hint: string;
rows: DayBookRow[];
credit: number;
debit: number;
}[] {
const order: { key: DayBookSection; label: string; hint: string }[] = [
{ key: 'CASHIER', label: 'Cashier', hint: 'Cash actually taken in and paid out over the counter' },
{ key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' },
// Postings that reached the ledger without a request behind them: interest the nightly run
// credited, a charge taken from a balance, a manual journal. These are the day's work too,
// and an officer accounting for the day cannot do it from the counter registers alone.
{
key: 'SYSTEM',
label: 'System & Journal',
hint: 'Interest, charges and journals posted without a counter request'
}
];
return order
.map((s) => {
const rows = this.filteredRows.filter((r) => r.section === s.key);
return {
...s,
rows,
credit: rows
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
.reduce((t, r) => t + Number(r.amount ?? 0), 0),
debit: rows
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
.reduce((t, r) => t + Number(r.amount ?? 0), 0)
};
})
.filter((s) => s.rows.length > 0);
}
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
get filteredRows(): DayBookRow[] {
const base = this.rows;
const q = this.search.trim().toLowerCase();
if (!q) return base;
return base.filter((r) =>
[
r.voucherNo,
r.accountNo,
r.party,
r.kind,
r.paymentTypeName,
r.handledBy,
r.description
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
);
}
/** Totals for what is on screen, so a search or side filter still reconciles. */
get shownCredit(): number {
return this.filteredRows
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
}
get shownDebit(): number {
return this.filteredRows
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
}
/** Only approved money is real money — a pending or rejected voucher must not move the totals.
* The three source registers don't share one terminal status string: deposits and disbursements
* land on APPROVED, but a clerk withdrawal's own finished state is WITHDRAWN — missing it here
* meant a completed withdrawal moved real money out yet the DR total stayed at zero. */
private countsAsMoney(r: DayBookRow): boolean {
const s = (r.status ?? '').toUpperCase();
return s === 'APPROVED' || s === 'COMPLETED' || s === 'WITHDRAWN';
}
/**
* The day totalled per person, for the officer who signs the book off.
*
* A branch's day book is read two ways. Entry by entry, to trace one voucher — which the sections
* below already give. And person by person, to ask what each member of staff handled and whether
* their column adds up: that is the reconciliation a principal officer actually performs, and
* without it they are adding a day of rows in their head.
*
* Built from whoever appears in the day's own rows rather than from a list of staff, so it needs
* no configuring: one person working alone gets one line, five people get five, and somebody who
* handled nothing today is simply absent instead of showing as a row of zeros.
*
* Only approved money counts, the same rule the section totals use — a pending request is not
* cash anybody is answerable for yet. Pending and rejected rows are still counted as ITEMS, so a
* person whose work is all awaiting approval does not vanish from the sheet.
*
* Ordered by the largest turnover first: the columns worth checking hardest are the ones that
* moved the most money.
*/
get byUser(): {
name: string;
credit: number;
debit: number;
net: number;
items: number;
pending: number;
}[] {
const map = new Map<string, { name: string; credit: number; debit: number; items: number; pending: number }>();
for (const r of this.filteredRows) {
// Rows the registers could not attribute are grouped rather than dropped: an unattributed
// amount is exactly what an officer needs to see, and hiding it would leave the sheet's own
// total disagreeing with the day's.
const name = (r.handledBy ?? '').trim() || 'Unattributed';
const entry = map.get(name) ?? { name, credit: 0, debit: 0, items: 0, pending: 0 };
entry.items++;
if (this.countsAsMoney(r)) {
const amount = Number(r.amount ?? 0);
if (r.side === 'CR') {
entry.credit += amount;
} else {
entry.debit += amount;
}
} else {
entry.pending++;
}
map.set(name, entry);
}
return Array.from(map.values())
.map((e) => ({ ...e, net: e.credit - e.debit }))
.sort((a, b) => b.credit + b.debit - (a.credit + a.debit));
}
/** The sheet's own totals, which must equal the day's — that is what makes it verifiable. */
get byUserTotals(): { credit: number; debit: number; net: number; items: number; pending: number } {
return this.byUser.reduce(
(t, u) => ({
credit: t.credit + u.credit,
debit: t.debit + u.debit,
net: t.net + u.net,
items: t.items + u.items,
pending: t.pending + u.pending
}),
{ credit: 0, debit: 0, net: 0, items: 0, pending: 0 }
);
}
/**
* Approved and paid out are not the same state, and the day book is where the difference matters:
* an approved withdrawal is a promise, a withdrawn one is money gone. Both used to render in the
* same green, so a reader could not tell from the badge whether the customer had actually been
* handed the cash — the one thing this page is read to find out.
*/
statusClass(status: string): string {
switch ((status ?? '').toUpperCase()) {
case 'WITHDRAWN':
case 'COMPLETED':
case 'CLOSED':
// Cash has left the counter. The darker, filled badge — the final state.
return 'db-badge db-badge--done';
case 'APPROVED':
// Passed, but nobody has handed anything over yet.
return 'db-badge db-badge--ok';
case 'PENDING':
return 'db-badge db-badge--pending';
case 'REJECTED':
return 'db-badge db-badge--rejected';
default:
return 'db-badge';
}
}
/** Time of day only — the date is already the page's subject. */
timeOf(iso: string | null): string {
if (!iso) return '—';
const t = iso.split('T')[1];
return t ? t.slice(0, 5) : '—';
}
exportCsv(): void {
const header = [
'Section',
'Voucher No',
'Side',
'Type',
'Account',
'Party',
'Amount',
'Payment Type',
'Status',
'Handled By',
'Approved By',
'Time'
];
const lines = this.filteredRows.map((r) =>
[
r.section,
r.voucherNo ?? '',
r.side,
r.kind,
r.accountNo ?? '',
r.party ?? '',
String(r.amount ?? 0),
r.paymentTypeName ?? '',
r.status,
r.handledBy ?? '',
r.approvedBy ?? '',
this.timeOf(r.submittedAt)
]
// Quote every field and double any embedded quote: names and narrations routinely
// contain commas, which would otherwise split into extra columns.
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
const csv = [
header.join(','),
...lines
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `day-book-${this.date}.csv`;
a.click();
URL.revokeObjectURL(url);
}
}
@@ -1,606 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg eod">
<!-- Toolbar: never printed, because a printed sheet has nothing to click. -->
<div class="pg-head no-print">
<div>
<h1 class="pg-title">End of the Day Report</h1>
<p class="pg-sub">What moved today, whether the cash agrees with the books, and who signs off.</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Business date</span>
<input type="date" [(ngModel)]="date" (change)="reload()" />
</label>
<button class="pg-btn pg-btn--ghost" (click)="reload()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
Reload
</button>
<button class="pg-btn pg-btn--primary" (click)="print()" [disabled]="!loaded">
<mat-icon>print</mat-icon>
Print / Save as PDF
</button>
</div>
</div>
@if (loading) {
<div class="pg-empty">Loading the day&rsquo;s figures&hellip;</div>
}
@if (loaded) {
<div class="eod-sheet">
<!-- ── Header ── -->
<div class="eod-sheet__head">
<h2 class="eod-sheet__title">Daily EOD &amp; Transaction Report</h2>
<div class="eod-sheet__meta">
<div class="eod-meta">
<span class="eod-meta__label">Date</span>
<span class="eod-meta__value">{{ date | date: 'dd MMM yyyy' }}</span>
</div>
<div class="eod-meta">
<span class="eod-meta__label">Branch</span>
<span class="eod-meta__value">{{ branchName || '—' }}</span>
</div>
<div class="eod-meta">
<span class="eod-meta__label">Prepared by</span>
<span class="eod-meta__value">{{ preparedBy || '—' }}</span>
</div>
<div class="eod-meta">
<span class="eod-meta__label">Scope</span>
<span class="eod-meta__value">{{ scopedToCashier ? 'My counter' : 'Branch counter' }}</span>
</div>
</div>
</div>
<!-- ── 1. Transaction summary ── -->
<section class="eod-section">
<h3 class="eod-section__title"><span class="eod-section__no">1</span> Transaction Summary</h3>
@if (sectionLines.length) {
<p class="eod-caption">Where the money came from</p>
<table class="pg-table">
<thead>
<tr>
<th>Source</th>
<th class="num">Entries</th>
<th class="num col-in">Money in</th>
<th class="num col-out">Money out</th>
</tr>
</thead>
<tbody>
@for (l of sectionLines; track l.label) {
<tr>
<td>{{ l.label }}</td>
<td class="num">{{ l.count }}</td>
<td class="num col-in">{{ l.credit ? (l.credit | currency: 'INR') : '—' }}</td>
<td class="num col-out">{{ l.debit ? (l.debit | currency: 'INR') : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td class="num">
<strong>{{ txnCount }}</strong>
</td>
<td class="num col-in">
<strong>{{ summaryCreditTotal | currency: 'INR' }}</strong>
</td>
<td class="num col-out">
<strong>{{ summaryDebitTotal | currency: 'INR' }}</strong>
</td>
</tr>
</tfoot>
</table>
<p class="eod-caption eod-caption--spaced">What kind of entries</p>
<table class="pg-table">
<thead>
<tr>
<th>Kind</th>
<th class="num">Entries</th>
<th class="num col-in">Money in</th>
<th class="num col-out">Money out</th>
</tr>
</thead>
<tbody>
@for (l of kindLines; track l.label) {
<tr>
<td>{{ l.label }}</td>
<td class="num">{{ l.count }}</td>
<td class="num col-in">{{ l.credit ? (l.credit | currency: 'INR') : '—' }}</td>
<td class="num col-out">{{ l.debit ? (l.debit | currency: 'INR') : '—' }}</td>
</tr>
}
</tbody>
</table>
<p class="eod-note">
Only entries that actually moved money. Anything still pending is listed at the bottom. These figures match
the Day Book for the same date.
</p>
} @else {
<div class="pg-empty">No transactions on this date.</div>
}
</section>
<!-- ── 2. Cash ──
Movement first, because that is the part every figure here agrees on. A comparison
against what is held is only offered where both sides describe the same period. -->
<section class="eod-section">
<h3 class="eod-section__title"><span class="eod-section__no">2</span> Cash</h3>
@if (scopedToCashier) {
<p class="eod-caption">Your drawer today</p>
<table class="pg-table eod-recon">
<tbody>
<tr>
<td>Cash you took from the vault this morning</td>
<td class="num">{{ openingFloat | currency: 'INR' }}</td>
</tr>
<tr>
<td>Cash you took from members <span class="eod-hint">deposits, including DD cash</span></td>
<td class="num col-in">+{{ cashDeposits | currency: 'INR' }}</td>
</tr>
@if (interCashierIn) {
<tr>
<td>Cash another cashier gave you</td>
<td class="num col-in">+{{ interCashierIn | currency: 'INR' }}</td>
</tr>
}
<tr>
<td>Cash you paid to members <span class="eod-hint">withdrawals</span></td>
<td class="num col-out">−{{ cashWithdrawals | currency: 'INR' }}</td>
</tr>
@if (settledOut) {
<tr>
<td>Cash you handed back to the vault</td>
<td class="num col-out">−{{ settledOut | currency: 'INR' }}</td>
</tr>
}
@if (interCashierOut) {
<tr>
<td>Cash you gave another cashier</td>
<td class="num col-out">−{{ interCashierOut | currency: 'INR' }}</td>
</tr>
}
<tr class="eod-recon__expected">
<td><strong>So your drawer should hold</strong></td>
<td class="num">
@if (expectedCash !== null) {
<strong>{{ expectedCash | currency: 'INR' }}</strong>
} @else {
<span class="eod-blank">&mdash;</span>
}
</td>
</tr>
<tr>
<td><strong>System says you hold</strong></td>
<td class="num">
@if (actualCash !== null) {
<strong>{{ actualCash | currency: 'INR' }}</strong>
} @else {
<span class="eod-blank">nothing recorded</span>
}
</td>
</tr>
@if (variance !== null) {
<tr class="eod-recon__variance" [class]="varianceClass">
<td><strong>Difference</strong></td>
<td class="num">
<strong>{{ variance | currency: 'INR' }}</strong>
</td>
</tr>
}
</tbody>
</table>
@if (variance !== null) {
@if (varianceOk) {
<p class="eod-verdict eod-verdict--ok">
<mat-icon>check_circle</mat-icon>
Your cash agrees. Nothing short, nothing over.
</p>
} @else {
<p class="eod-verdict eod-verdict--bad">
<mat-icon>error</mat-icon>
{{ variance! > 0 ? 'Extra' : 'Short' }} by
{{ (variance! > 0 ? variance : -variance!) | currency: 'INR' }}. Explain this before closing.
</p>
}
} @else {
<p class="eod-note">Today&rsquo;s cash legs were not sent for this login, so nothing is compared.</p>
}
} @else {
<p class="eod-caption">Cash that moved across the branch today</p>
<table class="pg-table eod-recon">
<tbody>
<tr>
<td>Cash taken from members <span class="eod-hint">deposits</span></td>
<td class="num col-in">+{{ branchDeposits ?? 0 | currency: 'INR' }}</td>
</tr>
<tr>
<td>Cash paid to members <span class="eod-hint">withdrawals and closures</span></td>
<td class="num col-out">−{{ branchWithdrawals ?? 0 | currency: 'INR' }}</td>
</tr>
<tr class="eod-recon__expected">
<td><strong>Net change today</strong></td>
<td class="num col-net">
<strong>{{ branchNetMovement | currency: 'INR' }}</strong>
</td>
</tr>
</tbody>
</table>
<p class="eod-caption eod-caption--spaced">Cash the branch is holding right now</p>
<table class="pg-table eod-recon">
<tbody>
<tr>
<td>In the vaults</td>
<td class="num">{{ vaultTotal ?? 0 | currency: 'INR' }}</td>
</tr>
<tr>
<td>With the cashiers</td>
<td class="num">{{ drawersTotal ?? 0 | currency: 'INR' }}</td>
</tr>
<tr class="eod-recon__expected">
<td><strong>Total cash in the branch</strong></td>
<td class="num">
@if (branchHeldTotal !== null) {
<strong>{{ branchHeldTotal | currency: 'INR' }}</strong>
} @else {
<span class="eod-blank">nothing recorded</span>
}
</td>
</tr>
</tbody>
</table>
<p class="eod-note">
These are two different things and are not subtracted from each other. The first is today&rsquo;s movement;
the second is everything the branch has accumulated to date. Each cashier&rsquo;s own drawer is checked
against their own day on their login, and the branch figure is signed off on the Day Open / Day Close
screen.
</p>
}
</section>
<!-- ── 3. Who handled the day, and what each drawer holds ──
A clerk signs off other people's work, so the sheet has to name them. On a cashier's own
login this collapses to one row, which is still the right thing to print. -->
<section class="eod-section">
<h3 class="eod-section__title"><span class="eod-section__no">3</span> Handled By</h3>
@if (byUser.length) {
<p class="eod-caption">What each person handled</p>
<table class="pg-table">
<thead>
<tr>
<th>Handled by</th>
<th class="num">Entries</th>
<th class="num col-in">Money in</th>
<th class="num col-out">Money out</th>
<th class="num col-net">Net</th>
</tr>
</thead>
<tbody>
@for (u of byUser; track u.label) {
<tr>
<td>{{ u.label }}</td>
<td class="num">{{ u.count }}</td>
<td class="num col-in">{{ u.credit ? (u.credit | currency: 'INR') : '—' }}</td>
<td class="num col-out">{{ u.debit ? (u.debit | currency: 'INR') : '—' }}</td>
<td class="num col-net">{{ u.credit - u.debit | currency: 'INR' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td class="num">
<strong>{{ txnCount }}</strong>
</td>
<td class="num col-in">
<strong>{{ summaryCreditTotal | currency: 'INR' }}</strong>
</td>
<td class="num col-out">
<strong>{{ summaryDebitTotal | currency: 'INR' }}</strong>
</td>
<td class="num col-net">
<strong>{{ summaryCreditTotal - summaryDebitTotal | currency: 'INR' }}</strong>
</td>
</tr>
</tfoot>
</table>
} @else {
<div class="pg-empty">Nobody moved money on this date.</div>
}
@if (cashierPositions.length) {
<p class="eod-caption eod-caption--spaced">What each cashier is holding</p>
<table class="pg-table">
<thead>
<tr>
<th>Cashier</th>
<th class="num">Cash held</th>
</tr>
</thead>
<tbody>
@for (p of cashierPositions; track p.cashierId) {
<tr>
<td>{{ p.cashierName }}</td>
<td class="num">{{ p.totalAmount | currency: 'INR' }}</td>
</tr>
}
</tbody>
@if (cashierPositions.length > 1) {
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td class="num">
<strong>{{ cashierPositionTotal | currency: 'INR' }}</strong>
</td>
</tr>
</tfoot>
}
</table>
<p class="eod-note">
This is what each drawer should contain right now &mdash; a running total, not just today. Count against it
before settling to the vault.
</p>
}
</section>
<!-- ── 4. Every transaction ──
The sections above are totals, and a total nobody can break down is a total nobody can
check. This is the day itself, line by line, so a figure above can be traced to the
entries that made it. -->
<section class="eod-section eod-section--wide">
<h3 class="eod-section__title"><span class="eod-section__no">4</span> Every Transaction</h3>
@if (approvedRows.length) {
<table class="pg-table eod-txns">
<thead>
<tr>
<th>Voucher</th>
<th>Kind</th>
<th>Account</th>
<th>Party</th>
<th>Handled by</th>
<th class="num col-in">Money in</th>
<th class="num col-out">Money out</th>
</tr>
</thead>
<tbody>
@for (r of approvedRows; track r.voucherNo + '|' + r.txnId) {
<tr>
<td class="eod-mono">{{ r.voucherNo || '—' }}</td>
<td>{{ kindLabel(r.kind) }}</td>
<td class="eod-mono">{{ r.accountNo || '—' }}</td>
<td>{{ r.party || '—' }}</td>
<td>{{ r.handledBy || '—' }}</td>
<td class="num col-in">{{ r.side === 'CR' ? (r.amount | currency: 'INR') : '—' }}</td>
<td class="num col-out">{{ r.side === 'DR' ? (r.amount | currency: 'INR') : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="5">
<strong>Total &mdash; {{ txnCount }} entries</strong>
</td>
<td class="num col-in">
<strong>{{ summaryCreditTotal | currency: 'INR' }}</strong>
</td>
<td class="num col-out">
<strong>{{ summaryDebitTotal | currency: 'INR' }}</strong>
</td>
</tr>
</tfoot>
</table>
} @else {
<div class="pg-empty">No transactions on this date.</div>
}
@if (pendingRows.length) {
<p class="eod-caption eod-caption--spaced">Still pending &mdash; no money has moved on these</p>
<table class="pg-table eod-txns">
<thead>
<tr>
<th>Voucher</th>
<th>Kind</th>
<th>Account</th>
<th>Party</th>
<th>Raised by</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (r of pendingRows; track r.voucherNo + '|' + r.txnId) {
<tr>
<td class="eod-mono">{{ r.voucherNo || '—' }}</td>
<td>{{ kindLabel(r.kind) }}</td>
<td class="eod-mono">{{ r.accountNo || '—' }}</td>
<td>{{ r.party || '—' }}</td>
<td>{{ r.handledBy || '—' }}</td>
<td class="num">{{ r.amount | currency: 'INR' }}</td>
</tr>
}
</tbody>
</table>
}
</section>
<!-- ── Last night's batch ──
Yesterday's work, posted after that day had already been counted and signed. Shown here
so it is not lost, kept out of every total above so today's figures still tie to the
ledger. Only appears when there is such a batch. -->
@if (priorDayRows.length) {
<section class="eod-section eod-section--wide eod-section--prior">
<h3 class="eod-section__title">
<mat-icon class="eod-section__icon">nights_stay</mat-icon>
Posted Overnight &mdash; work of
{{ priorDayDate ? (priorDayDate | date: 'dd MMM yyyy') : 'the previous day' }}
</h3>
<!-- Both dates side by side, because the whole point of this block is that they differ.
A reader who only sees one of them cannot tell whether the entry is being counted
today or merely reported today. -->
<div class="eod-datepair">
<div class="eod-datepair__item">
<span class="eod-datepair__label">These belong to</span>
<span class="eod-datepair__value">{{
priorDayDate ? (priorDayDate | date: 'dd MMM yyyy') : 'the previous day'
}}</span>
<span class="eod-datepair__hint">the day the system posted them against &mdash; already closed</span>
</div>
<div class="eod-datepair__arrow">→</div>
<div class="eod-datepair__item">
<span class="eod-datepair__label">Showing on</span>
<span class="eod-datepair__value">{{ date | date: 'dd MMM yyyy' }}</span>
<span class="eod-datepair__hint">today&rsquo;s report, because the run finished after midnight</span>
</div>
</div>
<p class="eod-note eod-note--lead">
The nightly run posted these after
{{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'the previous day' }} had already closed and been signed
off, so they surface on today&rsquo;s report instead. In the ledger they sit on
{{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'that date' }}, and they are
<strong>not counted</strong> in any figure above.
</p>
<table class="pg-table eod-txns">
<thead>
<tr>
<th>Voucher</th>
<th>Kind</th>
<th>Account</th>
<th>Party</th>
<th>Ledger date</th>
<th class="num col-in">Money in</th>
<th class="num col-out">Money out</th>
</tr>
</thead>
<tbody>
@for (r of priorDayRows; track r.voucherNo + '|' + r.txnId) {
<tr>
<td class="eod-mono">{{ r.voucherNo || '—' }}</td>
<td>{{ kindLabel(r.kind) }}</td>
<td class="eod-mono">{{ r.accountNo || '—' }}</td>
<td>{{ r.party || '—' }}</td>
<!-- Per row, not just in the heading: one run can span a date boundary, and a
reader checking a single figure against the ledger needs the date on the line
they are looking at. -->
<td class="eod-datecell">{{ r.effectiveDate ? (r.effectiveDate | date: 'dd MMM yyyy') : '—' }}</td>
<td class="num col-in">{{ r.side === 'CR' ? (r.amount | currency: 'INR') : '—' }}</td>
<td class="num col-out">{{ r.side === 'DR' ? (r.amount | currency: 'INR') : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="5">
<strong>Total &mdash; {{ priorDayRows.length }} entries</strong>
</td>
<td class="num col-in">
<strong>{{ priorDayCredit | currency: 'INR' }}</strong>
</td>
<td class="num col-out">
<strong>{{ priorDayDebit | currency: 'INR' }}</strong>
</td>
</tr>
</tfoot>
</table>
</section>
}
<!-- ── 5. Operational notes ── -->
<section class="eod-section">
<h3 class="eod-section__title"><span class="eod-section__no">5</span> Anything Outstanding</h3>
<ul class="eod-notes">
<li>
<span class="eod-notes__label">Entries handled</span>
<span class="eod-notes__value">{{ txnCount }} entries &middot; {{ kindLines.length }} kind(s)</span>
</li>
<li [class.eod-notes--warn]="pendingRows.length > 0">
<span class="eod-notes__label">Waiting for approval</span>
<span class="eod-notes__value">
@if (pendingRows.length) {
{{ pendingRows.length }} entry(ies) &mdash; the day cannot close until these are done
} @else {
None
}
</span>
</li>
<li [class.eod-notes--warn]="mismatchRows.length > 0">
<span class="eod-notes__label">Slips that did not match</span>
<span class="eod-notes__value">
@if (mismatchRows.length) {
{{ mismatchRows.length }} slip(s) do not agree with the system
} @else {
None
}
</span>
</li>
<li>
<span class="eod-notes__label">Slips not checked yet</span>
<span class="eod-notes__value">
{{
unverifiedRows.length
? unverifiedRows.length + ' entry(ies) still to be checked against paper'
: 'All checked'
}}
</span>
</li>
@if (priorDayRows.length) {
<li>
<span class="eod-notes__label">Posted overnight</span>
<span class="eod-notes__value">
{{ priorDayRows.length }} entry(ies) for
{{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'the previous day' }} &mdash; listed in their own
section, not in today&rsquo;s figures
</span>
</li>
}
<li [class.eod-notes--ok]="allClear">
<span class="eod-notes__label">Status</span>
<span class="eod-notes__value">
{{
allClear
? 'Everything is clear — the day is ready to close.'
: 'Sort out the items above before closing the day.'
}}
</span>
</li>
</ul>
</section>
<!-- ── Signatures ── -->
<section class="eod-sign">
<div class="eod-sign__block">
<div class="eod-sign__line"></div>
<div class="eod-sign__role">{{ supervising ? 'Prepared by' : 'Cashier' }}</div>
<div class="eod-sign__name">{{ preparedBy || '' }}</div>
</div>
<div class="eod-sign__block">
<div class="eod-sign__line"></div>
<div class="eod-sign__role">Manager / Principal Officer</div>
<div class="eod-sign__name">&nbsp;</div>
</div>
</section>
<p class="eod-foot">
Generated from the day book and the closing cash report for {{ date | date: 'dd MMM yyyy' }}. Figures move with
the underlying vouchers &mdash; reprint after any reversal.
</p>
</div>
}
</div>
@@ -1,448 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* The sheet itself, which is what gets signed. Bordered on screen so it reads as one document
rather than three panels, and the same borders carry into print. */
/* Centred in the page, because it is a document rather than a screen: a signed sheet pinned to the
left edge of a wide monitor reads as a panel that failed to fill its space. */
.eod-sheet {
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 28px 32px 24px;
max-width: 900px;
margin: 0 auto;
width: 100%;
}
/* The toolbar and the loading line above it share the sheet's own edges, not the viewport's. */
.eod .pg-head,
.eod > .pg-empty {
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.eod-sheet__head {
border-bottom: 2px solid #0f172a;
padding-bottom: 14px;
margin-bottom: 22px;
}
.eod-sheet__title {
margin: 0 0 14px;
font-size: 19px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #0f172a;
text-align: center;
}
.eod-sheet__meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px 18px;
}
.eod-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.eod-meta__label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #64748b;
}
.eod-meta__value {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
/* ── Sections ── */
.eod-section {
margin-bottom: 26px;
/* A section must not be split across two printed pages: a reconciliation whose expected line is
on page one and whose variance is on page two is not a document anybody can sign. */
break-inside: avoid;
page-break-inside: avoid;
}
.eod-section__title {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 12px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #0f172a;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 7px;
}
.eod-section__no {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
background: #0f172a;
color: #fff;
font-size: 11px;
font-weight: 700;
flex: 0 0 auto;
}
.eod-note {
margin: 8px 0 0;
font-size: 11.5px;
color: #64748b;
line-height: 1.5;
}
/* Names the table below it, so two tables in one section do not read as one long list. */
.eod-caption {
margin: 0 0 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #475569;
}
.eod-caption--spaced {
margin-top: 18px;
}
/* The line-by-line list is wider than the rest of the sheet, so it scrolls in its own box rather
than making the whole page scroll sideways. */
.eod-section--wide {
.pg-table {
min-width: 100%;
}
}
.eod-txns {
font-size: 12px;
td,
th {
white-space: nowrap;
}
}
/* Voucher numbers and account numbers are read digit by digit, so they get a fixed pitch. */
.eod-mono {
font-family: 'Roboto Mono', ui-monospace, 'Courier New', monospace;
font-size: 11.5px;
}
/* Last night's batch. Set apart on purpose: these figures belong to a day that has already been
signed off, so the block has to read as an appendix rather than as part of the day being counted.
Indigo, because that is already the day book's colour for system postings. */
.eod-section--prior {
background: #f5f3ff;
border: 1px solid #ddd6fe;
border-radius: 8px;
padding: 16px 18px 14px;
margin-top: 30px;
.eod-section__title {
color: #5b21b6;
border-bottom-color: #ddd6fe;
}
.pg-table th {
background: #ede9fe;
color: #5b21b6;
}
}
/* Sits in the title row in place of the numbered chip: this block is outside the numbered sequence,
because it is not a step in counting today. */
.eod-section__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #7c3aed;
flex: 0 0 auto;
}
.eod-note--lead {
margin: 0 0 12px;
color: #5b21b6;
}
/* The two dates, stated as a pair with an arrow between them. Spelling out "belongs to X, showing
on Y" is the only way a reader can be sure which of the two a figure is counted under — a single
date in a heading leaves exactly the ambiguity this block exists to remove. */
.eod-datepair {
display: flex;
align-items: center;
gap: 14px;
margin: 0 0 14px;
padding: 12px 14px;
background: #fff;
border: 1px solid #ddd6fe;
border-radius: 6px;
flex-wrap: wrap;
}
.eod-datepair__item {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1 1 200px;
}
.eod-datepair__label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #7c3aed;
}
.eod-datepair__value {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.eod-datepair__hint {
font-size: 10.5px;
color: #64748b;
line-height: 1.4;
}
.eod-datepair__arrow {
font-size: 20px;
color: #a78bfa;
flex: 0 0 auto;
}
/* Dates in the row are read against the ledger, so they do not shrink into the grey the way a
secondary column normally would. */
.eod-datecell {
font-size: 11.5px;
font-weight: 600;
color: #5b21b6;
white-space: nowrap;
}
.eod-hint {
display: block;
font-size: 10.5px;
font-weight: 400;
color: #94a3b8;
}
.eod-blank {
color: #94a3b8;
font-style: italic;
}
/* ── Reconciliation table ── */
/* Two columns only, so the label takes the room and the figure sits in a fixed money column. */
.eod-recon {
td:first-child {
width: auto;
}
td:last-child {
width: 190px;
}
}
.eod-recon__expected td {
border-top: 1px solid #94a3b8;
background: #f8fafc;
}
.eod-recon__variance td {
border-top: 2px solid #0f172a;
}
.eod-recon__variance.eod-ok td {
background: #f0fdf4;
color: #15803d;
}
.eod-recon__variance.eod-bad td {
background: #fef2f2;
color: #b91c1c;
}
/* ── Verdict line ── */
.eod-verdict {
display: flex;
align-items: center;
gap: 7px;
margin: 12px 0 0;
padding: 9px 12px;
border-radius: 6px;
font-size: 12.5px;
font-weight: 600;
line-height: 1.45;
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
flex: 0 0 auto;
}
}
.eod-verdict--ok {
background: #f0fdf4;
border: 1px solid #86efac;
color: #15803d;
}
.eod-verdict--bad {
background: #fef2f2;
border: 1px solid #fca5a5;
color: #b91c1c;
}
/* ── Operational notes ── */
.eod-notes {
list-style: none;
margin: 0;
padding: 0;
li {
display: flex;
gap: 14px;
padding: 8px 10px;
border-bottom: 1px solid #f1f5f9;
font-size: 12.5px;
&:last-child {
border-bottom: 0;
}
}
}
.eod-notes__label {
flex: 0 0 190px;
font-weight: 600;
color: #475569;
}
.eod-notes__value {
color: #0f172a;
}
.eod-notes--warn {
background: #fffbeb;
.eod-notes__value {
color: #b45309;
font-weight: 600;
}
}
.eod-notes--ok {
background: #f0fdf4;
.eod-notes__value {
color: #15803d;
font-weight: 600;
}
}
/* ── Signatures ── */
.eod-sign {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 40px;
margin-top: 46px;
break-inside: avoid;
page-break-inside: avoid;
}
.eod-sign__block {
text-align: center;
}
.eod-sign__line {
border-bottom: 1px solid #0f172a;
height: 34px;
margin-bottom: 7px;
}
.eod-sign__role {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #475569;
}
.eod-sign__name {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
.eod-foot {
margin: 26px 0 0;
padding-top: 12px;
border-top: 1px dashed #cbd5e1;
font-size: 10.5px;
color: #94a3b8;
text-align: center;
line-height: 1.5;
}
/* ── Print ──
The sheet is the document; the shell, the toolbar and the app chrome are not. Colours are forced
so the variance verdict survives a printer that would otherwise drop backgrounds. */
@media print {
.no-print,
.pg-head {
display: none !important;
}
.eod-sheet {
border: 0;
border-radius: 0;
padding: 0;
max-width: none;
}
.eod-sheet,
.eod-recon__variance td,
.eod-verdict,
.eod-notes--warn,
.eod-notes--ok {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.eod-sheet__title {
font-size: 16px;
}
.pg-table {
font-size: 11px;
}
}
@@ -1,536 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { CashierService } from '../cashier.service';
import { SettingsService } from 'app/settings/settings.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
/** One part of the day's work — a section of the day book, or a kind of entry within it. */
interface SummaryLine {
label: string;
credit: number;
debit: number;
count: number;
}
/**
* The single sheet a cashier signs the day off with.
*
* Every other cashier screen answers one question — what is in the drawer, which vouchers are
* unverified, what did each person handle. At close of day somebody has to sign one piece of paper
* saying the money was counted and it agreed, and until now that meant reading four screens and
* copying figures onto a form by hand.
*
* Nothing here is computed on the server that was not already being computed: the closing report
* supplies the drawer legs, the day book supplies the transactions, and the cashier position
* supplies what the system believes is held. This screen assembles them in the order a person
* verifying the day reads them in, and prints.
*/
@Component({
selector: 'mifosx-end-of-day-report',
templateUrl: './end-of-day-report.component.html',
styleUrls: [
'../cashier-shared.scss',
'./end-of-day-report.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class EndOfDayReportComponent implements OnInit {
private service = inject(CashierService);
private settings = inject(SettingsService);
private auth = inject(AuthenticationService);
date = '';
loading = false;
loaded = false;
/** Who is signing, and for which branch — read from the session, never typed in. */
preparedBy = '';
branchName = '';
// ── Section 1: transaction summary ──
rows: any[] = [];
creditTotal = 0;
debitTotal = 0;
// ── Section 2: cash drawer ──
/** Drawn from the vault in the morning: a cashier's opening float. */
openingFloat = 0;
cashDeposits = 0;
interCashierIn = 0;
cashWithdrawals = 0;
settledOut = 0;
interCashierOut = 0;
/** Whether the server sent this day's own cash legs, which only a cashier-scoped call gets. */
hasDayLegs = false;
/** Whether the drawer figures are one cashier's or the whole branch's counter work. */
scopedToCashier = false;
/** What the drawers hold between them, per the system. */
drawersTotal: number | null = null;
/**
* Each cashier and what their drawer holds.
*
* A clerk holds no drawer of their own — they supervise the counter — so a single "actual cash"
* figure says nothing to them. The position endpoint already answers for whoever is asking: one
* drawer for a cashier, every drawer for a clerk or officer, so the same call serves both and the
* screen reads differently depending on who opened it.
*/
cashierPositions: { cashierId: number | null; cashierName: string; totalAmount: number }[] = [];
/**
* The branch's own expected cash, from the day register.
*
* A branch-wide reading cannot be assembled from the per-cashier day legs: those are only
* returned when the call is scoped to one cashier. The branch day register already keeps this
* arithmetic for the close-day gate — opening cash, plus approved deposits, less what was
* withdrawn — so the sheet reads it rather than inventing a second version of the same sum.
*
* Null when no day has been opened for this date, which is a real state: there is nothing to
* reconcile against on a day the branch never opened.
*/
branchOpeningCash: number | null = null;
branchDeposits: number | null = null;
branchWithdrawals: number | null = null;
branchExpectedCash: number | null = null;
/**
* What the vaults hold, which a branch-wide count must include.
*
* The branch register's expected cash covers the whole branch — vault and every drawer together —
* because cash moved from the vault to a cashier has not left the branch. Counting only the
* drawers against it would report the entire vault as a shortage.
*/
vaultTotal: number | null = null;
ngOnInit(): void {
this.date = this.businessDate();
this.preparedBy = this.currentUserName();
this.branchName = this.currentOfficeName();
this.load();
}
/**
* The date the books are open on, not the wall clock.
*
* An EOD sheet dated by the browser would be filed against the wrong day whenever the branch is
* still working yesterday's business date, which is exactly when a day is being closed.
*/
private businessDate(): string {
const d = this.settings.businessDate;
const safe = d instanceof Date && !isNaN(d.getTime()) ? d : new Date();
return `${safe.getFullYear()}-${String(safe.getMonth() + 1).padStart(2, '0')}-${String(safe.getDate()).padStart(2, '0')}`;
}
/** The member of staff behind the login, falling back to the username when no staff is linked. */
private currentUserName(): string {
const c = this.auth.getCredentials();
return c ? c.staffDisplayName || c.username || '' : '';
}
private currentOfficeName(): string {
return this.auth.getCredentials()?.officeName || '';
}
load(): void {
this.loading = true;
this.loaded = false;
this.service.dayBook(this.date).subscribe({
next: (res: any) => {
this.rows = res?.rows || [];
this.creditTotal = Number(res?.creditTotal || 0);
this.debitTotal = Number(res?.debitTotal || 0);
this.loading = false;
this.loaded = true;
},
error: () => {
this.rows = [];
this.creditTotal = 0;
this.debitTotal = 0;
this.loading = false;
this.loaded = true;
}
});
this.service.closingCashReport(this.date).subscribe({
next: (res: any) => {
// The four day legs are only sent for a cashier-scoped call. Read as absent rather than
// zero: a leg that is missing and a leg that is genuinely nil mean different things, and
// treating the first as the second is what let the sheet claim an expected balance of zero
// for a whole branch.
this.hasDayLegs = res?.todayVaultIn != null;
this.openingFloat = Number(res?.todayVaultIn || 0);
this.cashDeposits = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0);
this.cashWithdrawals = Number(res?.withdrawalTotal || 0);
this.settledOut = Number(res?.todaySettledOut || 0);
this.interCashierIn = Number(res?.todayInterCashierIn || 0);
this.interCashierOut = Number(res?.todayInterCashierOut || 0);
this.scopedToCashier = res?.scopedToCashier === true;
},
error: () => {
this.openingFloat = 0;
this.cashDeposits = 0;
this.cashWithdrawals = 0;
this.settledOut = 0;
this.interCashierIn = 0;
this.interCashierOut = 0;
this.scopedToCashier = false;
this.hasDayLegs = false;
}
});
// The branch's expected cash for the date, which is the only basis a branch-wide reading can
// reconcile on. Fails quietly for a plain cashier, who is not allowed to operate the day.
this.service.branchDayClosePreview(this.date).subscribe({
next: (res: any) => {
this.branchOpeningCash = res?.openingCash == null ? null : Number(res.openingCash);
this.branchDeposits = res?.deposits == null ? null : Number(res.deposits);
this.branchWithdrawals = res?.withdrawals == null ? null : Number(res.withdrawals);
this.branchExpectedCash = res?.expectedCash == null ? null : Number(res.expectedCash);
},
error: () => {
this.branchOpeningCash = null;
this.branchDeposits = null;
this.branchWithdrawals = null;
this.branchExpectedCash = null;
}
});
// One object for a cashier, an array of every cashier for a clerk or officer — the server
// decides from the caller's own roles, so this screen does not have to ask who is reading it.
this.service.cashierPositionReport(undefined, this.date).subscribe({
next: (res: any) => {
const list = Array.isArray(res) ? res : res ? [res] : [];
this.cashierPositions = list
.filter((p: any) => p && p.cashierId != null)
.map((p: any) => ({
cashierId: p.cashierId == null ? null : Number(p.cashierId),
cashierName: p.cashierName || `Cashier ${p.cashierId}`,
totalAmount: Number(p.totalAmount || 0)
}))
.sort((a: any, b: any) => b.totalAmount - a.totalAmount);
this.drawersTotal = this.cashierPositions.length
? this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0)
: null;
},
error: () => {
// Nothing to count against, so the variance line stays blank rather than reading zero — a
// variance of zero is a claim that somebody counted and it agreed.
this.cashierPositions = [];
this.drawersTotal = null;
}
});
// Vault stock, so a branch-wide count is of the whole branch and not just the counters.
this.service.vaultPosition(undefined, this.date).subscribe({
next: (res: any) => {
const list = Array.isArray(res) ? res : res ? [res] : [];
this.vaultTotal = list.reduce((s: number, v: any) => s + Number(v?.totalAmount || 0), 0);
},
error: () => {
this.vaultTotal = null;
}
});
}
// ── Section 1 ──
/**
* Only the entries that actually moved money.
*
* A pending request is listed on the day book so nobody loses sight of it, but it has taken no
* cash — counting it into an EOD summary would make the sheet disagree with the drawer.
*/
/**
* Last night's batch: posted against yesterday, surfacing on this report.
*
* A voucher's timestamp is the minute the job ran, while the transaction and the journal entry
* behind it carry the business date. So the interest run that fires just after midnight stamps
* itself with today although the ledger says yesterday — and yesterday was counted, reported and
* signed off hours before. Re-opening that day would invalidate a signed sheet, so the work is
* listed here instead, under its own heading, saying which day it belongs to. It is excluded from
* every total on this page: it is not today's money.
*/
get priorDayRows(): any[] {
return this.rows.filter((r) => r.section === 'SYSTEM_PRIOR');
}
get priorDayCredit(): number {
return this.priorDayRows.reduce((s, r) => s + (r.side === 'CR' ? Number(r.amount || 0) : 0), 0);
}
get priorDayDebit(): number {
return this.priorDayRows.reduce((s, r) => s + (r.side === 'DR' ? Number(r.amount || 0) : 0), 0);
}
/** The business date last night's batch worked on, for the heading. */
get priorDayDate(): string | null {
const withDate = this.priorDayRows.find((r) => r.effectiveDate);
return withDate ? withDate.effectiveDate : null;
}
get approvedRows(): any[] {
return this.rows.filter(
(r) =>
r.section !== 'SYSTEM_PRIOR' &&
(r.status === 'APPROVED' || r.status === 'COMPLETED' || r.status === 'WITHDRAWN')
);
}
/**
* The day split the way this system actually records it: where the entry came from.
*
* Not split by payment mode, which is the obvious shape for a retail till but the wrong one here.
* A payment type is only recorded against a denomination breakdown, and a breakdown is only
* written for physical cash — so a UPI or transfer deposit carries no payment type at all, and a
* mode-wise table would file every non-cash entry under "not specified" while claiming to be a
* breakdown by mode. The day book's own sections are recorded on every row, so this agrees with
* the day book for the same date instead of quietly disagreeing with it.
*/
get sectionLines(): SummaryLine[] {
const order = [
'CASHIER',
'CLERK',
'SYSTEM'
];
const labels: Record<string, string> = {
CASHIER: 'Cash counter',
CLERK: 'Clerk desk',
SYSTEM: 'System — interest, charges, journals'
};
const map = new Map<string, SummaryLine>();
for (const r of this.approvedRows) {
const key = order.includes(r.section) ? r.section : 'SYSTEM';
let line = map.get(key);
if (!line) {
line = { label: labels[key] || key, credit: 0, debit: 0, count: 0 };
map.set(key, line);
}
this.addTo(line, r);
}
return order.filter((k) => map.has(k)).map((k) => map.get(k)!);
}
/**
* What kind of entry it was — deposit, withdrawal, interest posting, charge.
*
* The sections above say where the day's work came from; this says what the work was, which is
* the question somebody checking a figure against a register actually asks.
*/
get kindLines(): SummaryLine[] {
const map = new Map<string, SummaryLine>();
for (const r of this.approvedRows) {
const key = (r.kind || 'OTHER').toString();
let line = map.get(key);
if (!line) {
line = { label: this.titleCase(key), credit: 0, debit: 0, count: 0 };
map.set(key, line);
}
this.addTo(line, r);
}
return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit));
}
/** Adds one day-book row onto a summary line, on the side the row belongs to. */
private addTo(line: SummaryLine, row: any): void {
const amt = Number(row.amount || 0);
if (row.side === 'CR') {
line.credit += amt;
} else {
line.debit += amt;
}
line.count += 1;
}
/** DD CASH, INTEREST_POSTING and the like, as a person would read them. */
kindLabel(raw: string | null | undefined): string {
if (!raw) {
return '—';
}
return raw
.toString()
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase());
}
private titleCase(raw: string): string {
return this.kindLabel(raw);
}
get txnCount(): number {
return this.approvedRows.length;
}
/** More than one drawer in view means a supervisor is reading, not a cashier. */
get supervising(): boolean {
return this.cashierPositions.length > 1 || (!this.scopedToCashier && this.cashierPositions.length > 0);
}
get cashierPositionTotal(): number {
return this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0);
}
/**
* Each person who handled the day's work, with their own credit, debit and net.
*
* The clerk signing the day off is verifying other people's work, so the sheet has to say whose.
* Read off the day book's own handledBy, which every register stamps.
*/
get byUser(): SummaryLine[] {
const map = new Map<string, SummaryLine>();
for (const r of this.approvedRows) {
const key = (r.handledBy || 'Unattributed').toString();
let line = map.get(key);
if (!line) {
line = { label: key, credit: 0, debit: 0, count: 0 };
map.set(key, line);
}
this.addTo(line, r);
}
return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit));
}
get summaryCreditTotal(): number {
return this.sectionLines.reduce((s, l) => s + l.credit, 0);
}
get summaryDebitTotal(): number {
return this.sectionLines.reduce((s, l) => s + l.debit, 0);
}
// ── Section 2 ──
/** Everything that came into the drawer today, over the counter and from another cashier. */
get totalReceived(): number {
return this.cashDeposits + this.interCashierIn;
}
/** Everything that left it: paid to members, handed to the vault, passed to another cashier. */
get totalPaidOut(): number {
return this.cashWithdrawals + this.settledOut + this.interCashierOut;
}
/** What should be on the desk: the float, plus what came in, less what went out. */
get expectedClosing(): number {
return this.openingFloat + this.totalReceived - this.totalPaidOut;
}
/**
* Whether an expected-against-held comparison can honestly be made.
*
* Only for a single cashier, and only when the server sent that cashier's day legs. Both halves
* then describe the same thing: today's legs, against a drawer that carries nothing forward
* because a cashier draws in the morning and hands back at close.
*
* Never branch-wide. The branch register's expected cash is *today's* arithmetic — yesterday's
* closing, plus today's deposits, less today's withdrawals — while the vault and drawer positions
* are running totals accumulated since the system went live. Subtracting one from the other
* compares an afternoon against a lifetime: it reported ₹2,11,932 extra on a day when ₹100 came
* in and ₹100 went out. There is no branch-wide running total to compare against, so the sheet
* shows the branch its movement and each drawer's position, and states no difference at all
* rather than a confident wrong one.
*/
get canReconcile(): boolean {
return this.scopedToCashier && this.hasDayLegs;
}
/** The cash the system believes this cashier is holding. */
get actualCash(): number | null {
return this.scopedToCashier ? this.drawersTotal : null;
}
/** What today's legs say should be in the drawer. */
get expectedCash(): number | null {
return this.canReconcile ? this.expectedClosing : null;
}
/** Held less expected, for one cashier's own day. Null wherever that comparison is not honest. */
get variance(): number | null {
const expected = this.expectedCash;
if (expected === null || this.actualCash === null) {
return null;
}
return this.actualCash - expected;
}
/** Vaults and drawers together — what the branch is holding, per the system. */
get branchHeldTotal(): number | null {
if (this.drawersTotal === null && this.vaultTotal === null) {
return null;
}
return Number(this.drawersTotal ?? 0) + Number(this.vaultTotal ?? 0);
}
/** Today's net cash movement across the branch: what came in less what went out. */
get branchNetMovement(): number {
return Number(this.branchDeposits ?? 0) - Number(this.branchWithdrawals ?? 0);
}
/** True only when a count exists and it agrees to the paisa. */
get varianceOk(): boolean {
return this.variance !== null && Math.abs(this.variance) < 0.005;
}
get varianceClass(): string {
if (this.variance === null) {
return '';
}
return this.varianceOk ? 'eod-ok' : 'eod-bad';
}
// ── Section 3 ──
/** Entries still waiting on somebody, which is what a day cannot be closed over. */
get pendingRows(): any[] {
return this.rows.filter((r) => r.status === 'PENDING' || r.status === 'SUBMITTED');
}
/** Cash entries whose paper slip nobody has checked against the screen. */
get unverifiedRows(): any[] {
return this.approvedRows.filter((r) => !r.verifyStatus);
}
/** A slip that was checked and did not agree with what was keyed in. */
get mismatchRows(): any[] {
return this.rows.filter((r) => r.verifyStatus === 'MISMATCH' || r.verifyStatus === 'DISPUTED');
}
/**
* Whether the day is ready to close.
*
* Nothing pending, no slip disagreeing, and — where a cash comparison was actually made — the
* cash agreeing. A branch-wide reading makes no comparison, so it is not held to one: requiring
* `varianceOk` there meant the sheet could never say a day was clear however clean it was.
*/
get allClear(): boolean {
if (this.pendingRows.length > 0 || this.mismatchRows.length > 0) {
return false;
}
return this.variance === null || this.varianceOk;
}
reload(): void {
this.load();
}
print(): void {
window.print();
}
}
@@ -11,29 +11,41 @@
<div>
<div class="pg-eyebrow">CASHIER · INTER-CASHIER TRANSFER</div>
<h1 class="pg-title">Inter-Cashier Transfer</h1>
<p class="pg-sub">Hand over denomination-wise cash from your drawer to another cashier at your counter.</p>
<p class="pg-sub">Move denomination-wise cash between two cashiers within the same teller.</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</header>
@if (ownCashierMissing) {
<div class="ict-alert">
<mat-icon>person_off</mat-icon>
Your login is not linked to a cashier. An inter-cashier transfer is made by the cashier handing the cash over, so
log in as that cashier.
</div>
}
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellerName || '—'" readonly />
</mat-form-field>
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
}
<!-- The sender is always the cashier who is logged in; nothing to pick. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (You)</mat-label>
<input matInput type="text" [value]="fromParty || '—'" readonly />
<mat-label>From (Cashier)</mat-label>
<mat-select [value]="fromCashierId" (selectionChange)="onFromSelect($event.value)" [disabled]="!selectedTellerId">
@for (c of availableFrom; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) {
<mat-option [value]="null" disabled>No cashiers</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -42,7 +54,7 @@
@for (c of availableTo; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (fromCashierId && !loadingCashiers && availableTo.length === 0) {
@if (fromCashierId && availableTo.length === 0) {
<mat-option [value]="null" disabled>No other cashiers</mat-option>
}
</mat-select>
@@ -50,7 +62,7 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label>
<input matInput type="date" [value]="transferDate" (change)="onDateChange($any($event.target).value)" />
<input matInput type="date" [value]="transferDate" (change)="transferDate = $any($event.target).value" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
@@ -59,102 +71,26 @@
</mat-form-field>
</div>
<!-- Both sides of the transfer: who they are, whether the vault has allocated them cash today,
and what they hold now. -->
@if (fromCashierId || toCashierId) {
<div class="ict-parties">
<ng-container
*ngTemplateOutlet="
partyCard;
context: { title: 'From (Sending Cashier)', s: fromSummary, loading: loadingFrom, picked: !!fromCashierId }
"
></ng-container>
<mat-icon class="ict-parties__arrow">arrow_forward</mat-icon>
<ng-container
*ngTemplateOutlet="
partyCard;
context: { title: 'To (Receiving Cashier)', s: toSummary, loading: loadingTo, picked: !!toCashierId }
"
></ng-container>
</div>
}
<ng-template #partyCard let-title="title" let-s="s" let-loading="loading" let-picked="picked">
<div class="ict-party" [class.ict-party--blocked]="s && !s.cashAllocated">
<div class="ict-party__title">{{ title }}</div>
@if (!picked) {
<div class="ict-party__empty">Select a cashier</div>
} @else if (loading) {
<div class="ict-party__empty">Loading…</div>
} @else if (!s) {
<div class="ict-party__empty">Could not load this cashier's details.</div>
} @else {
<div class="ict-party__name">{{ s.cashierName || '—' }}</div>
<div class="ict-party__meta">
<span><mat-icon>point_of_sale</mat-icon> {{ s.tellerName || '—' }}</span>
<span><mat-icon>account_balance</mat-icon> {{ s.officeName || '—' }}</span>
</div>
<div class="ict-party__figures">
<div>
<div class="ict-party__label">Allocated today</div>
<div class="ict-party__value">₹{{ s.allocatedAmount | number: '1.2-2' }}</div>
</div>
<div>
<div class="ict-party__label">Cash in hand</div>
<div class="ict-party__value">₹{{ s.totalAmount | number: '1.2-2' }}</div>
</div>
</div>
@if (!s.cashAllocated) {
<div class="ict-party__warn">
<mat-icon>block</mat-icon> No cash allocated from the vault for this date — inter-cashier transfer is not
allowed.
</div>
}
}
</div>
</ng-template>
@if (fromSummary && toSummary && !bothAllocated) {
<div class="ict-alert">
<mat-icon>block</mat-icon>
Both cashiers must first be allocated cash from the vault (Cash Transfer) for {{ transferDate }} before an
inter-cashier transfer.
</div>
}
@if (anyExceedsHeld) {
<div class="ict-alert">
<mat-icon>error_outline</mat-icon>
The sending cashier does not hold enough of the highlighted denominations.
</div>
}
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">From holds</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
<th class="num">To holds</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="5">Bank Notes</td>
<td colspan="3">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr [class.ict-row--over]="exceedsHeld(d)">
<tr>
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
></ng-container>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -163,28 +99,18 @@
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
<td class="num">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
></ng-container>
</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="5">Coins</td>
<td colspan="3">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr [class.ict-row--over]="exceedsHeld(d)">
<tr>
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
></ng-container>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -193,34 +119,17 @@
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
<td class="num">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
></ng-container>
</td>
</tr>
}
@if (denoms.length === 0) {
<tr>
<td colspan="5" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- A held count, with what it becomes once this transfer is recorded. -->
<ng-template #heldCell let-n="n" let-delta="delta">
@if (n === null) {
—
} @else {
{{ n }}
@if (delta !== 0) {
<span class="ict-after">→ {{ n + delta }}</span>
}
}
</ng-template>
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
@@ -1,150 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.ict-parties {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: stretch;
gap: 12px;
margin-bottom: 12px;
&__arrow {
align-self: center;
color: #64748b;
}
@media (width <= 760px) {
grid-template-columns: 1fr;
&__arrow {
justify-self: center;
transform: rotate(90deg);
}
}
}
.ict-party {
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
&--blocked {
background: #fef2f2;
border-color: #fecaca;
}
&__title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&__empty {
margin-top: 8px;
font-size: 13px;
color: #94a3b8;
}
&__name {
margin-top: 4px;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 4px;
font-size: 12px;
color: #475569;
span {
display: inline-flex;
align-items: center;
gap: 4px;
}
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
&__figures {
display: flex;
gap: 24px;
margin-top: 10px;
}
&__label {
font-size: 11px;
color: #64748b;
}
&__value {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
&__warn {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
font-size: 12px;
font-weight: 600;
color: #991b1b;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
.ict-alert {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 8px 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 12px;
color: #991b1b;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.ict-row--over {
background: #fef2f2;
}
.ict-held--over {
color: #b91c1c;
font-weight: 700;
}
.ict-after {
margin-left: 4px;
font-size: 11px;
color: #64748b;
}
@@ -17,7 +17,6 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -29,27 +28,12 @@ interface Cashier {
id: number;
label: string;
}
/** One side of the transfer, from GET /cashintercashier/cashier-summary. */
interface CashierSummary {
cashierId: number;
cashierName: string;
tellerName: string;
officeName: string;
cashAllocated: boolean;
allocatedAmount: number;
totalAmount: number;
/** Count held per denomination id. */
held: Record<number, number>;
}
/** Move cash denomination-wise between two cashiers under the same teller. */
@Component({
selector: 'mifosx-inter-cashier-transfer',
templateUrl: './inter-cashier-transfer.component.html',
styleUrls: [
'../cashier-shared.scss',
'./inter-cashier-transfer.component.scss'
],
styleUrls: ['../cashier-shared.scss'],
standalone: true,
imports: [
CommonModule,
@@ -72,12 +56,7 @@ export class InterCashierTransferComponent implements OnInit {
note = '';
saving = false;
/**
* The sending cashier is always the one logged in: cash is handed over from their own drawer, so there is
* no one else to pick on that side. Only the receiving cashier is chosen.
*/
ownCashierMissing = false;
tellerName = '';
tellers: { id: number; name: string }[] = [];
allCashiers: Cashier[] = [];
selectedTellerId: number | null = null;
fromCashierId: number | null = null;
@@ -86,25 +65,21 @@ export class InterCashierTransferComponent implements OnInit {
toParty = '';
loadingCashiers = false;
/** Details and denomination-wise cash of each side, so both are seen before the transfer is recorded. */
fromSummary: CashierSummary | null = null;
toSummary: CashierSummary | null = null;
loadingFrom = false;
loadingTo = false;
get denoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
/** Everyone under the same teller except the sender. */
get availableFrom(): Cashier[] {
return this.allCashiers;
}
get availableTo(): Cashier[] {
return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
}
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -122,20 +97,25 @@ export class InterCashierTransferComponent implements OnInit {
this.coins = [];
}
});
// The sender is the cashier behind this login; their counter then gives the receivers to pick from.
this.service.getCurrentCashierInfo().subscribe((info) => {
if (!info) {
this.ownCashierMissing = true;
return;
}
this.fromCashierId = info.cashierId;
this.fromParty = info.cashierName;
this.loadSummary('from');
});
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
}
/** The other cashiers at this counter, one of whom receives the cash. */
private loadReceivers(tellerId: number): void {
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.fromCashierId = null;
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.allCashiers = [];
if (!tellerId) return;
this.loadingCashiers = true;
this.service
.getCashiersForTeller(tellerId)
@@ -146,90 +126,20 @@ export class InterCashierTransferComponent implements OnInit {
});
}
onFromSelect(cashierId: number): void {
this.fromCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.fromParty = c ? c.label : '';
if (this.toCashierId === cashierId) {
this.toCashierId = null;
this.toParty = '';
}
}
onToSelect(cashierId: number): void {
this.toCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.toParty = c ? c.label : '';
this.loadSummary('to');
}
onDateChange(value: string): void {
this.transferDate = value;
this.loadSummary('from');
this.loadSummary('to');
}
/** Fetch one side's details, vault allocation and denomination-wise cash for the transfer date. */
private loadSummary(side: 'from' | 'to'): void {
const cashierId = side === 'from' ? this.fromCashierId : this.toCashierId;
if (side === 'from') {
this.fromSummary = null;
} else {
this.toSummary = null;
}
if (!cashierId || !this.transferDate) return;
this.setLoading(side, true);
this.service
.getInterCashierCashierSummary(cashierId, this.transferDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
this.setLoading(side, false);
// A late answer for a cashier no longer selected on that side is dropped.
const current = side === 'from' ? this.fromCashierId : this.toCashierId;
if (!r || current !== cashierId) return;
// The sender's summary also tells which counter they are at, hence who they can hand cash to.
if (side === 'from' && r.tellerId && this.selectedTellerId !== Number(r.tellerId)) {
this.selectedTellerId = Number(r.tellerId);
this.tellerName = r.tellerName || '';
this.loadReceivers(this.selectedTellerId);
}
const held: Record<number, number> = {};
for (const d of r.denominations || []) {
held[d.denominationId] = (held[d.denominationId] || 0) + Number(d.count || 0);
}
const summary: CashierSummary = {
cashierId: r.cashierId,
cashierName: r.cashierName,
tellerName: r.tellerName,
officeName: r.officeName,
cashAllocated: !!r.cashAllocated,
allocatedAmount: Number(r.allocatedAmount || 0),
totalAmount: Number(r.totalAmount || 0),
held
};
if (side === 'from') {
this.fromSummary = summary;
} else {
this.toSummary = summary;
}
});
}
private setLoading(side: 'from' | 'to', loading: boolean): void {
if (side === 'from') {
this.loadingFrom = loading;
} else {
this.loadingTo = loading;
}
}
/** Count of the denomination the cashier holds, or null while their figures are not loaded. */
heldBy(summary: CashierSummary | null, d: Denom): number | null {
return summary ? summary.held[d.id] || 0 : null;
}
/** The sending cashier is asked to hand on more of this denomination than they hold. */
exceedsHeld(d: Denom): boolean {
return !!this.fromSummary && this.countFor(d) > (this.fromSummary.held[d.id] || 0);
}
get anyExceedsHeld(): boolean {
return this.denoms.some((d) => this.exceedsHeld(d));
}
/** Both sides must have been allocated cash from the vault for the day before they can transfer. */
get bothAllocated(): boolean {
return !!this.fromSummary?.cashAllocated && !!this.toSummary?.cashAllocated;
}
countFor(d: Denom): number {
@@ -249,14 +159,7 @@ export class InterCashierTransferComponent implements OnInit {
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
}
get valid(): boolean {
return (
this.total > 0 &&
!!this.selectedTellerId &&
!!this.fromCashierId &&
!!this.toCashierId &&
this.bothAllocated &&
!this.anyExceedsHeld
);
return this.total > 0 && !!this.selectedTellerId && !!this.fromCashierId && !!this.toCashierId;
}
clear(): void {
@@ -283,12 +186,10 @@ export class InterCashierTransferComponent implements OnInit {
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
this.clear();
this.note = '';
// The sender stays the logged-in cashier; the receiver is picked afresh, and the sender's holdings
// are read again now that part of them has gone.
this.fromCashierId = null;
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.toSummary = null;
this.loadSummary('from');
this.saving = false;
},
error: () => {
@@ -1,229 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">MY WORK</div>
<h1 class="pg-title">My Vouchers</h1>
<p class="pg-sub">
Everything you entered and everything you approved — receipts, payments and journals in one list.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Period</span>
<select [(ngModel)]="period" (change)="load()">
<option value="DAY">Day</option>
<option value="MONTH">Month</option>
</select>
</label>
<label class="pg-field">
<span>{{ period === 'DAY' ? 'Date' : 'Month of' }}</span>
<input type="date" [(ngModel)]="anchor" (change)="load()" />
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Run
</button>
</div>
</header>
@if (error) {
<div class="pg-alert pg-alert--error">{{ error }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (report && !loading) {
<div class="mw-who">
<strong>{{ report.displayName || report.username }}</strong>
<span class="mw-who__sep">·</span>
{{ report.from | date: 'dd MMM yyyy' }}
@if (report.from !== report.to) {
<span> to {{ report.to | date: 'dd MMM yyyy' }}</span>
}
</div>
<!-- The day at a glance, read off the same rows the table below shows: narrow "My part" to
Entered or Approved and these five follow, instead of staying pinned to the combined
day total regardless of the filter. -->
<div class="mw-tiles">
<div class="mw-tile">
<div class="mw-tile__label">Received</div>
<div class="mw-tile__value mw-tile__value--cr">₹{{ receivedTotal | number: '1.2-2' }}</div>
</div>
<div class="mw-tile">
<div class="mw-tile__label">Payments</div>
<div class="mw-tile__value mw-tile__value--dr">₹{{ paymentTotal | number: '1.2-2' }}</div>
</div>
<div class="mw-tile">
<div class="mw-tile__label">Journal</div>
<div class="mw-tile__value">₹{{ journalTotal | number: '1.2-2' }}</div>
</div>
<div class="mw-tile">
<div class="mw-tile__label">Entered</div>
<div class="mw-tile__value">{{ raisedCount }}</div>
</div>
<div class="mw-tile">
<div class="mw-tile__label">Approved</div>
<div class="mw-tile__value">{{ verifiedCount }}</div>
</div>
</div>
@if (report.count > 0) {
<div class="mw-filters">
<label class="pg-field">
<span>Side</span>
<select [(ngModel)]="sideFilter">
<option value="">All</option>
<option value="CR">Received</option>
<option value="DR">Payment</option>
<option value="JV">Journal</option>
</select>
</label>
<label class="pg-field">
<span>My part</span>
<select [(ngModel)]="roleFilter">
<option value="">All</option>
<option value="RAISED">Entered</option>
<option value="VERIFIED">Approved</option>
<option value="RAISED_AND_VERIFIED">Entered &amp; approved</option>
</select>
</label>
@if (isFiltered) {
<button type="button" class="pg-btn" (click)="clearFilters()"><mat-icon>clear</mat-icon> Clear</button>
}
</div>
}
@if (rows.length === 0) {
<div class="pg-empty">
@if (report.count === 0) {
Nothing recorded against you for this period.
} @else {
No vouchers match these filters — {{ report.count }} hidden.
}
</div>
} @else {
<div class="mw-count">
Showing {{ rows.length }}
@if (isFiltered) {
<span>of {{ report.count }}</span>
}
{{ rows.length === 1 ? 'voucher' : 'vouchers' }}
<span class="mw-count__total">Total ₹{{ filteredTotal | number: '1.2-2' }}</span>
</div>
<!-- One table per part played, not one long list. Entering a request and passing somebody
else's are different jobs answerable to different people, and each carries its own total
so a maker's tally and a checker's can both be read off the page. -->
@for (g of groups; track g.key) {
<div class="pg-card mw-group" [class]="'mw-group--' + g.key">
<div class="mw-group__head">
<strong>{{ g.label }}</strong>
<span class="mw-group__hint">{{ g.hint }}</span>
<span class="mw-group__count">
{{ g.rows.length }} {{ g.rows.length === 1 ? 'voucher' : 'vouchers' }}
<span class="mw-group__total">₹{{ g.total | number: '1.2-2' }}</span>
</span>
</div>
<div class="pg-table-wrap">
<table class="pg-table mw-table">
<thead>
<tr>
<th>Voucher</th>
<th>Side</th>
<th>Type</th>
<th>Debit</th>
<th>Credit</th>
<th class="num">Amount</th>
<th>My part</th>
<th>Status</th>
<th>Correction</th>
<th>Time</th>
</tr>
</thead>
<tbody>
@for (r of g.rows; track r.voucherNo) {
<!-- The left edge repeats the section's own colour, so a row still says which part it
is when the heading has scrolled away. Clicking anywhere on the row opens the full
slip -- every line, not just the first debit and first credit this table has room
to show. -->
<tr
[class]="'mw-row mw-row--' + r.myRole"
class="mw-row--clickable"
(click)="openDetail(r.voucherNo)"
[attr.aria-busy]="loadingDetailFor === r.voucherNo"
>
<td class="mw-vno">
{{ r.voucherNo }}
@if (loadingDetailFor === r.voucherNo) {
<mat-icon class="mw-vno__spinner" inline>hourglass_empty</mat-icon>
}
</td>
<td>
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
</td>
<td class="mw-type">{{ r.voucherType }}</td>
<td>
{{ r.debitParty || '—' }}
@if (r.debitAccount) {
<span class="mw-acct">{{ r.debitAccount }}</span>
}
</td>
<td>
{{ r.creditParty || '—' }}
@if (r.creditAccount) {
<span class="mw-acct">{{ r.creditAccount }}</span>
}
@if (r.extraLines > 0) {
<span class="mw-more" title="Click the row to see every line">+{{ r.extraLines }} more</span>
}
</td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
<td>
<span class="mw-part" [class]="'mw-part--' + r.myRole">
<mat-icon class="mw-part__icon">{{ roleIcon(r.myRole) }}</mat-icon>
{{ roleLabel(r.myRole) }}
</span>
<!-- Whose entry it was, when it was not this user's — that is the whole point of
an approval row appearing here at all. -->
@if (r.myRole === 'VERIFIED' && r.raisedBy) {
<span class="mw-acct">by {{ r.raisedBy }}</span>
}
</td>
<td>
<span class="mw-status" [class]="'mw-status--' + statusClass(r.verificationStatus)">
{{ statusLabel(r.verificationStatus) }}
</span>
</td>
<td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<mifosx-voucher-status
[status]="r.voucherStatus"
[revokedBy]="r.revokedByVoucherNo"
[replaces]="r.replacesVoucherNo"
[note]="r.correctionNote"
></mifosx-voucher-status>
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
<span class="mw-acct">—</span>
}
</td>
<td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
}
</div>
@@ -1,309 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* Who and when, above the day's figures. */
.mw-who {
font-size: 13px;
color: #475569;
margin: 0 0 12px;
strong {
color: #0f172a;
}
&__sep {
margin: 0 6px;
color: #cbd5e1;
}
}
/* The day at a glance: the two sides a branch balances, then what the user's part was. */
.mw-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.mw-tile {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px 12px;
&__label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-bottom: 4px;
}
&__value {
font-size: 18px;
font-weight: 600;
color: #0f172a;
/* Received green, paid-out red — the convention a passbook already uses, so the two totals
are told apart without reading the labels. */
&--cr {
color: #15803d;
}
&--dr {
color: #b91c1c;
}
}
}
.mw-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
margin-bottom: 12px;
}
.mw-count {
font-size: 12px;
color: #64748b;
margin-bottom: 8px;
&__total {
float: right;
font-weight: 600;
color: #0f172a;
}
}
.mw-table {
font-size: 13px;
}
/* Money cells use the shared `.num` from cashier-shared.scss, which right-aligns and
tabular-figures the heading as well as the cell. A local class only reached the cells, leaving
every heading left-aligned over right-aligned figures. */
.mw-vno {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
white-space: nowrap;
}
.mw-type {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #64748b;
white-space: nowrap;
}
.mw-time {
white-space: nowrap;
color: #64748b;
}
/* The account number or the maker's name, under the party it belongs to. */
.mw-acct {
display: block;
font-size: 11px;
color: #94a3b8;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.mw-more {
display: inline-block;
margin-left: 6px;
font-size: 10px;
padding: 1px 5px;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
}
.mw-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: 10px;
white-space: nowrap;
&--CR {
background: #dcfce7;
color: #15803d;
}
&--DR {
background: #fee2e2;
color: #b91c1c;
}
&--JV {
background: #e0e7ff;
color: #4338ca;
}
}
/* Whose work the row is, marked on the row itself.
A coloured left edge and a faint tint, rather than a different text colour: the eye finds the
edge while scanning a column of figures, which is how a day's list is actually read. Kept faint
so the amounts stay the thing being read. */
.mw-row {
border-left: 3px solid transparent;
&--RAISED {
border-left-color: #86efac;
}
/* Somebody else's entry that this person passed. The row a reviewer came looking for, so it
carries the darker of the two greens. */
&--VERIFIED {
border-left-color: #15803d;
background: #f0fdf4;
}
/* Entered and approved by the same person. Legitimate in a small branch, but the one combination
an auditor stops on, so it is the only row given a warm tint. */
&--RAISED_AND_VERIFIED {
border-left-color: #d97706;
background: #fffbf3;
}
}
.mw-row--clickable {
cursor: pointer;
&:hover {
background: #f8fafc;
}
}
.mw-vno__spinner {
margin-left: 4px;
font-size: 14px;
vertical-align: middle;
color: #94a3b8;
}
/* One section per part played. The heading carries the section's own count and total, so a maker's
tally and a checker's are each readable without adding rows up. */
.mw-group {
margin-bottom: 16px;
/* Two shades of one colour rather than two colours: entering and approving are stages of the
same piece of work, and the darker edge reads as the later one. */
border-left: 3px solid #86efac;
/* Same shades the row edges use, so a section and its rows read as one thing. */
&--VERIFIED {
border-left-color: #15803d;
}
&--RAISED_AND_VERIFIED {
border-left-color: #d97706;
}
&__head {
display: flex;
align-items: baseline;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
strong {
font-size: 13px;
color: #0f172a;
}
}
&__hint {
font-size: 11px;
color: #64748b;
}
&__count {
margin-left: auto;
font-size: 11px;
color: #64748b;
white-space: nowrap;
}
&__total {
margin-left: 8px;
font-weight: 700;
color: #0f172a;
}
}
/* Where the voucher stands. Three states, because there are three things a reader does: nothing
(done), chase it (pending), or look into it (bad). */
.mw-status {
font-size: 11px;
font-weight: 600;
white-space: nowrap;
/* Approved, posted — settled but nothing handed over. Lighter than 'paid' but still dark enough
to read as body text; a pale green on white fails at this size. */
&--done {
color: #16a34a;
}
/* Cash actually gone. Darker and filled, the same way the day book marks it. */
&--paid {
background: #15803d;
color: #fff;
padding: 2px 8px;
border-radius: 999px;
}
&--pending {
color: #b45309;
}
&--bad {
color: #b91c1c;
}
}
/* The part played, as a small pill with its own glyph. */
.mw-part {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
padding: 2px 8px 2px 6px;
border-radius: 10px;
white-space: nowrap;
&__icon {
font-size: 13px;
width: 13px;
height: 13px;
line-height: 13px;
}
&--RAISED {
background: #dcfce7;
color: #166534;
}
&--VERIFIED {
background: #15803d;
color: #fff;
}
&--RAISED_AND_VERIFIED {
background: #fef3c7;
color: #b45309;
}
}
@@ -1,371 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
/** Which register the voucher was numbered in: receipt, payment, or journal. */
export type VoucherSide = 'CR' | 'DR' | 'JV';
interface MyWorkRow {
voucherNo: string;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
side: VoucherSide | null;
voucherType: string | null;
amount: number | null;
createdAt: string | null;
raisedBy: string | null;
branchName: string | null;
verificationStatus: string | null;
verifiedByName: string | null;
verifiedAt: string | null;
debitParty: string | null;
debitAccount: string | null;
creditParty: string | null;
creditAccount: string | null;
/** Sides beyond the one debit and one credit shown — a deposit that also took a charge. */
extraLines: number;
myRole: MyRole;
}
interface MyWorkReport {
from: string;
to: string;
username: string;
displayName: string;
vouchers: MyWorkRow[];
count: number;
raisedCount: number;
verifiedCount: number;
receivedTotal: number;
paymentTotal: number;
journalTotal: number;
}
/**
* One person's day at the branch: what they entered and what they approved, in one list.
*
* The Received and Payment registers answer "what moved today" across everyone. This answers the
* question each member of staff asks at a close — "what did I do today" — which those two cannot,
* because a checker's approval raises no voucher of its own and leaves its only trace on the
* verification row.
*/
@Component({
selector: 'mifosx-my-work',
templateUrl: './my-work.component.html',
styleUrls: [
'../cashier-shared.scss',
'./my-work.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
VoucherStatusComponent
]
})
export class MyWorkComponent implements OnInit {
private service = inject(CashierService);
private dialog = inject(MatDialog);
/** Which voucher's detail is currently being fetched, so the row that was clicked can show its
* own spinner instead of a page-wide one for what is a small, single-row lookup. */
loadingDetailFor: string | null = null;
/** Day is the working default; a month is what a close or an audit asks for. */
period: 'DAY' | 'MONTH' = 'DAY';
anchor = this.todayIso();
/**
* Narrow to one side, or to the part the user played. Both applied in the browser, and only to
* the table below — the five summary tiles are pinned to Entered work regardless (see
* enteredRows), so this can default to showing everything without muddying them.
*/
sideFilter: '' | VoucherSide = '';
roleFilter: '' | MyRole = '';
report: MyWorkReport | null = null;
loading = false;
error: string | null = null;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.error = null;
const { from, to } = this.range();
this.service
.myVoucherWork(from, to)
.pipe(
catchError((err: any) => {
this.error =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
'Your work for this period could not be loaded.';
return of(null);
})
)
.subscribe((res: MyWorkReport | null) => {
this.loading = false;
this.report = res;
});
}
/**
* The rows after the two on-screen filters.
*
* Filtered here rather than at the server: the day's own work is a small list, and narrowing it
* without a round trip is what makes the filters feel like part of reading the page.
*/
get rows(): MyWorkRow[] {
const all = this.report?.vouchers ?? [];
return all.filter(
(r) => (!this.sideFilter || r.side === this.sideFilter) && (!this.roleFilter || r.myRole === this.roleFilter)
);
}
/**
* The rows split by the part the user played, each group with its own total.
*
* Entering a request and passing somebody else's are different jobs answerable to different
* people, and a branch reads them separately: "what did I put through" is a maker's own tally,
* "what did I let through" is a checker's. One list mixed them, so neither total could be read
* off the page — a manager who entered one voucher and approved twenty saw twenty-one rows and
* had to sort them by eye.
*
* An empty group is dropped rather than shown as a heading over nothing, so a clerk who never
* approves anything sees one section and not a permanently empty second one.
*/
get groups(): { key: MyRole; label: string; hint: string; rows: MyWorkRow[]; total: number }[] {
const order: { key: MyRole; label: string; hint: string }[] = [
{ key: 'RAISED', label: 'Entered by me', hint: 'Requests and postings you put through' },
{ key: 'VERIFIED', label: 'Approved by me', hint: "Other people's work you passed" },
{
key: 'RAISED_AND_VERIFIED',
label: 'Entered & approved by me',
hint: 'Both roles on the same voucher — kept separate for the record'
}
];
return order
.map((g) => {
const rows = this.rows.filter((r) => r.myRole === g.key);
return { ...g, rows, total: rows.reduce((sum, r) => sum + Number(r.amount ?? 0), 0) };
})
.filter((g) => g.rows.length > 0);
}
/** True when a filter is hiding part of what was loaded, so the count can say so. */
get isFiltered(): boolean {
return !!this.sideFilter || !!this.roleFilter;
}
clearFilters(): void {
this.sideFilter = '';
this.roleFilter = '';
}
/** The total of the rows on screen, which is not the day's total once a filter is on. */
get filteredTotal(): number {
return this.rows.reduce((sum, r) => sum + Number(r.amount ?? 0), 0);
}
/**
* What this person themselves put through today — never narrowed by the Side or My part
* filters, which exist only to browse the table below.
*
* The five summary tiles are pinned to this set on purpose. They answer one question, "what did
* I personally do today", and that answer cannot depend on which slice of the list a person
* happens to have the table filtered to at the moment — a Received figure that changed whenever
* someone picked "Approved" to go look at what they'd passed would not be their own total any
* more, it would be whatever the table was showing.
*/
get enteredRows(): MyWorkRow[] {
const all = this.report?.vouchers ?? [];
return all.filter((r) => r.myRole === 'RAISED' || r.myRole === 'RAISED_AND_VERIFIED');
}
get receivedTotal(): number {
return this.enteredRows.filter((r) => r.side === 'CR').reduce((sum, r) => sum + Number(r.amount ?? 0), 0);
}
get paymentTotal(): number {
return this.enteredRows.filter((r) => r.side === 'DR').reduce((sum, r) => sum + Number(r.amount ?? 0), 0);
}
get journalTotal(): number {
return this.enteredRows
.filter((r) => r.side !== 'CR' && r.side !== 'DR')
.reduce((sum, r) => sum + Number(r.amount ?? 0), 0);
}
get raisedCount(): number {
return this.enteredRows.length;
}
get verifiedCount(): number {
const all = this.report?.vouchers ?? [];
return all.filter((r) => r.myRole === 'VERIFIED' || r.myRole === 'RAISED_AND_VERIFIED').length;
}
sideLabel(side: VoucherSide | null): string {
if (side === 'CR') {
return 'Received';
}
if (side === 'DR') {
return 'Payment';
}
return 'Journal';
}
roleLabel(role: MyRole): string {
if (role === 'RAISED') {
return 'Entered';
}
if (role === 'VERIFIED') {
return 'Approved';
}
return 'Entered & approved';
}
/**
* Where the voucher stands, in words a branch uses.
*
* A voucher with no status at all is not unknown — it is a posting that never went through a
* request: interest the nightly run credited, a charge taken from a balance. Those are done the
* moment they exist, so they read as Posted rather than as a blank a reader has to interpret.
*/
statusLabel(status: string | null): string {
const s = (status ?? '').toUpperCase();
if (!s) {
return 'Posted';
}
if (s === 'WITHDRAWN') {
return 'Paid out';
}
if (s === 'APPROVED') {
return 'Approved';
}
if (s === 'PENDING') {
return 'Pending';
}
if (s === 'REJECTED') {
return 'Rejected';
}
if (s === 'CLOSED') {
return 'Closed';
}
// OK / MISMATCH come from a physical cash verification rather than a request.
return s.charAt(0) + s.slice(1).toLowerCase();
}
/**
* Groups the statuses by what a reader does about them.
*
* 'paid' is kept apart from 'done' because approved and paid out are different facts: one is a
* promise, the other is money gone. Sharing a colour hid exactly the thing somebody opens this
* page to check.
*/
statusClass(status: string | null): string {
const s = (status ?? '').toUpperCase();
if (s === 'PENDING') {
return 'pending';
}
if (s === 'REJECTED' || s === 'MISMATCH') {
return 'bad';
}
if (s === 'WITHDRAWN' || s === 'COMPLETED' || s === 'CLOSED') {
return 'paid';
}
return 'done';
}
/**
* A glyph per role, so the part played reads before the words do.
*
* A pen for an entry the user made, a tick for one they approved, and a warning for both on the
* same voucher — which a small branch does legitimately, and an auditor should still see marked.
*/
roleIcon(role: MyRole): string {
if (role === 'RAISED') {
return 'edit';
}
if (role === 'VERIFIED') {
return 'check_circle';
}
return 'warning';
}
/** The period the anchor date names: that day, or the whole calendar month around it. */
private range(): { from: string; to: string } {
if (this.period === 'DAY') {
return { from: this.anchor, to: this.anchor };
}
const [
y,
m
] = this.anchor.split('-').map(Number);
const first = `${y}-${String(m).padStart(2, '0')}-01`;
// Day 0 of the next month is the last day of this one, which avoids a table of month lengths
// and gets February right in a leap year.
const last = new Date(y, m, 0);
const lastIso = `${last.getFullYear()}-${String(last.getMonth() + 1).padStart(2, '0')}-${String(
last.getDate()
).padStart(2, '0')}`;
return { from: first, to: lastIso };
}
/** Opens the full slip for one voucher — every debit and credit line, not just the first side of
* each a table row has room to show. */
openDetail(voucherNo: string): void {
if (this.loadingDetailFor) {
return;
}
this.loadingDetailFor = voucherNo;
this.service
.voucherDetail(voucherNo)
.pipe(
catchError(() => of(null)),
finalize(() => (this.loadingDetailFor = null))
)
.subscribe((detail: any) => {
if (!detail) {
return;
}
this.dialog.open(VoucherDetailDialogComponent, {
data: { detail },
width: '600px',
autoFocus: false
});
});
}
private todayIso(): string {
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -1,334 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
export interface VoucherDetailDialogData {
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
* only renders, so one loading state lives in the row's own click handler rather than being
* duplicated inside every dialog that could ever show a voucher. */
detail: {
voucherNo: string;
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
branchName: string | null;
createdBy: string | null;
createdAt: string | null;
totalDebit: number;
totalCredit: number;
balanced: boolean;
lines: {
lineNo: number;
entryType: 'DR' | 'CR';
partyName: string | null;
accountNo: string | null;
amount: number;
description: string | null;
}[];
/** GL entries a native platform command posted for the same underlying event, outside this
* voucher entirely — a share redemption's Dr Share Capital / Cr Share Sundry pair, posted at
* manager approval, before this voucher's own Dr Sundry / Cr Cash at Vault settlement leg even
* exists. Empty for every voucher that already is the whole transaction on its own. */
relatedLines: {
stage: string;
entryType: 'DR' | 'CR';
partyName: string | null;
accountNo: string | null;
amount: number;
description: string | null;
}[];
};
}
/** The full slip behind a summarised register row — every debit and credit line a voucher is
* made of, not just the first side of each that a table row has room for. */
@Component({
selector: 'mifosx-voucher-detail-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
VoucherStatusComponent
],
template: `
<div class="vd-shell">
<div class="vd-header">
<div class="vd-header__title">
<mat-icon class="vd-icon">receipt_long</mat-icon>
<div>
<h2>
{{ detail.voucherNo }}
<mifosx-voucher-status
[status]="detail.voucherStatus"
[revokedBy]="detail.revokedByVoucherNo"
[replaces]="detail.replacesVoucherNo"
[note]="detail.correctionNote"
></mifosx-voucher-status>
</h2>
<p>{{ detail.voucherType || 'Voucher' }} &middot; {{ detail.branchName || 'Head Office' }}</p>
</div>
</div>
<button mat-icon-button mat-dialog-close aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="vd-meta">
<span
>Raised by <strong>{{ detail.createdBy || '—' }}</strong></span
>
@if (detail.createdAt) {
<span>{{ detail.createdAt | date: 'dd MMM yyyy, h:mm a' }}</span>
}
@if (!detail.balanced) {
<span class="vd-unbalanced">
<mat-icon inline>warning</mat-icon>
Debit and credit do not balance
</span>
}
</div>
<table class="vd-table">
<thead>
<tr>
<th>Side</th>
<th>Party</th>
<th>Account No.</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (line of detail.lines; track line.lineNo) {
<tr>
<td>
<span
class="vd-side"
[class.vd-side--dr]="line.entryType === 'DR'"
[class.vd-side--cr]="line.entryType === 'CR'"
>
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
</span>
</td>
<td>{{ line.partyName || '—' }}</td>
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
@if (line.description) {
<tr class="vd-desc-row">
<td></td>
<td colspan="3" class="vd-desc">{{ line.description }}</td>
</tr>
}
} @empty {
<tr>
<td colspan="4" class="vd-empty">This voucher's individual lines were not recorded.</td>
</tr>
}
</tbody>
@if (detail.lines.length > 0) {
<tfoot>
<tr>
<td colspan="3">Total</td>
<td class="num">
₹{{ detail.totalDebit | number: '1.2-2' }} / ₹{{ detail.totalCredit | number: '1.2-2' }}
</td>
</tr>
</tfoot>
}
</table>
@if (detail.relatedLines.length > 0) {
<div class="vd-related">
<div class="vd-related__title">
<mat-icon inline>link</mat-icon>
Also posted for this transaction — not part of this voucher
</div>
<table class="vd-table vd-table--related">
<tbody>
@for (line of detail.relatedLines; track $index) {
<tr>
<td>
<span
class="vd-side"
[class.vd-side--dr]="line.entryType === 'DR'"
[class.vd-side--cr]="line.entryType === 'CR'"
>
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
</span>
</td>
<td>{{ line.partyName || '—' }}</td>
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
<p class="vd-related__hint">
{{ detail.relatedLines[0].stage }} — this and the lines above are the same transaction's full accounting.
</p>
</div>
}
<div class="vd-actions">
<button mat-flat-button color="primary" mat-dialog-close>Close</button>
</div>
</div>
`,
styles: [
`
.vd-shell {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 24px 16px;
min-width: 420px;
max-width: 560px;
}
.vd-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.vd-header__title {
display: flex;
align-items: center;
gap: 12px;
}
.vd-header__title h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.vd-header__title p {
margin: 2px 0 0;
font-size: 13px;
color: rgba(0, 0, 0, 0.6);
}
.vd-icon {
color: #1976d2;
}
.vd-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.vd-unbalanced {
display: inline-flex;
align-items: center;
gap: 4px;
color: #b91c1c;
font-weight: 600;
}
.vd-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.vd-table th {
text-align: left;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.03em;
color: rgba(0, 0, 0, 0.5);
padding: 6px 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.vd-table td {
padding: 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.vd-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.vd-acct {
color: rgba(0, 0, 0, 0.55);
}
.vd-side {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.vd-side--dr {
background: #fee2e2;
color: #b91c1c;
}
.vd-side--cr {
background: #dcfce7;
color: #15803d;
}
.vd-desc-row td {
padding-top: 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.vd-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
font-style: italic;
}
.vd-empty {
text-align: center;
color: rgba(0, 0, 0, 0.5);
padding: 16px 8px;
}
.vd-table tfoot td {
font-weight: 600;
border-bottom: none;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.vd-related {
border-top: 1px dashed rgba(0, 0, 0, 0.15);
padding-top: 12px;
}
.vd-related__title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #6d28d9;
margin-bottom: 8px;
}
.vd-table--related td {
background: #faf5ff;
}
.vd-related__hint {
margin: 6px 0 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
}
.vd-actions {
display: flex;
justify-content: flex-end;
}
`
]
})
export class VoucherDetailDialogComponent {
dialogRef = inject(MatDialogRef<VoucherDetailDialogComponent>);
private data = inject<VoucherDetailDialogData>(MAT_DIALOG_DATA);
detail = this.data.detail;
}
@@ -19,21 +19,34 @@
<!-- ── Entry Tab ── -->
<mat-tab label="New Reconciliation">
<div class="pg-tab-body">
@if (ownCashierMissing) {
<div class="pg-empty">
Your login is not linked to a cashier, so there is no cash drawer to reconcile. Ask an administrator to link
your user to a cashier.
</div>
}
<div class="pg-toolbar pg-toolbar--mat">
<!-- A reconciliation is the cashier's own count of their own drawer: nothing to pick. -->
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="ownTellerName || '—'" readonly />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier (You)</mat-label>
<input matInput type="text" [value]="cashierName || '—'" readonly />
<mat-label>Cashier</mat-label>
<mat-select
[value]="selectedCashierId"
(selectionChange)="onCashierSelect($event.value)"
[disabled]="!selectedTellerId"
>
@for (c of cashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -12,12 +12,12 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -57,6 +57,7 @@ interface ReconRecord {
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
MatTabsModule
]
@@ -65,14 +66,9 @@ export class ReconciliationComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
/**
* A reconciliation is the cashier's own count of their own drawer, so nobody else makes one and there is
* no cashier to pick: the screen is fixed to the logged-in cashier. The server refuses anything else.
*/
ownCashierMissing = false;
ownTellerName = '';
// ── Entry form ──
tellers: { id: number; name: string }[] = [];
cashiers: { id: number; label: string }[] = [];
selectedTellerId: number | null = null;
selectedCashierId: number | null = null;
cashierName = '';
@@ -89,30 +85,44 @@ export class ReconciliationComponent implements OnInit {
loadingHistory = false;
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.lockToOwnCashier();
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
this.loadHistory();
}
/** Take the logged-in cashier as the one being reconciled, with their counter for the record. */
private lockToOwnCashier(): void {
this.service.getCurrentCashierInfo().subscribe((info) => {
if (!info) {
this.ownCashierMissing = true;
return;
}
this.selectedCashierId = info.cashierId;
this.cashierName = info.cashierName;
this.service
.getInterCashierCashierSummary(info.cashierId, this.reconDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
this.selectedTellerId = r?.tellerId ?? null;
this.ownTellerName = r?.tellerName || '';
});
this.fetchExpected();
});
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.selectedCashierId = null;
this.cashierName = '';
this.cashiers = [];
this.rows = [];
if (!tellerId) return;
this.service
.getCashiersForTeller(tellerId)
.pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
if (this.cashiers.length === 1) {
this.onCashierSelect(this.cashiers[0].id);
}
});
}
onCashierSelect(cashierId: number): void {
this.selectedCashierId = cashierId;
const c = this.cashiers.find((x) => x.id === cashierId);
this.cashierName = c ? c.label : '';
this.rows = [];
this.fetchExpected();
}
fetchExpected(): void {
@@ -0,0 +1,375 @@
<!--
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="pg">
<!-- ── HEADER ── -->
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · SHARE COLLECTION</div>
<h1 class="pg-title">Share Collection</h1>
<p class="pg-sub">
Collect cash from member for share purchase — amounts are held in Sundry GL until the account is activated.
</p>
</div>
</header>
<!-- ── SEARCH BAR ── -->
@if (pageState !== 'success') {
<div class="sc-search-bar">
<div class="sc-search-bar__input-wrap">
<mat-icon class="sc-search-bar__icon">search</mat-icon>
<input
class="sc-search-bar__input"
type="text"
placeholder="Enter Share Account ID or Account No."
[(ngModel)]="accountInput"
(keyup.enter)="searchAccount()"
/>
</div>
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="pageState === 'searching' || !accountInput.trim()"
(click)="searchAccount()"
>
<mat-icon>manage_search</mat-icon>
{{ pageState === 'searching' ? 'Searching…' : 'Search' }}
</button>
</div>
}
<!-- ── ERROR ── -->
@if (errorMsg) {
<div class="sc-alert sc-alert--error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
<!-- ── SEARCHING SPINNER ── -->
@if (pageState === 'searching') {
<div class="pg-empty">
<mat-icon class="sc-spin">hourglass_empty</mat-icon>
<p>Looking up account…</p>
</div>
}
<!-- ── ACCOUNT LOADED ── -->
@if (pageState === 'loaded') {
<!-- Member Card -->
<div class="sc-member-card">
<div class="sc-member-card__avatar">
<mat-icon>person</mat-icon>
</div>
<div class="sc-member-card__info">
<div class="sc-member-card__name">{{ account.clientName }}</div>
<div class="sc-member-card__meta">
<span class="sc-badge sc-badge--account">{{ account.accountNo }}</span>
<span class="sc-badge sc-badge--product">{{ account.productName }}</span>
<span
class="sc-badge"
[class.sc-badge--pending]="account.status?.code?.includes('pending')"
[class.sc-badge--active]="account.status?.code?.includes('active')"
>{{ account.status?.value }}</span
>
</div>
</div>
<!-- Editable Shares + Price -->
<div class="sc-member-card__shares">
<div class="sc-shares-inputs">
<div class="sc-shares-field">
<label class="sc-shares-label">No. of Shares</label>
<input
class="sc-shares-input"
type="number"
min="0"
[(ngModel)]="requestedSharesInput"
(input)="recalc()"
placeholder="0"
/>
</div>
<div class="sc-shares-sep">×</div>
<div class="sc-shares-field">
<label class="sc-shares-label">Price / Share (₹)</label>
<input
class="sc-shares-input"
type="number"
min="0"
[(ngModel)]="pricePerShare"
(input)="recalc()"
placeholder="0.00"
/>
</div>
<div class="sc-shares-sep">=</div>
<div class="sc-shares-result">
<div class="sc-shares-label">Share Amount</div>
<div class="sc-shares-total">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</div>
</div>
</div>
</div>
</div>
<!-- Amount Breakdown + GL Preview -->
<div class="sc-split">
<!-- LEFT: Amount Breakdown -->
<div class="pg-card sc-breakdown">
<div class="sc-breakdown__header">
<mat-icon>receipt_long</mat-icon>
<span>Amount Breakdown</span>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Description</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>
Share Amount
<span class="sc-dim">
({{ requestedSharesInput }} shares × {{ currencySymbol }}{{ pricePerShare | number: '1.2-2' }})
</span>
</td>
<td class="num">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</td>
</tr>
@for (charge of charges; track charge.id) {
<tr>
<td>{{ charge.name }}</td>
<td class="num sc-charge-amount">
<input class="sc-charge-input" type="number" min="0" [(ngModel)]="charge.amount" (input)="recalc()" />
</td>
</tr>
}
@if (charges.length === 0) {
<tr>
<td colspan="2">
<span class="sc-dim">No charges applicable</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="sc-total-row">
<td><strong>Total Payable</strong></td>
<td class="num sc-total-amount">
<strong>{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
<!-- RIGHT: GL Preview -->
<div class="pg-card sc-gl-preview">
<div class="sc-breakdown__header">
<mat-icon>account_tree</mat-icon>
<span>GL Entries Preview</span>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Account</th>
<th>Dr / Cr</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
<tr class="gl-row--dr">
<td class="gl-acname">Cash Vault GL</td>
<td><span class="sc-dr-badge">Dr</span></td>
<td class="num gl-amt--dr">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</td>
</tr>
<tr class="gl-row--cr">
<td class="gl-acname">Share Sundry GL</td>
<td><span class="sc-cr-badge">Cr</span></td>
<td class="num gl-amt--cr">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</td>
</tr>
@if (totalChargeAmount > 0) {
<tr class="gl-row--cr">
<td class="gl-acname">Charge Receivable GL</td>
<td><span class="sc-cr-badge">Cr</span></td>
<td class="num gl-amt--cr">{{ currencySymbol }}{{ totalChargeAmount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
<div class="sc-gl-note">
<mat-icon>info_outline</mat-icon>
Funds move to Share GL &amp; Income GL when account is <strong>activated</strong>.
</div>
</div>
</div>
<!-- ── DENOMINATION (Cash Only) ── -->
@if (isCashPayment && allDenominations.length > 0) {
<div class="pg-card sc-denom">
<div class="sc-breakdown__header">
<mat-icon>payments</mat-icon>
<span>Denomination Entry</span>
@if (denominationTotal > 0) {
<span
class="sc-denom-badge"
[class.sc-denom-badge--match]="denomMatchesTotal"
[class.sc-denom-badge--mismatch]="!denomMatchesTotal"
>
₹{{ denominationTotal | number: '1.2-2' }}
{{ denomMatchesTotal ? '✓ Matches' : '≠ Mismatch' }}
</span>
}
@if (denominationTotal === 0) {
<span class="sc-denom-required">⚠ Required for Cash</span>
}
</div>
<div class="sc-denom-body">
<table class="pg-table sc-denom-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (d of allDenominations; track d.id) {
<tr>
<td>
<span class="sc-denom-chip" [class.sc-denom-chip--coin]="d.kind === 'coin'">{{ d.label }}</span>
</td>
<td class="num">
<div class="sc-denom-stepper">
<button type="button" (click)="bumpDenom(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input
type="number"
min="0"
[value]="countFor(d) || ''"
placeholder="0"
(input)="onDenomChange(d, $any($event.target).value)"
/>
<button type="button" (click)="bumpDenom(d, 1)">+</button>
</div>
</td>
<td class="num">{{ subtotalFor(d) > 0 ? (subtotalFor(d) | number: '1.2-2') : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td></td>
<td class="num">
<strong>₹{{ denominationTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
@if (!denomMatchesTotal && denominationTotal > 0) {
<div class="sc-denom-mismatch">
<mat-icon>warning</mat-icon>
Denomination ₹{{ denominationTotal | number: '1.2-2' }} must equal Total Payable ₹{{
totalAmount | number: '1.2-2'
}}.
</div>
}
@if (denominationTotal > 0) {
<div class="sc-denom-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="clearDenominations()">
<mat-icon>clear</mat-icon> Clear
</button>
</div>
}
</div>
</div>
}
<!-- Transaction Details -->
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Transaction Date *</mat-label>
<input matInput type="date" [(ngModel)]="transactionDate" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Payment Mode</mat-label>
<mat-select [(ngModel)]="paymentTypeId">
<mat-option [value]="null">— Cash (Default) —</mat-option>
@for (pt of paymentTypes; track pt.id) {
<mat-option [value]="pt.id">{{ pt.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
<mat-label>Note (Optional)</mat-label>
<input matInput type="text" [(ngModel)]="note" placeholder="e.g. Share purchase for 100 shares" />
</mat-form-field>
</div>
<!-- Footer Submit Bar -->
<div class="pg-total-bar sc-footer">
<div class="sc-footer__summary">
<span class="sc-footer__label">Total to Collect</span>
<span class="sc-footer__amount">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</span>
</div>
<div class="sc-footer__actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="reset()"><mat-icon>close</mat-icon> Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!isValid || saving" (click)="submit()">
<mat-icon>payments</mat-icon>
{{ saving ? 'Posting…' : 'Collect & Post' }}
</button>
</div>
</div>
}
<!-- ── SUCCESS ── -->
@if (pageState === 'success' && collectionResult) {
<div class="sc-success">
<div class="sc-success__icon-wrap">
<mat-icon>check_circle</mat-icon>
</div>
<h2 class="sc-success__title">Collection Posted Successfully</h2>
<p class="sc-success__sub">
Cash of
<strong>{{ collectionResult.currencyCode }} {{ collectionResult.totalAmount | number: '1.2-2' }}</strong>
collected from <strong>{{ collectionResult.clientName }}</strong> on
<strong>{{ collectionResult.collectionDate }}</strong> by <strong>{{ collectionResult.collectedBy }}</strong
>.
</p>
<!-- Transaction reference -->
<div class="sc-success__ref">
<span class="sc-success__ref-label">Transaction ID</span>
<span class="sc-success__ref-val">{{ collectionResult.transactionId }}</span>
</div>
<!-- GL entries from backend -->
<div class="sc-success__gl">
@for (entry of collectionResult.glEntries; track entry.account) {
<div
class="sc-success__gl-row"
[class.sc-success__gl-row--dr]="entry.type === 'Dr'"
[class.sc-success__gl-row--cr]="entry.type === 'Cr'"
>
<span>{{ entry.type }}. {{ entry.account }}</span>
<span>{{ entry.currency }} {{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
<p class="sc-success__note">
Funds are held in Sundry GL. They will move to Share Equity GL when the account is activated.
</p>
<button type="button" class="pg-btn pg-btn--primary" (click)="reset()">
<mat-icon>add</mat-icon> New Collection
</button>
</div>
}
</div>
@@ -0,0 +1,701 @@
/**
* 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/.
*/
// ── Search Bar ───────────────────────────────────────────────────────────────
.sc-search-bar {
display: flex;
align-items: center;
gap: 10px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 10px 14px;
&__input-wrap {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
}
&__icon {
color: #94a3b8;
font-size: 20px;
width: 20px;
height: 20px;
flex-shrink: 0;
}
&__input {
flex: 1 1 auto;
border: none;
outline: none;
font-size: 14px;
font-family: inherit;
color: #0d1b2a;
background: transparent;
&::placeholder {
color: #94a3b8;
}
}
}
// ── Alert ────────────────────────────────────────────────────────────────────
.sc-alert {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
flex-shrink: 0;
}
&--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
}
// ── Spinner ──────────────────────────────────────────────────────────────────
.sc-spin {
animation: sc-spin 1.2s linear infinite;
display: block;
margin: 0 auto 8px;
font-size: 32px !important;
width: 32px !important;
height: 32px !important;
color: #94a3b8;
}
@keyframes sc-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// ── Member Card ──────────────────────────────────────────────────────────────
.sc-member-card {
display: flex;
align-items: center;
gap: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px 20px;
&__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #eff6ff;
flex-shrink: 0;
mat-icon {
color: #2563eb;
font-size: 24px;
width: 24px;
height: 24px;
}
}
&__info {
flex: 1 1 auto;
min-width: 0;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #1e293b;
margin-bottom: 6px;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
&__shares {
flex-shrink: 0;
display: flex;
align-items: center;
}
}
// ── Badges ───────────────────────────────────────────────────────────────────
.sc-badge {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 5px;
font-size: 11px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
&--account {
background: #eff6ff;
color: #1d4ed8;
border-color: #bfdbfe;
font-family: 'Roboto Mono', monospace;
}
&--product {
background: #f0fdf4;
color: #15803d;
border-color: #bbf7d0;
}
&--pending {
background: #fffbeb;
color: #d97706;
border-color: #fde68a;
}
&--active {
background: #f0fdf4;
color: #15803d;
border-color: #bbf7d0;
}
}
// ── Editable Share Inputs ────────────────────────────────────────────────────
.sc-shares-inputs {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.sc-shares-field {
display: flex;
flex-direction: column;
gap: 3px;
}
.sc-shares-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94a3b8;
}
.sc-shares-input {
width: 110px;
height: 38px;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
padding: 0 10px;
font-size: 14px;
font-weight: 700;
font-family: inherit;
color: #1353a0;
text-align: right;
outline: none;
background: #f8fafc;
transition: border-color 0.15s;
&:focus {
border-color: #1353a0;
background: #fff;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
}
.sc-shares-sep {
font-size: 20px;
font-weight: 300;
color: #94a3b8;
padding-top: 16px;
}
.sc-shares-result {
text-align: right;
}
.sc-shares-total {
font-size: 20px;
font-weight: 800;
color: #0f4f3c;
font-variant-numeric: tabular-nums;
}
// ── Editable Charge Amount ───────────────────────────────────────────────────
.sc-charge-amount {
padding: 4px 14px !important;
}
.sc-charge-input {
width: 100px;
height: 32px;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
padding: 0 8px;
font-size: 13px;
font-weight: 700;
font-family: inherit;
color: #1353a0;
text-align: right;
outline: none;
background: #f8fafc;
transition: border-color 0.15s;
&:focus {
border-color: #1353a0;
background: #fff;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
}
// ── Split Layout ─────────────────────────────────────────────────────────────
.sc-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
// ── Breakdown Card ───────────────────────────────────────────────────────────
.sc-breakdown {
&__header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
font-weight: 700;
color: #334155;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #0f4f3c;
}
}
}
.sc-gl-preview {
.sc-breakdown__header {
mat-icon {
color: #1d4ed8;
}
}
}
.sc-gl-note {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 10px 14px;
font-size: 12px;
color: #64748b;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 1px;
color: #94a3b8;
}
}
// ── Table helpers ────────────────────────────────────────────────────────────
.sc-dim {
color: #94a3b8;
font-size: 12px;
}
.sc-total-row td {
background: #f8fafc;
border-top: 2px solid #e2e8f0;
font-size: 14px;
}
.sc-total-amount {
color: #0f4f3c;
}
// ── Dr / Cr badges ───────────────────────────────────────────────────────────
.sc-dr-badge,
.sc-cr-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 20px;
border-radius: 4px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
}
.sc-dr-badge {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.sc-cr-badge {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
// ── Footer ───────────────────────────────────────────────────────────────────
.sc-footer {
justify-content: space-between !important;
&__summary {
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
}
&__amount {
font-size: 22px;
font-weight: 800;
color: #0f4f3c;
font-variant-numeric: tabular-nums;
}
&__actions {
display: flex;
align-items: center;
gap: 10px;
}
}
// ── Denomination Panel ────────────────────────────────────────────────────────
.sc-denom {
padding: 0;
overflow: hidden;
}
.sc-denom-body {
padding: 14px 16px;
}
.sc-denom-table {
width: 100%;
th,
td {
padding: 7px 10px;
font-size: 13px;
}
tfoot td {
border-top: 2px solid #e2e8f0;
background: #f8fafc;
font-size: 14px;
}
}
.sc-denom-chip {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 5px;
font-size: 12px;
font-weight: 700;
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
&--coin {
background: #fffbeb;
color: #d97706;
border-color: #fde68a;
}
}
.sc-denom-stepper {
display: flex;
align-items: center;
gap: 4px;
justify-content: flex-end;
button {
width: 26px;
height: 26px;
border: 1.5px solid #e2e8f0;
border-radius: 5px;
background: #f8fafc;
color: #475569;
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
&:hover:not(:disabled) {
background: #e0f2fe;
border-color: #7dd3fc;
color: #0369a1;
}
&:disabled {
opacity: 0.35;
cursor: not-allowed;
}
}
input {
width: 62px;
height: 28px;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
padding: 0 6px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
text-align: right;
outline: none;
background: #fff;
&:focus {
border-color: #1353a0;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
}
}
.sc-denom-badge {
margin-left: auto;
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 10px;
border-radius: 5px;
font-size: 12px;
font-weight: 700;
&--match {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--mismatch {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
}
.sc-denom-required {
margin-left: auto;
font-size: 12px;
font-weight: 700;
color: #d97706;
}
.sc-denom-mismatch {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
padding: 9px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: #dc2626;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
.sc-denom-actions {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
// ── Success ──────────────────────────────────────────────────────────────────
.sc-success {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 40px 24px;
gap: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
&__icon-wrap {
width: 72px;
height: 72px;
border-radius: 50%;
background: #f0fdf4;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 40px;
width: 40px;
height: 40px;
color: #16a34a;
}
}
&__title {
font-size: 22px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
&__sub {
font-size: 14px;
color: #64748b;
max-width: 480px;
margin: 0;
}
&__ref {
display: flex;
align-items: center;
gap: 10px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px 16px;
}
&__ref-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94a3b8;
white-space: nowrap;
}
&__ref-val {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-size: 13px;
font-weight: 600;
color: #1353a0;
}
&__note {
font-size: 12px;
color: #94a3b8;
max-width: 420px;
margin: 0;
text-align: center;
}
&__gl {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
max-width: 400px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 14px 16px;
}
&__gl-row {
display: flex;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
padding: 4px 0;
border-bottom: 1px dotted #e2e8f0;
&:last-child {
border-bottom: none;
}
&--dr {
color: #dc2626;
}
&--cr {
color: #15803d;
}
}
}
@@ -0,0 +1,282 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { CashierService } from '../cashier.service';
type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success';
interface Denomination {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
interface ShareCharge {
id: number;
name: string;
amount: number;
currencySymbol: string;
}
@Component({
selector: 'mifosx-share-collection',
templateUrl: './share-collection.component.html',
styleUrls: [
'../cashier-shared.scss',
'./share-collection.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule
]
})
export class ShareCollectionComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
pageState: PageState = 'idle';
accountInput = '';
transactionDate = '';
paymentTypeId: number | null = null;
paymentTypes: any[] = [];
note = '';
errorMsg = '';
saving = false;
account: any = null;
charges: ShareCharge[] = [];
collectionResult: any = null; // filled after successful POST
// Editable fields — cashier can override if API returns 0
requestedSharesInput = 0;
pricePerShare = 0;
// ── Denomination ────────────────────────────────────────────────────────────
notes: Denomination[] = [];
coins: Denomination[] = [];
denominationCounts: Record<string, number> = {};
get shareAmount(): number {
return this.requestedSharesInput * this.pricePerShare;
}
get totalChargeAmount(): number {
return this.charges.reduce((s, c) => s + c.amount, 0);
}
get totalAmount(): number {
return this.shareAmount + this.totalChargeAmount;
}
get currencySymbol(): string {
return this.account?.currency?.displaySymbol ?? '₹';
}
get isCashPayment(): boolean {
if (this.paymentTypeId === null) return true;
const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId);
return !!pt?.isCashPayment;
}
get allDenominations(): Denomination[] {
const desc = (a: Denomination, b: Denomination) => b.value - a.value;
return [
...[...this.notes].sort(desc),
...[...this.coins].sort(desc)
];
}
get denominationTotal(): number {
return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0);
}
get denomMatchesTotal(): boolean {
return this.denominationTotal === this.totalAmount;
}
get isValid(): boolean {
if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false;
if (this.isCashPayment && this.allDenominations.length > 0) {
return this.denomMatchesTotal;
}
return true;
}
ngOnInit(): void {
const d = new Date();
this.transactionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getPaymentTypes()
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types || []));
this.loadDenominations();
}
private loadDenominations(): void {
this.service
.getActiveDenominations()
.pipe(catchError(() => of([])))
.subscribe((list: any[]) => {
const rows: Denomination[] = (list || []).map((d: any) => ({
id: d.id,
value: d.denomination ?? d.value,
kind: d.type === 'coin' ? 'coin' : 'note',
label: '₹' + (d.denomination ?? d.value)
}));
const order = (a: Denomination, b: Denomination) => b.value - a.value;
this.notes = rows.filter((r) => r.kind === 'note').sort(order);
this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
});
}
// ── Denomination helpers ──────────────────────────────────────────────────
denomKey(d: Denomination): string {
return `${d.kind}:${d.value}`;
}
countFor(d: Denomination): number {
return this.denominationCounts[this.denomKey(d)] || 0;
}
subtotalFor(d: Denomination): number {
return this.countFor(d) * d.value;
}
onDenomChange(d: Denomination, raw: any): void {
this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
}
bumpDenom(d: Denomination, delta: number): void {
this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
}
clearDenominations(): void {
this.denominationCounts = {};
}
searchAccount(): void {
const query = this.accountInput.trim();
if (!query) return;
this.pageState = 'searching';
this.account = null;
this.charges = [];
this.errorMsg = '';
this.service
.getShareAccountForCollection(query)
.pipe(
catchError((err) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.developerMessage ||
'Account not found. Please check the account ID and try again.';
this.pageState = 'idle';
return of(null);
})
)
.subscribe((acc: any) => {
if (!acc) return;
this.account = acc;
// Auto-detect shares from summary or first pending purchasedShares entry
const pendingTx = (acc.purchasedShares || []).find(
(t: any) =>
t.status?.code === 'purchasedSharesStatusType.applied' && t.type?.code === 'purchasedSharesType.purchased'
);
this.requestedSharesInput = Number(
acc.summary?.totalPendingForApprovalShares || pendingTx?.numberOfShares || acc.requestedShares || 0
);
this.pricePerShare = Number(pendingTx?.purchasedPrice || acc.currentMarketPrice || acc.unitPrice || 0);
// Show ALL charges on the account — no type filtering
this.charges = (acc.charges || []).map((c: any) => ({
id: c.chargeId ?? c.id,
name: c.name,
amount: Number(c.amount ?? c.amountOutstanding ?? 0),
currencySymbol: c.currency?.displaySymbol ?? '₹'
}));
this.pageState = 'loaded';
});
}
recalc(): void {
// triggered by input changes — nothing extra needed, getters auto-recalculate
}
submit(): void {
if (!this.isValid || this.saving) return;
this.saving = true;
const payload = {
shareAccountId: this.account.id,
accountNo: this.account.accountNo,
clientName: this.account.clientName,
transactionDate: this.transactionDate,
requestedShares: this.requestedSharesInput,
pricePerShare: this.pricePerShare,
shareAmount: this.shareAmount,
chargeAmount: this.totalChargeAmount,
totalAmount: this.totalAmount,
paymentTypeId: this.paymentTypeId || null,
note: this.note || null
};
this.service.collectSharePayment(payload).subscribe({
next: (result: any) => {
this.collectionResult = result;
this.snackBar.open('Share collection posted successfully.', 'Close', { duration: 3000 });
this.pageState = 'success';
this.saving = false;
},
error: (err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
'Collection failed. Please try again.';
this.snackBar.open(msg, 'Close', { duration: 8000 });
this.saving = false;
}
});
}
reset(): void {
this.pageState = 'idle';
this.accountInput = '';
this.account = null;
this.charges = [];
this.errorMsg = '';
this.note = '';
this.paymentTypeId = null;
this.requestedSharesInput = 0;
this.pricePerShare = 0;
this.collectionResult = null;
this.saving = false;
this.clearDenominations();
}
}
@@ -1,910 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
export interface CashierIssueDdDialogData {
leaflet: any;
branchName: string;
sponsorBankName: string;
faceValue: number;
chargesAmount: number;
gstPercentage: number;
gstAmount: number;
totalCashPayable: number;
/**
* How the draft is being paid for. Cash is a walk-in purchaser the bank may know nothing about,
* so their name, address and Aadhaar have to be captured here. An account debit is a member whose
* account is already on screen — those fields are not asked for, and must not be sent, because
* the identity is the account holder's rather than a walk-in's.
*/
paymentMethod?: 'CASH' | 'ACCOUNT_DEBIT';
/** The account being debited, shown so the clerk can see what is funding the draft. */
accountNo?: string;
/** The account holder, for the same reason. */
accountHolderName?: string;
/**
* How many drafts this slip raises. Absent or 1 is the ordinary single draft and the dialog is
* unchanged; 2 or more turns it into a slip, asking for a face value, charge and beneficiary per
* draft and reporting the combined total the account will be debited for.
*/
ddCount?: number;
/**
* Whether the funding account is a GL account rather than a member's. Only affects wording: a GL
* account has no passbook, so it is not promised one.
*/
glFunded?: boolean;
/**
* The leaflets provisionally shown against each line of a slip.
*
* Only ever indicative. The server allocates the real numbers inside the locked transaction that
* submits the slip, so what is shown here is the next-available run at the moment the dialog was
* opened — accurate unless another clerk submits first, which is precisely why the numbers cannot
* be promised until submit returns them.
*/
previewDdNumbers?: string[];
}
/** One captured draft on a multi-DD slip. */
export interface DdSlipLineResult {
beneficiaryName: string;
payableAtBranch: string;
payableAtCity: string;
faceValue: number;
chargesAmount: number;
}
@Component({
selector: 'mifosx-cashier-issue-dd-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatIconModule
],
template: `
<div class="dd-dialog-shell">
<div class="dd-dialog-header">
<div class="dd-dialog-header__title">
<mat-icon class="dd-icon">receipt_long</mat-icon>
<div>
<h2>
@if (isSlip) {
Multi-DD Slip · {{ lineCount }} Demand Drafts
} @else {
{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}
}
</h2>
<p>
@if (isSlip) {
One debit funds all {{ lineCount }} drafts · Enter each draft's beneficiary and amount
} @else {
Next available DD leaflet auto-selected ·
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser &amp; beneficiary details' }}
}
</p>
</div>
</div>
<button mat-icon-button type="button" (click)="onClose()" class="close-btn">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content class="dd-dialog-body">
<!--
Slip running total.
A slip's figures are not known until every line is typed — different drafts carry different
face values and different charges — so the total the account will be debited for is built
up here as the clerk works, rather than shown as a fixed figure up front. Answering "what
is this going to cost my member" without it would mean adding the lines by hand.
-->
@if (isSlip) {
<div class="dd-summary-card">
<div class="summary-col">
<span class="label">Drafts On This Slip</span>
<strong class="dd-number-highlight">{{ lineCount }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">Total Face Value</span>
<strong class="amount">₹{{ slipFaceValue | number: '1.2-2' }}</strong>
<span class="sublabel">+ Charges ₹{{ slipCharges | number: '1.2-2' }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">GST ({{ data.gstPercentage }}%)</span>
<strong class="amount">₹{{ slipGst | number: '1.2-2' }}</strong>
<span class="sublabel">on charges only</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col highlight-col">
<span class="label">Total To Debit</span>
<strong class="total-amount">₹{{ slipTotal | number: '1.2-2' }}</strong>
<span class="sublabel">{{ data.accountNo || 'Account' }} · one debit</span>
</div>
</div>
<!--
Said plainly because the numbers on screen are the one thing in this dialog the clerk
cannot rely on. They are the next-available run as it stood when the dialog opened; the
binding allocation happens inside the submit transaction, so a slip filed after another
clerk's gets the following numbers instead.
-->
<div class="dd-preview-note">
<mat-icon>info</mat-icon>
<div>
DD numbers below are <strong>provisional</strong>. The final numbers are allocated when the slip is
submitted and will be confirmed on the receipt.
</div>
</div>
} @else {
<!-- Draft & Financials Summary Card -->
<div class="dd-summary-card">
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
Leaflet" named the stock item rather than the draft, so it was not obvious that this
is the DD number being issued. -->
<div class="summary-col">
<span class="label">DD Number Being Issued</span>
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">Face Value</span>
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | number: '1.2-2' }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">GST ({{ data.gstPercentage }}%)</span>
<strong class="amount">₹{{ data.gstAmount | number: '1.2-2' }}</strong>
<span class="sublabel">on charges only</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col highlight-col">
<span class="label">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
<span class="sublabel">
@if (isAccountDebit) {
{{ data.accountNo || 'Account' }}
} @else {
Denominations verified
}
</span>
</div>
</div>
}
<!--
The account funding the draft, stated before the form.
A clerk reaching this dialog has an account loaded on the workstation behind it, and the
debit is irreversible once a checker approves. Naming the account and its holder here is
what makes a wrong account visible while it can still be cancelled.
-->
@if (isAccountDebit) {
<div class="dd-funding-note">
<mat-icon>account_balance_wallet</mat-icon>
<div>
<strong>Debiting {{ data.accountNo }}</strong>
@if (data.accountHolderName) {
· {{ data.accountHolderName }}
}
<div class="dd-funding-sub">
@if (isSlip) {
₹{{ slipTotal | number: '1.2-2' }} will be debited from this account as a
<strong>single transaction</strong> covering all {{ lineCount }} drafts, when a checker approves the
slip —
<!-- A GL account has no passbook, so the reassurance a member gets is phrased for
the ledger instead of promising a statement line that does not exist. -->
@if (isGlFunded) {
one journal entry against this GL account, not {{ lineCount }}.
} @else {
the member's passbook shows one entry, not {{ lineCount }}.
}
} @else {
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
approves. No cash is taken at the counter.
}
</div>
</div>
</div>
}
<!--
A slip's drafts, one card each.
Face value and charge sit on the line rather than on the slip because that is how the bank
actually uses this: three drafts to three institutions are three different amounts, and the
charge is levied per draft. The server stores and posts them per line for the same reason.
-->
@if (isSlip) {
<form [formGroup]="slipForm" class="dd-form">
<div formArrayName="lines">
@for (lineCtrl of slipLines.controls; track $index) {
<div class="dd-line-card" [formGroupName]="$index">
<div class="dd-line-head">
<span class="dd-line-badge">DD {{ $index + 1 }} of {{ lineCount }}</span>
@if (previewNumberAt($index)) {
<span class="dd-line-number">No. {{ previewNumberAt($index) }} <em>(provisional)</em></span>
}
<span class="dd-line-total"> Debit ₹{{ lineTotalAt($index) | number: '1.2-2' }} </span>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
<input
matInput
formControlName="beneficiaryName"
placeholder="e.g. UNIVERSITY OF MUMBAI"
maxlength="120"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('required')"
>Beneficiary name is required</mat-error
>
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('maxlength')"
>Max 120 characters</mat-error
>
</mat-form-field>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - Branch *</mat-label>
<input
matInput
formControlName="payableAtBranch"
placeholder="e.g. FORT BRANCH"
maxlength="150"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="lineCtrl.get('payableAtBranch')?.hasError('required')"
>Payable Branch is required</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - City *</mat-label>
<input
matInput
formControlName="payableAtCity"
placeholder="e.g. MUMBAI"
maxlength="100"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="lineCtrl.get('payableAtCity')?.hasError('required')"
>Payable City is required</mat-error
>
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Face Value *</mat-label>
<input matInput type="number" formControlName="faceValue" placeholder="0.00" />
<span matPrefix>₹&nbsp;</span>
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('required')"
>Face value is required</mat-error
>
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('min')"
>Face value must be greater than zero</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Charges</mat-label>
<input matInput type="number" formControlName="chargesAmount" placeholder="0.00" />
<span matPrefix>₹&nbsp;</span>
<mat-hint>GST ₹{{ lineGstAt($index) | number: '1.2-2' }} at {{ data.gstPercentage }}%</mat-hint>
<mat-error *ngIf="lineCtrl.get('chargesAmount')?.hasError('min')"
>Charges cannot be negative</mat-error
>
</mat-form-field>
</div>
</div>
}
</div>
</form>
} @else {
<form [formGroup]="form" class="dd-form">
<!--
Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an
account debit the purchaser IS the account holder, already identified and KYC'd, and
re-capturing a name and Aadhaar here would invite a mismatch against the account on record.
-->
@if (!isAccountDebit) {
<div class="form-section-title">Walk-in Purchaser Details</div>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Walk-in Purchaser Name *</mat-label>
<input
matInput
formControlName="walkinPurchaserName"
placeholder="e.g. RAMESH CHANDRA"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
>Purchaser name is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
>Must be at least 3 characters</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>12-Digit Aadhaar Number *</mat-label>
<input
matInput
formControlName="walkinPurchaserAadhaar"
placeholder="12-digit Aadhaar"
maxlength="12"
/>
<mat-hint>Masked to last 4 digits before storage</mat-hint>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
>Aadhaar number is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
>Must be exactly 12 numeric digits</mat-error
>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Purchaser Address / Location *</mat-label>
<input
matInput
formControlName="walkinPurchaserAddress"
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
>Address is required</mat-error
>
</mat-form-field>
}
<!-- Beneficiary Section -->
<div class="form-section-title">Beneficiary & Payment Details</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
<input
matInput
formControlName="beneficiaryName"
placeholder="e.g. UNIVERSITY OF MUMBAI"
maxlength="120"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('required')"
>Beneficiary name is required</mat-error
>
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('maxlength')">Max 120 characters</mat-error>
</mat-form-field>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - Branch *</mat-label>
<input
matInput
formControlName="payableAtBranch"
placeholder="e.g. FORT BRANCH"
maxlength="150"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
>Payable Branch is required</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - City *</mat-label>
<input
matInput
formControlName="payableAtCity"
placeholder="e.g. MUMBAI"
maxlength="100"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
</mat-form-field>
</div>
</form>
}
</mat-dialog-content>
<mat-dialog-actions class="dd-dialog-actions" align="end">
@if (isSlip) {
<span class="dd-actions-total">
{{ lineCount }} drafts · <strong>₹{{ slipTotal | number: '1.2-2' }}</strong> to debit
</span>
}
<button mat-button type="button" (click)="onClose()">Cancel</button>
<button mat-flat-button color="primary" [disabled]="activeForm.invalid" (click)="onSubmit()">
{{ isSlip ? 'Save Slip Details' : 'Save DD Details' }}
</button>
</mat-dialog-actions>
</div>
`,
styles: [
`
.dd-dialog-shell {
display: flex;
flex-direction: column;
max-height: 90vh;
}
.dd-dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #0f172a;
color: #ffffff;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.dd-dialog-header__title {
display: flex;
align-items: center;
gap: 12px;
}
.dd-dialog-header__title h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #ffffff;
}
.dd-dialog-header__title p {
margin: 2px 0 0 0;
font-size: 12px;
color: #94a3b8;
}
.dd-icon {
color: #38bdf8;
font-size: 28px;
width: 28px;
height: 28px;
}
.close-btn {
color: #94a3b8;
}
.close-btn:hover {
color: #ffffff;
}
.dd-dialog-body {
padding: 20px 24px;
overflow-y: auto;
}
.dd-summary-card {
display: flex;
align-items: center;
justify-content: space-between;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 18px;
margin-bottom: 20px;
}
.summary-col {
display: flex;
flex-direction: column;
gap: 2px;
}
.summary-col.highlight-col {
background: #ecfdf5;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #a7f3d0;
}
.summary-divider {
width: 1px;
height: 36px;
background: #e2e8f0;
}
.summary-col .label {
font-size: 11px;
text-transform: uppercase;
color: #64748b;
font-weight: 600;
}
.dd-number-highlight {
font-size: 16px;
color: #0369a1;
font-family: monospace;
}
.summary-col .amount {
font-size: 15px;
color: #1e293b;
}
.summary-col .total-amount {
font-size: 16px;
color: #065f46;
}
.summary-col .sublabel {
font-size: 11px;
color: #94a3b8;
}
.form-section-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
color: #475569;
margin: 16px 0 10px 0;
border-bottom: 1px solid #f1f5f9;
padding-bottom: 4px;
}
.form-section-title:first-child {
margin-top: 0;
}
.dd-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.full-width {
width: 100%;
}
.form-row {
display: flex;
gap: 16px;
}
.form-field-flex {
flex: 1;
}
.dd-dialog-actions {
padding: 12px 24px;
border-top: 1px solid #e2e8f0;
}
/* The account about to be debited — read before the form, so a wrong one is caught here. */
.dd-funding-note {
display: flex;
gap: 10px;
align-items: flex-start;
margin: 14px 0 4px;
padding: 11px 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-left: 3px solid #1d4ed8;
border-radius: 6px;
font-size: 13px;
color: #334155;
}
.dd-funding-note mat-icon {
color: #1d4ed8;
flex-shrink: 0;
}
.dd-funding-sub {
margin-top: 3px;
font-size: 11.5px;
line-height: 1.5;
color: #64748b;
}
/* One card per draft on a slip — the boundary is what stops a clerk typing line 2's
beneficiary against line 1's amount. */
.dd-line-card {
border: 1px solid #e2e8f0;
border-left: 3px solid #0369a1;
border-radius: 8px;
padding: 14px 16px 4px;
margin-bottom: 14px;
background: #fcfdff;
}
.dd-line-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.dd-line-badge {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #ffffff;
background: #0369a1;
padding: 3px 9px;
border-radius: 999px;
}
.dd-line-number {
font-family: monospace;
font-size: 12px;
color: #475569;
}
.dd-line-number em {
font-style: normal;
color: #94a3b8;
font-size: 11px;
}
.dd-line-total {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: #065f46;
background: #ecfdf5;
border: 1px solid #a7f3d0;
padding: 3px 10px;
border-radius: 6px;
}
.dd-preview-note {
display: flex;
gap: 8px;
align-items: flex-start;
margin: 0 0 16px;
padding: 9px 12px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
color: #78350f;
}
.dd-preview-note mat-icon {
color: #b45309;
font-size: 18px;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.dd-actions-total {
margin-right: auto;
font-size: 13px;
color: #475569;
}
.dd-actions-total strong {
color: #065f46;
}
`
]
})
export class CashierIssueDdDialogComponent implements OnInit {
private fb = inject(FormBuilder);
private dialogRef = inject(MatDialogRef<CashierIssueDdDialogComponent>);
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
form!: FormGroup;
/** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */
slipForm!: FormGroup;
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
get isAccountDebit(): boolean {
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
}
/** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */
get lineCount(): number {
const n = Number(this.data.ddCount) || 1;
return n > 1 ? n : 1;
}
/** Whether this dialog is capturing a multi-DD slip rather than a single draft. */
get isSlip(): boolean {
return this.lineCount > 1;
}
/** Whether the debit lands on a GL account rather than a member's — wording only. */
get isGlFunded(): boolean {
return !!this.data.glFunded;
}
/** The form the Save button validates against — whichever of the two was built. */
get activeForm(): FormGroup {
return this.isSlip ? this.slipForm : this.form;
}
get slipLines(): FormArray {
return this.slipForm.get('lines') as FormArray;
}
/** The provisional leaflet number shown against a line, if the caller supplied a run of them. */
previewNumberAt(index: number): string | null {
return this.data.previewDdNumbers?.[index] ?? null;
}
private lineValueAt(index: number, field: string): number {
return Number(this.slipLines.at(index)?.get(field)?.value) || 0;
}
/**
* GST on one line's charge.
*
* Rounded per line, matching the server, which computes and stores GST against each draft rather
* than once for the slip. Summing rounded lines and rounding a summed charge can differ by a
* paisa, and it is the per-line figure that ends up on the draft.
*/
lineGstAt(index: number): number {
const charges = this.lineValueAt(index, 'chargesAmount');
if (charges <= 0) return 0;
return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100;
}
/** What one line adds to the debit — its face value, its charge and the GST on that charge. */
lineTotalAt(index: number): number {
return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index);
}
get slipFaceValue(): number {
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0);
}
get slipCharges(): number {
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0);
}
get slipGst(): number {
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0);
}
/** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */
get slipTotal(): number {
return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9);
}
ngOnInit(): void {
if (this.isSlip) {
this.slipForm = this.fb.group({
lines: this.fb.array(
Array.from({ length: this.lineCount }, () =>
this.fb.group({
beneficiaryName: [
'',
[
Validators.required,
Validators.maxLength(120)
]
],
payableAtBranch: [
'',
[Validators.required]
],
payableAtCity: [
'',
[Validators.required]
],
// Zero is rejected rather than merely warned about: the server refuses a line with a
// face value at or below zero, and a slip that fails there has already cost the clerk
// every other line they typed.
faceValue: [
null as
| number
| null,
[
Validators.required,
Validators.min(0.01)
]
],
chargesAmount: [
0,
[Validators.min(0)]
]
})
)
)
});
return;
}
// Walk-in identity is required for cash and rejected outright for an account debit — the server
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
// Registering them unconditionally and merely hiding them would hold the form invalid on a
// required field the clerk cannot see.
this.form = this.fb.group(
this.isAccountDebit
? {
beneficiaryName: [
'',
[
Validators.required,
Validators.maxLength(120)
]
],
payableAtBranch: [
'',
[Validators.required]
],
payableAtCity: [
'',
[Validators.required]
]
}
: {
walkinPurchaserName: [
'',
[
Validators.required,
Validators.minLength(3)
]
],
walkinPurchaserAddress: [
'',
[Validators.required]
],
walkinPurchaserAadhaar: [
'',
[
Validators.required,
Validators.pattern(/^[0-9]{12}$/)
]
],
beneficiaryName: [
'',
[
Validators.required,
Validators.maxLength(120)
]
],
payableAtBranch: [
'',
[Validators.required]
],
payableAtCity: [
'',
[Validators.required]
]
}
);
}
onClose(): void {
this.dialogRef.close();
}
onSubmit(): void {
if (this.activeForm.invalid) return;
// A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on
// a member-account debit, where the purchaser is the account holder already on record.
if (this.isSlip) {
const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => {
const v = c.value;
return {
beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(),
payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(),
payableAtCity: (v.payableAtCity || '').trim().toUpperCase(),
faceValue: Number(v.faceValue) || 0,
chargesAmount: Number(v.chargesAmount) || 0
};
});
this.dialogRef.close({ lines });
return;
}
const val = this.form.value;
const beneficiary = {
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
payableAtBranch: (val.payableAtBranch || '').trim().toUpperCase(),
payableAtCity: (val.payableAtCity || '').trim().toUpperCase()
};
// Walk-in fields are left off entirely on an account debit rather than sent empty: the server
// rejects a purchaser name arriving alongside an account, and '' would be sent as one.
this.dialogRef.close(
this.isAccountDebit
? beneficiary
: {
walkinPurchaserName: (val.walkinPurchaserName || '').trim().toUpperCase(),
walkinPurchaserAddress: (val.walkinPurchaserAddress || '').trim().toUpperCase(),
walkinPurchaserAadhaar: (val.walkinPurchaserAadhaar || '').trim(),
...beneficiary
}
);
}
}
File diff suppressed because it is too large Load Diff
@@ -457,21 +457,6 @@ $ws-fld-bd: #c8d8eb;
}
}
&--action {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
font-weight: 700;
box-shadow: 0 2px 8px rgb(79 70 229 / 30%);
svg {
stroke: #fff;
}
&:hover {
filter: brightness(1.08);
}
}
&--post {
background: $ws-accent;
color: #fff;
@@ -916,20 +901,6 @@ input.ws-f__ctl--readonly {
font-size: 11px;
}
/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled
from a token-driven request whose real account type row.acTyp cannot show (see
acTypLockedByToken in the component). */
span.ws-f__ctl--locked {
display: inline-flex;
align-items: center;
background: #f1f5f9;
color: #475569;
cursor: not-allowed;
user-select: none;
font-size: 11px;
font-weight: 600;
}
input.ws-amt-input--locked {
background: #fef9c3;
color: #713f12;
@@ -2364,171 +2335,12 @@ select.te-cell {
&--disbursement {
background: linear-gradient(135deg, #0284c7, #0369a1);
}
&--action {
background: linear-gradient(135deg, #6366f1, #4f46e5);
}
}
.te-confirm-icon {
font-size: 20px;
}
// ── Account Actions popup ─────────────────────────────────────────────────────
.te-aa-box {
width: 460px;
}
.te-aa-account {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 4px;
&__no {
font-family: 'Courier New', monospace;
font-size: 13px;
font-weight: 700;
color: #1e40af;
letter-spacing: 0.03em;
}
&__name {
font-size: 13px;
font-weight: 600;
color: #0d1b2a;
}
}
.te-aa-check {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1.5px solid #fecaca;
background: #fef2f2;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.15s;
&:hover {
border-color: #f87171;
}
input {
margin-top: 2px;
width: 16px;
height: 16px;
accent-color: #dc2626;
cursor: pointer;
flex-shrink: 0;
}
span {
display: flex;
flex-direction: column;
gap: 2px;
}
strong {
font-size: 13px;
font-weight: 700;
color: #991b1b;
}
small {
font-size: 11.5px;
color: #b91c1c;
line-height: 1.4;
}
}
.te-aa-calc {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
font-size: 13px;
&--loading,
&--error {
display: block;
font-size: 12.5px;
font-weight: 600;
text-align: center;
padding: 14px 12px;
}
&--loading {
background: #f8fafc;
border-color: #e2e8f0;
color: #64748b;
}
&--error {
background: #fef2f2;
border-color: #fecaca;
color: #b91c1c;
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
color: #166534;
&--total {
margin-top: 4px;
padding-top: 8px;
border-top: 1.5px dashed #bbf7d0;
font-weight: 800;
font-size: 15px;
color: #15803d;
}
}
&__note {
margin-top: 4px;
font-size: 11px;
line-height: 1.45;
color: #4d7c56;
}
&__warn {
margin-top: 4px;
padding: 7px 9px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
line-height: 1.45;
color: #92400e;
}
}
.te-aa-next {
margin-top: 4px;
padding: 9px 11px;
background: #eef2ff;
border: 1px solid #c7d2fe;
border-radius: 7px;
font-size: 12px;
line-height: 1.5;
color: #3730a3;
strong {
font-weight: 700;
}
}
.te-confirm-body {
padding: 16px 20px;
display: flex;
@@ -3295,21 +3107,6 @@ select.te-cell {
border-radius: 10px;
box-shadow: 0 1px 4px rgb(16 185 129 / 12%);
/* A closure is destructive, so it must not read like the ordinary green "all good" banner. */
&--closure {
background: linear-gradient(135deg, #eef2ff, #f5f3ff);
border-color: #a5b4fc;
box-shadow: 0 1px 4px rgb(79 70 229 / 14%);
.ws-withdrawal-banner__icon {
color: #4f46e5;
}
.ws-withdrawal-banner__title {
color: #3730a3;
}
}
&--loading {
background: #f8fafc;
border-color: #cbd5e1;
@@ -3398,67 +3195,3 @@ select.te-cell {
transform: rotate(360deg);
}
}
.ws-dd-unconfigured-banner {
display: flex;
align-items: center;
gap: 12px;
margin: 10px 16px;
padding: 12px 16px;
background: #fffbeb;
border: 1.5px solid #fcd34d;
border-radius: 8px;
color: #92400e;
font-size: 13px;
mat-icon {
color: #d97706;
font-size: 24px;
width: 24px;
height: 24px;
flex-shrink: 0;
}
&__text {
flex: 1;
line-height: 1.4;
strong {
color: #78350f;
}
}
}
.te-dd-issue-btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: linear-gradient(135deg, #0284c7, #0ea5e9);
color: #fff;
border: none;
border-radius: 6px;
padding: 7px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 4px rgb(2 132 199 / 35%);
transition: all 0.15s ease-in-out;
letter-spacing: 0.2px;
&:hover:not(:disabled) {
background: linear-gradient(135deg, #0369a1, #0284c7);
box-shadow: 0 3px 8px rgb(2 132 199 / 50%);
transform: translateY(-1px);
}
&:active:not(:disabled) {
transform: translateY(0);
}
&:disabled {
background: #cbd5e1;
color: #64748b;
cursor: not-allowed;
box-shadow: none;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,134 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">BRANCH OPERATIONS · VAULT POSITION</div>
<h1 class="pg-title">Vault Position</h1>
<p class="pg-sub">
Denomination-wise stock a vault currently holds — the branch's own opening breakdown, plus whatever has been
settled back into it, minus whatever it has handed out to cashiers since.
</p>
</div>
</header>
<div class="pg-toolbar pg-toolbar--mat">
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Vault</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Vault (optional)</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="selectedTellerId = $event.value">
<mat-option [value]="null">All Vaults</mat-option>
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>As of Date</mat-label>
<input matInput type="date" [value]="asOfDate" (change)="asOfDate = $any($event.target).value" />
</mat-form-field>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>bar_chart</mat-icon> Load Position
</button>
</div>
@if (errorMsg) {
<div class="pg-empty" style="color: #dc2626">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@for (pos of results; track pos.tellerId) {
<div class="pg-card" style="margin-bottom: 14px">
<div class="pos-header">
<span class="pos-cashier">{{ pos.tellerName || labelForTeller(pos.tellerId) }}</span>
<span class="pos-total">₹{{ pos.totalAmount | number: '1.2-2' }}</span>
</div>
@if (!pos.hasBreakdown) {
<div class="pg-empty">
No denomination breakdown was recorded for this vault's opening day on or before {{ pos.asOfDate }}. Open (or
re-open) the business day with a note/coin count to start tracking its stock.
</div>
} @else {
@if (pos.anchorDate && pos.anchorDate !== pos.asOfDate) {
<div class="pg-empty" style="padding: 8px 14px; text-align: left">
Carried forward from the opening breakdown recorded on {{ pos.anchorDate }}.
</div>
}
@if (pos.denominations.length === 0) {
<div class="pg-empty">No stock — everything opened for this vault has been handed out.</div>
}
@if (notes(pos).length > 0) {
<div class="pos-section-label">Notes</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Current Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (d of notes(pos); track d.denominationId) {
<tr>
<td>
<span class="pg-chip">₹{{ d.denominationValue }}</span>
</td>
<td style="text-align: center; font-weight: 700">{{ d.count }}</td>
<td class="num">₹{{ d.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
@if (coins(pos).length > 0) {
<div class="pos-section-label pos-section-label--coin">Coins</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Current Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (d of coins(pos); track d.denominationId) {
<tr>
<td>
<span class="pg-chip pg-chip--coin">₹{{ d.denominationValue }}</span>
</td>
<td style="text-align: center; font-weight: 700">{{ d.count }}</td>
<td class="num">₹{{ d.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
}
</div>
}
@if (!loading && results.length === 0 && !errorMsg) {
<div class="pg-empty">Select filters and click "Load Position".</div>
}
</div>
@@ -1,116 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
denominationValue: number;
kind: string;
count: number;
amount: number;
}
interface VaultPos {
tellerId: number;
tellerName?: string;
asOfDate: string;
anchorDate?: string;
hasBreakdown: boolean;
totalAmount: number;
denominations: DenomRow[];
}
/**
* Denomination-wise stock a vault (teller) is currently holding — "how much of what does the bank
* have", the same question cashier-position answers for a cashier's own float.
*/
@Component({
selector: 'mifosx-vault-position',
templateUrl: './vault-position.component.html',
styleUrls: [
'../cashier-shared.scss',
'../cashier-position/cashier-position.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule
]
})
export class VaultPositionComponent implements OnInit {
private service = inject(CashierService);
tellers: { id: number; name: string }[] = [];
selectedTellerId: number | null = null;
asOfDate = '';
loading = false;
results: VaultPos[] = [];
errorMsg = '';
ngOnInit(): void {
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.selectedTellerId = this.tellers[0].id;
}
this.load();
});
}
load(): void {
this.loading = true;
this.results = [];
this.errorMsg = '';
this.service
.vaultPosition(this.selectedTellerId ?? undefined, this.asOfDate)
.pipe(catchError(() => of(null)))
.subscribe((data: any) => {
this.loading = false;
if (!data) {
this.errorMsg = 'Failed to load vault position.';
return;
}
const arr: any[] = Array.isArray(data) ? data : [data];
this.results = arr;
});
}
labelForTeller(id: number): string {
const t = this.tellers.find((x) => x.id === id);
return t ? t.name : `Vault #${id}`;
}
notes(pos: VaultPos): DenomRow[] {
return pos.denominations.filter((d) => d.kind !== 'coin');
}
coins(pos: VaultPos): DenomRow[] {
return pos.denominations.filter((d) => d.kind === 'coin');
}
}
@@ -1,170 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cv-tab-content vca-tab">
<!-- ── TITLE BAR ── -->
<div class="vca-action-bar">
<div class="vca-action-bar__left">
<mat-icon class="vca-action-bar__icon">undo</mat-icon>
<div>
<div class="vca-action-bar__title">Voucher Corrections</div>
<div class="vca-action-bar__sub">
Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval
moves money.
</div>
</div>
</div>
<div class="vca-counts">
<span class="vca-count"
><strong>{{ unpassCount }}</strong> unpass</span
>
<span class="vca-count"
><strong>{{ correctionCount }}</strong> correction</span
>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="vca-filter-bar">
<div class="vca-search-box">
<mat-icon>search</mat-icon>
<input type="text" placeholder="Search by voucher, account, customer or user" [(ngModel)]="searchTerm" />
@if (searchTerm) {
<button type="button" class="vca-search-box__clear" (click)="searchTerm = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
<span class="vca-fb-sep"></span>
<select class="vca-filter-select" [(ngModel)]="kindFilter">
@for (opt of kindOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<button type="button" class="vca-btn vca-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading && !loaded) {
<div class="vca-empty"><p class="vca-empty__title">Loading…</p></div>
} @else if (rows.length === 0) {
<div class="vca-empty">
<mat-icon class="vca-empty__icon">inbox</mat-icon>
<p class="vca-empty__title">No voucher correction waiting</p>
<p class="vca-empty__text">There is no unpass request or correction entry waiting for review.</p>
</div>
} @else {
<div class="vca-table-wrap">
<table class="vca-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Request</th>
<th>Posted To (Wrong)</th>
<th>Correct Account</th>
<th class="num">Amount</th>
<th>Reason</th>
<th>Raised By</th>
<th>Raised At</th>
<th class="vca-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.request.id) {
<tr class="vca-row">
<td class="vca-mono vca-voucher">{{ r.request.original_voucher_no }}</td>
<td>
<span class="vca-kind" [class]="'vca-kind vca-kind--' + r.kind">{{ kindLabel(r.kind) }}</span>
</td>
<td>
<div class="vca-mono">{{ r.request.wrong_account_no }}</div>
<div class="vca-sub">{{ r.request.wrong_client_name || '—' }}</div>
</td>
<td>
@if (r.kind === 'correction') {
<div class="vca-mono vca-strong">{{ r.request.correct_account_no }}</div>
<div class="vca-sub">{{ r.request.correct_client_name || '—' }}</div>
} @else {
<span class="vca-none">—</span>
}
</td>
<td class="num vca-amount">₹{{ r.request.amount | number: '1.2-2' }}</td>
<td class="vca-reason" [title]="r.request.reason || ''">{{ r.request.reason || '—' }}</td>
<td>{{ r.raisedBy || '—' }}</td>
<td class="vca-dt">{{ formatDateTime(r.raisedAt) }}</td>
<td class="vca-action-cell">
<button
type="button"
class="vca-btn vca-btn--reject"
[disabled]="busyId === r.request.id"
(click)="startReject(r)"
>
<mat-icon>close</mat-icon> Reject
</button>
<button
type="button"
class="vca-btn vca-btn--approve"
[disabled]="busyId === r.request.id"
(click)="approve(r)"
>
<mat-icon>check</mat-icon>
{{ r.kind === 'unpass' ? 'Approve unpass' : 'Approve correction' }}
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
<ng-template #rejectDialog>
@if (rejecting) {
<div class="vca-dlg">
<header class="vca-dlg__head">
<mat-icon class="vca-dlg__ico">block</mat-icon>
<div class="vca-dlg__titles">
<div class="vca-dlg__title">
Reject {{ rejecting.kind === 'unpass' ? 'unpass request' : 'correction entry' }}
</div>
<div class="vca-dlg__sub vca-mono">{{ rejecting.request.original_voucher_no }}</div>
</div>
<button type="button" class="vca-dlg__x" aria-label="Close" (click)="cancelReject()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vca-dlg__body">
<label class="vca-label" for="vca-reason">Reason (required)</label>
<textarea
id="vca-reason"
rows="3"
placeholder="Why is this being rejected?"
[(ngModel)]="rejectReason"
></textarea>
</div>
<footer class="vca-dlg__foot">
<button type="button" class="vca-btn vca-btn--ghost" (click)="cancelReject()">Cancel</button>
<button
type="button"
class="vca-btn vca-btn--reject-solid"
[disabled]="!rejectReason.trim() || busyId === rejecting.request.id"
(click)="confirmReject()"
>
Confirm reject
</button>
</footer>
</div>
}
</ng-template>
@@ -1,485 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
:host {
display: block;
}
.vca-tab {
padding: 0;
}
.vca-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #0f4f3c;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
max-width: 640px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
.vca-counts {
display: flex;
gap: 8px;
}
.vca-count {
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
color: #475569;
strong {
color: #0f4f3c;
}
}
.vca-filter-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.vca-fb-sep {
flex-shrink: 0;
width: 1px;
height: 22px;
margin: 0 4px;
background: #e2e8f0;
}
.vca-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
min-width: 240px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
mat-icon {
flex-shrink: 0;
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: #94a3b8;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.vca-filter-select {
height: 30px;
min-width: 140px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
// ── Buttons ─────────────────────────────────────────────────────────────────
.vca-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
font-family: inherit;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&--refresh {
height: 30px;
width: 30px;
justify-content: center;
padding: 0;
margin-left: auto;
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
&--approve {
background: #0f4f3c;
color: #fff;
&:hover:not(:disabled) {
background: #0a3d2e;
}
}
&--reject {
border-color: #fecaca;
background: #fff;
color: #dc2626;
&:hover:not(:disabled) {
background: #fef2f2;
}
}
&--reject-solid {
background: #dc2626;
color: #fff;
}
&--ghost {
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.vca-table-wrap {
overflow-x: auto;
background: #fff;
}
.vca-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
padding: 7px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
color: #0d1b2a;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.vca-th-action {
text-align: right;
}
}
.vca-row:hover td {
background: #f8fafc;
}
.vca-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.vca-voucher {
font-weight: 700;
color: #0f4f3c;
}
.vca-strong {
font-weight: 700;
}
.vca-sub {
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
.vca-none {
color: #cbd5e1;
}
.vca-amount {
font-weight: 700;
}
.vca-reason {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vca-dt {
white-space: nowrap;
color: #475569;
}
.vca-kind {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&--unpass {
background: #fef3c7;
color: #92400e;
}
&--correction {
background: #dbeafe;
color: #1e40af;
}
}
.vca-action-cell {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
// ── Empty ───────────────────────────────────────────────────────────────────
.vca-empty {
padding: 28px 16px;
text-align: center;
color: #94a3b8;
&__icon {
display: block;
margin: 0 auto 6px;
font-size: 30px;
width: 30px;
height: 30px;
opacity: 0.6;
}
&__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
&__text {
margin: 3px 0 0;
font-size: 11px;
}
}
// ── Reject popup ────────────────────────────────────────────────────────────
.vca-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #fef2f2;
color: #dc2626;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
padding: 12px 14px;
textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.vca-label {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
@@ -1,192 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { VoucherCorrection, VoucherCorrectionService } from '../voucher-correction.service';
type Kind = 'unpass' | 'correction';
type KindFilter = 'ALL' | Kind;
/** One row of the approvals table: a request, and which of the two steps it is waiting at. */
interface ApprovalRow {
kind: Kind;
request: VoucherCorrection;
raisedBy: string | null;
raisedAt: string | null;
}
/**
* Manager side of the voucher correction, laid out like the other approval tabs: approves or rejects the unpass of a
* voucher, and later the correction entry the cashier made. Only the second approval moves money.
*/
@Component({
selector: 'mifosx-voucher-correction-approvals',
templateUrl: './voucher-correction-approvals.component.html',
styleUrls: ['./voucher-correction-approvals.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule,
MatSnackBarModule
]
})
export class VoucherCorrectionApprovalsComponent implements OnInit {
private service = inject(VoucherCorrectionService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
all: ApprovalRow[] = [];
loading = false;
loaded = false;
busyId: number | null = null;
kindFilter: KindFilter = 'ALL';
searchTerm = '';
/** The row being rejected, and the reason typed for it. */
rejecting: ApprovalRow | null = null;
rejectReason = '';
readonly kindOptions: { value: KindFilter; label: string }[] = [
{ value: 'ALL', label: 'All requests' },
{ value: 'unpass', label: 'Unpass requests' },
{ value: 'correction', label: 'Correction entries' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.list('UNPASS_PENDING,CORRECTION_PENDING')
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.all = rows.map((r) =>
r.status === 'UNPASS_PENDING'
? { kind: 'unpass', request: r, raisedBy: r.unpass_by_name, raisedAt: r.unpass_at }
: { kind: 'correction', request: r, raisedBy: r.correction_by_name, raisedAt: r.correction_at }
);
this.loading = false;
this.loaded = true;
});
}
/** The table: the kind picked, narrowed by whatever is typed in the search box. */
get rows(): ApprovalRow[] {
const term = this.searchTerm.trim().toLowerCase();
return this.all.filter((r) => {
if (this.kindFilter !== 'ALL' && r.kind !== this.kindFilter) return false;
if (!term) return true;
const q = r.request;
return [
q.original_voucher_no,
q.wrong_account_no,
q.wrong_client_name,
q.correct_account_no,
q.correct_client_name,
r.raisedBy
].some((v) => (v ?? '').toLowerCase().includes(term));
});
}
get unpassCount(): number {
return this.all.filter((r) => r.kind === 'unpass').length;
}
get correctionCount(): number {
return this.all.filter((r) => r.kind === 'correction').length;
}
kindLabel(kind: Kind): string {
return kind === 'unpass' ? 'Unpass' : 'Correction';
}
formatDateTime(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
return isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
approve(row: ApprovalRow): void {
if (this.busyId) return;
const id = row.request.id;
this.busyId = id;
const call = row.kind === 'unpass' ? this.service.approveUnpass(id) : this.service.approveCorrection(id);
call.subscribe({
next: (res: any) => {
this.busyId = null;
this.snackBar.open(
row.kind === 'unpass'
? `Voucher ${row.request.original_voucher_no} unpassed — the cashier can now enter the correct account`
: `Corrected. New voucher ${res?.newVoucherNo ?? ''}`,
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not approve');
}
});
}
startReject(row: ApprovalRow): void {
this.rejecting = row;
this.rejectReason = '';
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
}
cancelReject(): void {
this.dialogRef?.close();
}
confirmReject(): void {
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
const row = this.rejecting;
const id = row.request.id;
this.busyId = id;
const reason = this.rejectReason.trim();
const call =
row.kind === 'unpass' ? this.service.rejectUnpass(id, reason) : this.service.rejectCorrection(id, reason);
call.subscribe({
next: () => {
this.busyId = null;
this.dialogRef?.close();
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not reject');
}
});
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 8000 });
}
}

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