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
462 changed files with 7380 additions and 49548 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
+1 -2
View File
@@ -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",
@@ -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';
@@ -186,14 +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: 'chart-of-accounts',
data: { title: 'Chart of Accounts', breadcrumb: 'Chart of Accounts' },
@@ -88,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>
-20
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).
@@ -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 }
);
}
});
}
@@ -1,167 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="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) {
<!-- 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"
[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,308 +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;
}
}
.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: 48px;
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,237 +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 = '';
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 = '';
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.';
}
});
}
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);
}
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,103 +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 }}</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,299 +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;
}
&__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,7 +16,6 @@ import {
ReactiveFormsModule
} from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
/** Custom Services */
import { AccountingService } from '../accounting.service';
@@ -42,8 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
GlAccountDisplayComponent,
CurrencyPipe,
MatIconModule
CurrencyPipe
]
})
export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
-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
-2
View File
@@ -62,7 +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 { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -175,7 +174,6 @@ export function HttpLoaderFactory(http: HttpClient) {
CfcfrmsModule,
BankCalendarModule,
DeafModule,
SavingsDailyInterestModule,
DicgcModule,
FiuModule,
GlReportsModule,
@@ -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,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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface RegisterRow {
accountId: number;
accountNo: string;
section: 'SAVINGS' | 'FD' | 'RD' | 'SHARE';
clientName: string | null;
productName: string | null;
status: string;
openedOn: string | null;
closedOn: string | null;
/** True when this account was opened (or closed) on the very date being viewed. */
openedOnDate: boolean;
closedOnDate: boolean;
/** The account's balance as it stood on the register's date, for every section. */
balanceOnDate: number | null;
currentBalance: number | null;
sharesHeld?: number;
}
/**
* Account register for one date: which accounts opened and closed that day, each account's balance
* as it stood then, and what it holds now.
*/
@Component({
selector: 'mifosx-account-register',
templateUrl: './account-register.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./account-register.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class AccountRegisterComponent implements OnInit {
private service = inject(CashierService);
date = '';
section: 'ALL' | 'SAVINGS' | 'FD' | 'RD' | 'SHARE' = 'ALL';
search = '';
/** Narrows to just the accounts that opened or closed on the chosen date. */
movementOnly = false;
rows: RegisterRow[] = [];
openedCount = 0;
closedCount = 0;
totalBalanceOnDate = 0;
totalCurrentBalance = 0;
loading = false;
loaded = false;
errorMsg = '';
readonly sections = [
{ value: 'ALL', label: 'All accounts' },
{ value: 'SAVINGS', label: 'Savings' },
{ value: 'FD', label: 'Fixed Deposit' },
{ value: 'RD', label: 'Recurring Deposit' },
{ value: 'SHARE', label: 'Share' }
];
ngOnInit(): void {
this.date = this.today();
this.load();
}
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
load(): void {
if (!this.date) return;
this.loading = true;
this.errorMsg = '';
this.service
.accountRegister(this.date, this.section)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.message ??
'Could not load the account register.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.rows = res?.accounts ?? [];
this.openedCount = Number(res?.openedCount ?? 0);
this.closedCount = Number(res?.closedCount ?? 0);
this.totalBalanceOnDate = Number(res?.totalBalanceOnDate ?? 0);
this.totalCurrentBalance = Number(res?.totalCurrentBalance ?? 0);
});
}
shiftDate(days: number): void {
const [
y,
m,
d
] = this.date.split('-').map(Number);
const dt = new Date(y, m - 1, d + days);
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
this.load();
}
goToday(): void {
this.date = this.today();
this.load();
}
/** Free-text narrowing over the rows already fetched — no round trip per keystroke. */
get filteredRows(): RegisterRow[] {
const base = this.movementOnly ? this.rows.filter((r) => r.openedOnDate || r.closedOnDate) : this.rows;
const q = this.search.trim().toLowerCase();
if (!q) return base;
return base.filter((r) =>
[
r.accountNo,
r.clientName,
r.productName,
r.status,
r.section
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
);
}
/** Totals for what is on screen, so a filter or search still reconciles. */
get shownBalanceOnDate(): number {
return this.filteredRows.reduce((s, r) => s + Number(r.balanceOnDate ?? 0), 0);
}
get shownCurrentBalance(): number {
return this.filteredRows.reduce((s, r) => s + Number(r.currentBalance ?? 0), 0);
}
statusClass(status: string): string {
switch (status) {
case 'ACTIVE':
return 'db-badge db-badge--ok';
case 'CLOSED':
case 'PREMATURELY CLOSED':
case 'REJECTED':
case 'WITHDRAWN':
return 'db-badge db-badge--rejected';
case 'SUBMITTED':
case 'APPROVED':
return 'db-badge db-badge--pending';
default:
return 'db-badge';
}
}
exportCsv(): void {
const header = [
'Account No',
'Section',
'Client',
'Product',
'Status',
'Opened On',
'Closed On',
`Balance on ${this.date}`,
'Current Balance'
];
const lines = this.filteredRows.map((r) =>
[
r.accountNo,
r.section,
r.clientName ?? '',
r.productName ?? '',
r.status,
r.openedOn ?? '',
r.closedOn ?? '',
r.balanceOnDate == null ? '' : String(r.balanceOnDate),
String(r.currentBalance ?? 0)
]
// Quote every field and double embedded quotes: client names routinely contain commas.
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
const csv = [
header.join(','),
...lines
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `account-register-${this.date}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/** Hands the page to the browser's print dialog; print styles drop the toolbar and filters. */
printRegister(): void {
window.print();
}
}
@@ -1,731 +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>
}
<!-- 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)">
<mat-icon>lock</mat-icon> Close Day
</button>
}
</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>
}
<!-- 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">{{ d.closingCash != null ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}</td>
<td class="num">{{ d.bookCash != null ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}</td>
<td>
@if (d.cashDifference == null) {
—
} @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,344 +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;
}
}
.bd-success {
padding: 10px 14px;
margin-bottom: 14px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
color: #14532d;
font-size: 13px;
}
/* One row per never-closed day, inside the shared red exception panel. */
.bd-missed {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 7px 0;
font-size: 12px;
border-top: 1px dotted #fecaca;
&__age {
color: #b91c1c;
}
/* Pushed right so the date and age read before the action. */
&__act {
margin-left: auto;
height: 28px;
font-size: 11px;
}
}
/* The branch's current day — the one thing someone opening this screen is looking for. */
.bd-current {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 14px 16px;
margin-bottom: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #f59e0b;
border-radius: 8px;
/* No day open at all: nothing is running, so the amber "in progress" edge would mislead. */
&--shut {
border-left-color: #94a3b8;
background: #f8fafc;
}
&__main {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
&__date {
font-size: 15px;
color: #0d1b2a;
}
.pg-btn {
margin-left: auto;
}
}
.bd-panel {
margin-bottom: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
/* A late close is a different act from an ordinary one, and looks like it. */
&--late {
border-left: 4px solid #dc2626;
}
&__wide {
display: block;
margin-top: 12px;
max-width: 640px;
}
}
/* The closing figure's arithmetic, laid out as a running sum so each line can be checked against
the screen it came from. */
.bd-calc {
max-width: 520px;
margin-bottom: 14px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 5px 0;
color: #475569;
&--in .bd-calc__amt {
color: #15803d;
}
&--out .bd-calc__amt {
color: #b91c1c;
}
&--total {
margin-top: 4px;
padding-top: 8px;
border-top: 2px solid #e2e8f0;
font-weight: 800;
font-size: 14px;
color: #0d1b2a;
}
}
&__amt {
font-variant-numeric: tabular-nums;
font-weight: 700;
white-space: nowrap;
}
&__memo {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
font-size: 11px;
color: #94a3b8;
}
}
.bd-hint {
margin: 10px 0 0;
font-size: 11px;
color: #94a3b8;
}
.bd-note {
max-width: 280px;
font-size: 11px;
line-height: 1.4;
}
/* The denomination grid inside the open/close panels — same table chrome as pg-card elsewhere,
just tightened to sit inside the panel instead of spanning the page. */
.bd-denom {
margin: 4px 0 0;
max-width: 520px;
/* Flags a row where the hand-count no longer matches what the vault/cashier positions reported. */
&__auto {
display: block;
margin-top: 2px;
font-size: 10px;
white-space: nowrap;
}
&__reset {
margin-left: 10px;
height: 24px;
padding: 0 10px;
font-size: 11px;
}
}
/* Auto-fetched EOD summary: what the vault and every cashier under it currently hold. */
.bd-eod {
margin: 0 0 12px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #0ea5e9;
}
}
&__denoms {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 4px 0 8px;
}
&__chip {
padding: 2px 8px;
background: #f1f5f9;
border-radius: 999px;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: #334155;
&--up {
background: #dcfce7;
color: #15803d;
}
&--down {
background: #fee2e2;
color: #b91c1c;
}
}
&__cashier {
padding: 6px 0;
border-top: 1px dashed #e2e8f0;
&-head {
display: flex;
justify-content: space-between;
gap: 12px;
font-weight: 600;
color: #0d1b2a;
margin-bottom: 2px;
}
}
&__empty {
font-size: 11px;
}
&__status {
display: flex;
gap: 6px;
margin: 2px 0 6px;
}
&--total {
border-left: 4px solid #0ea5e9;
background: #f0f9ff;
}
&__totals {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px;
margin-bottom: 6px;
font-size: 13px;
font-variant-numeric: tabular-nums;
&-sum {
font-weight: 800;
font-size: 15px;
color: #0d1b2a;
}
}
}
/* Alert shown when cashiers holding cash have not been reconciled or settled — blocks day close. */
.bd-reconcile-block {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 14px 0;
padding: 12px 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 8px;
color: #991b1b;
font-size: 12px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #dc2626;
flex-shrink: 0;
margin-top: 1px;
}
&__list {
margin: 6px 0 6px 16px;
padding: 0;
list-style: disc;
font-size: 12px;
color: #b91c1c;
line-height: 1.6;
}
}
/* Amber, not red: an unreconciled cashier is now a reason to record why the day is being closed
rather than a refusal to close it at all. Red said "you cannot", which was true when the button
was disabled and misleading once a force close became possible. */
.bd-reconcile-block--warn {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
color: #92400e;
mat-icon {
color: #d97706;
}
}
@@ -1,676 +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 } 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
]
})
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 = '';
// 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);
}
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.service
.branchDayMissed()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.missed = res?.missed ?? [];
});
}
/** True when the branch has no open day at all, so today can be opened. */
get canOpenToday(): boolean {
return this.canOperate && !this.openDay;
}
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) : '—';
}
}
@@ -50,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">
@@ -59,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>
@@ -88,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>
@@ -111,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>
@@ -134,24 +111,13 @@
}
@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()">
@@ -1,51 +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;
}
@@ -29,10 +29,7 @@ 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,
@@ -73,14 +70,6 @@ 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;
ngOnInit(): void {
const d = new Date();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -112,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);
@@ -121,7 +109,6 @@ export class CashTransferComponent implements OnInit {
this.toParty = '';
this.toCashierId = null;
this.cashiers = [];
this.loadVaultAvailability();
if (!tellerId) {
return;
}
@@ -138,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;
@@ -204,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 {
+1 -164
View File
@@ -19,11 +19,6 @@ 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 { 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';
@@ -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,14 +78,6 @@ const routes: Routes = [
),
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
},
{
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: () =>
@@ -117,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: () =>
@@ -194,57 +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' }
},
{
// 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' },
@@ -289,57 +172,11 @@ const routes: Routes = [
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,
-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;
}
+2 -233
View File
@@ -37,10 +37,8 @@ export class CashierService {
}
// ── 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');
@@ -115,185 +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 });
}
/**
* 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 });
}
/** 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 });
}
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
@@ -369,54 +188,4 @@ export class CashierService {
getShareCollectionStatus(shareAccountId: number): Observable<any> {
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
}
/** 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 });
}
/** 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');
}
/** 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) } });
}
/** Receipts still sitting in Share Sundry, optionally for a single member. */
getUnappliedShareAdvances(clientId?: number): Observable<any> {
const suffix = clientId ? `?clientId=${clientId}` : '';
return this.http.get(`/shareadvancecollection${suffix}`);
}
/** 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);
}
/** 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}`);
}
}
@@ -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 });
}
+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>
}
@@ -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>
@@ -38,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[] = [];
@@ -108,86 +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);
}
get noteNetCount(): number {
return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount;
}
/**
* What is left in the drawer in notes: everything that came in, less everything that went out.
*
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's
* contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net
* of zero and had nothing to count against.
*/
get noteNetAmount(): number {
return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount;
}
get coinRows(): any[] {
return this.rows.filter((r) => r.kind === 'coin');
}
@@ -206,6 +84,7 @@ export class ClosingCashReportComponent implements OnInit {
}
load(): void {
this.showGl = false;
this.loading = true;
this.service.closingCashReport(this.date).subscribe({
next: (res: any) => {
@@ -214,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;
},
@@ -236,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,270 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<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>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>{{ 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="5"></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,378 +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';
/**
* 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;
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
]
})
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 = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
load(): void {
if (!this.date) return;
this.loading = true;
this.errorMsg = '';
this.service
.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();
}
}
@@ -1,204 +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>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. -->
<tr [class]="'mw-row mw-row--' + r.myRole">
<td class="mw-vno">{{ r.voucherNo }}</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">+{{ 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 class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
}
</div>
@@ -1,294 +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;
}
}
/* 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,330 +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';
/** 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;
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
]
})
export class MyWorkComponent implements OnInit {
private service = inject(CashierService);
/** 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 };
}
private todayIso(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -1,497 +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, 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;
}
@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>{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}</h2>
<p>
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">
<!-- Draft & Financials Summary Card -->
<div class="dd-summary-card">
<div class="summary-col">
<span class="label">Auto-Selected Leaflet</span>
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
</div>
<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">
₹{{ 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>
}
<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">
<button mat-button type="button" (click)="onClose()">Cancel</button>
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
Submit for Manager Approval
</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;
}
`
]
})
export class CashierIssueDdDialogComponent implements OnInit {
private fb = inject(FormBuilder);
private dialogRef = inject(MatDialogRef<CashierIssueDdDialogComponent>);
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
form!: 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';
}
ngOnInit(): void {
// 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.form.invalid) 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
}
);
}
}
@@ -120,9 +120,12 @@
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@for (t of typOptions; track t.value) {
<option [value]="t.value">{{ t.label }}</option>
}
<option value="~">~</option>
<option value="Withdrawal Slip">Withdrawal Slip</option>
<option value="Cheque">Cheque</option>
<option value="Disbursement">Disbursement</option>
<option value="Enter Bank Transfer">Enter Bank Transfer</option>
<option value="DD">DD (Demand Draft)</option>
</select>
</div>
<div class="ws-f ws-f--branch">
@@ -134,23 +137,6 @@
<option [ngValue]="27">027 · Industrial</option>
</select>
</div>
@if (row.typ === 'DD') {
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl ws-f__lbl--accent" title="Sponsor Bank">SPONSOR BANK</label>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.sponsorBankId"
(click)="$event.stopPropagation()"
>
@if (sponsorBanks.length === 0) {
<option [ngValue]="null">-- Select Bank --</option>
}
@for (sb of sponsorBanks; track sb.id) {
<option [ngValue]="sb.id">{{ sb.name }}</option>
}
</select>
</div>
}
<div class="ws-f ws-f--paytype">
<label class="ws-f__lbl ws-f__lbl--accent" title="Transaction Type">TXN TP.</label>
<select
@@ -159,6 +145,7 @@
(click)="$event.stopPropagation()"
[class.ws-f__ctl--req]="!row.paymentTypeId"
>
<option [ngValue]="null">— Select —</option>
@for (pt of paymentTypesForRow(row); track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option>
}
@@ -173,7 +160,6 @@
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
@@ -199,22 +185,7 @@
(input)="onAcNoInput(row, i, $any($event.target).value)"
(click)="$event.stopPropagation()"
(blur)="onAcNoBlur(row, i)"
[placeholder]="
isDdAccountDebitRow(row)
? 'Account to debit'
: row.typ === 'DD'
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
: 'Account number'
"
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
[title]="
isDdAccountDebitRow(row)
? 'Member account the draft is funded from'
: row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
"
placeholder="Account number"
/>
</div>
<div class="ws-f ws-f--ecsno">
@@ -234,10 +205,7 @@
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
row.acTyp === 'GL' && row.typ !== 'Disbursement' && row.typ !== 'Enter Bank Transfer'
"
(click)="row.dbCr = 'Dr'"
>
@@ -268,14 +236,14 @@
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
>
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
@if (!isClerk && (activeWithdrawalRequest || activeClosureRequest)) {
<span class="ws-amt-locked-badge" title="Amount locked — approved request">🔒</span>
@if (!isClerk && activeWithdrawalRequest) {
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
}
</label>
<div
class="ws-amt-wrap"
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
[class.ws-amt-wrap--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
>
@if (row.acTyp !== 'SHR') {
<span class="ws-amt-prefix">₹</span>
@@ -287,8 +255,8 @@
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[readonly]="!isClerk && !!activeWithdrawalRequest"
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
/>
</div>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
@@ -299,7 +267,7 @@
>
}
</div>
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl" title="Board Resolution Number">RESOLUTION NO.</label>
<input
@@ -318,7 +286,7 @@
(click)="$event.stopPropagation()"
/>
</div>
} @else if (isClerk && row.typ !== 'Withdrawal Slip') {
} @else if (row.typ !== 'Withdrawal Slip') {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
<input
@@ -343,8 +311,8 @@
<input
class="ws-f__ctl"
[(ngModel)]="row.tokNo"
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[class.ws-f__ctl--readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[readonly]="!isClerk && !!activeWithdrawalRequest"
[class.ws-f__ctl--readonly]="!isClerk && !!activeWithdrawalRequest"
placeholder="Enter token"
(blur)="onTokNoBlur(row)"
(click)="$event.stopPropagation()"
@@ -372,7 +340,7 @@
</div>
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
@if (isDdRow(row)) {
@if (isDdCashGlRow(row)) {
<div class="ws-group-row ws-group-row--mt">
<div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
@@ -407,7 +375,7 @@
</div>
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
{{ isDdAccountDebitRow(row) ? 'TOTAL TO DEBIT' : 'TOTAL TO COLLECT' }}
TOTAL TO COLLECT
</label>
<div class="ws-amt-wrap ws-amt-wrap--locked">
<span class="ws-amt-prefix">₹</span>
@@ -419,23 +387,6 @@
/>
</div>
</div>
<div class="ws-f" style="margin-left: auto; align-self: flex-end">
<button
type="button"
class="te-dd-issue-btn"
[disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]="
isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
"
>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
<span>Fill DD Details &amp; Send for Approval</span>
</button>
</div>
</div>
</div>
</div>
@@ -447,18 +398,6 @@
</div>
}
<!-- ── DD GL UNCONFIGURED WARNING BANNER ────────────────────────── -->
@if (selectedRow?.typ === 'DD' && !isDdGlMappingConfigured) {
<div class="ws-dd-unconfigured-banner">
<mat-icon>warning</mat-icon>
<div class="ws-dd-unconfigured-banner__text">
<strong>DD GL Mapping Required:</strong> Demand Draft GL accounts have not been configured yet. Please ask an
administrator to set up the mapping under <em>Admin &gt; Sponsor DD GST / GL Settings</em> before issuing
drafts.
</div>
</div>
}
<!-- ── APPROVED WITHDRAWAL BANNER (cashier disburse flow) ─────────── -->
@if (!isClerk && loadingWithdrawal) {
<div class="ws-withdrawal-banner ws-withdrawal-banner--loading">
@@ -497,73 +436,6 @@
</button>
</div>
}
@if (isClerk && pendingClosure) {
<div class="ws-withdrawal-banner ws-withdrawal-banner--closure">
<mat-icon class="ws-withdrawal-banner__icon">lock</mat-icon>
<div class="ws-withdrawal-banner__content">
<div class="ws-withdrawal-banner__title">Closing This Account</div>
<div class="ws-withdrawal-banner__details">
@if (closurePreview) {
<span
>Balance: <strong>₹{{ closurePreview.balance | number: '1.2-2' }}</strong></span
>
<span class="ws-wb-sep">·</span>
<span
>Interest: <strong>+₹{{ closurePreview.interest | number: '1.2-2' }}</strong></span
>
<span class="ws-wb-sep">·</span>
<span
>Payable: <strong>₹{{ closurePreview.total | number: '1.2-2' }}</strong></span
>
}
</div>
<div class="ws-withdrawal-banner__note">
Enter <strong>TOK. NO.</strong> below and press <strong>Save</strong> to send this for the Bank Manager's
approval. The account closes when the cashier hands over the cash.
</div>
</div>
<button type="button" class="ws-withdrawal-banner__clear" (click)="clearPendingClosure()" title="Cancel closure">
<mat-icon>close</mat-icon>
</button>
</div>
}
@if (!isClerk && activeClosureRequest) {
<div class="ws-withdrawal-banner">
<mat-icon class="ws-withdrawal-banner__icon">lock</mat-icon>
<div class="ws-withdrawal-banner__content">
<div class="ws-withdrawal-banner__title">Approved Account Closure Linked</div>
<div class="ws-withdrawal-banner__details">
<span
>Token: <strong>{{ activeClosureRequest.tok_no }}</strong></span
>
<span class="ws-wb-sep">·</span>
<span
>Cash to Hand Over:
<strong>₹{{ activeClosureRequest.cashier_disbursed_amount | number: '1.2-2' }}</strong></span
>
<span class="ws-wb-sep">·</span>
<span
>Client: <strong>{{ activeClosureRequest.client_name }}</strong></span
>
<span class="ws-wb-sep">·</span>
<span>By Clerk: {{ activeClosureRequest.clerk_name }}</span>
<span class="ws-wb-sep">·</span>
<span>Approved by: {{ activeClosureRequest.manager_name || '—' }}</span>
</div>
<div class="ws-withdrawal-banner__note">
@if (activeClosureRequest.rounding_amount && activeClosureRequest.rounding_amount !== 0) {
Exact closing balance was ₹{{ activeClosureRequest.closing_amount | number: '1.2-2' }} — rounded
{{ activeClosureRequest.rounding_amount > 0 ? 'up' : 'down' }} to whole rupees for cash.
} @else {
The account is already closed. Hand over the cash to complete the request.
}
</div>
</div>
<button type="button" class="ws-withdrawal-banner__clear" (click)="activeClosureRequest = null" title="Unlink">
<mat-icon>close</mat-icon>
</button>
</div>
}
<!-- ── STATUS ────────────────────────────────────────────────────────── -->
@if (loadingAccount) {
@@ -932,7 +804,7 @@
</div>
<div class="te-balance-row">
<span class="te-bal-num">4.</span><span class="te-bal-label">Freeze Amount</span>
<span class="te-bal-value">{{ currencySymbol }}{{ freezeAmount | number: '1.2-2' }}</span>
<span class="te-bal-value">{{ currencySymbol }}0.00</span>
</div>
<div class="te-balance-row">
<span class="te-bal-num">5.</span><span class="te-bal-label">Withdrawable Amt.</span>
@@ -993,7 +865,7 @@
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">Cash Hold</span>
<span class="te-bal-item__val">{{ cashHold | number: '1.2-2' }}</span>
<span class="te-bal-item__val">0.00</span>
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">Transfer Hold</span>
@@ -1001,7 +873,7 @@
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">Clearing Hold</span>
<span class="te-bal-item__val">{{ clearingHold | number: '1.2-2' }}</span>
<span class="te-bal-item__val">0.00</span>
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">After Trans.</span>
@@ -1017,11 +889,6 @@
<span class="te-bal-item__label">Currency</span>
<span class="te-bal-item__val">{{ accountDetails.currency?.code ?? 'INR' }}</span>
</div>
@if (canPrintStatement) {
<button type="button" class="te-btn" (click)="printStatement()">
<mat-icon>print</mat-icon> Statement
</button>
}
</div>
} @else {
<!-- Cashier: all account info in one compact strip -->
@@ -1122,7 +989,7 @@
</div>
<div class="te-acs-b">
<span class="te-acs-b-l">Cash Hold</span>
<span class="te-acs-b-v">{{ cashHold | number: '1.2-2' }}</span>
<span class="te-acs-b-v">0.00</span>
</div>
<div class="te-acs-b">
<span class="te-acs-b-l">Tr. Hold</span>
@@ -1130,19 +997,13 @@
</div>
<div class="te-acs-b">
<span class="te-acs-b-l">Clr. Hold</span>
<span class="te-acs-b-v">{{ clearingHold | number: '1.2-2' }}</span>
<span class="te-acs-b-v">0.00</span>
</div>
<div class="te-acs-b te-acs-b--after">
<span class="te-acs-b-l">After Trans.</span>
<span class="te-acs-b-v te-acs-b-v--cr">{{ currencySymbol }}{{ clearBalance | number: '1.2-2' }}</span>
</div>
</div>
@if (canPrintStatement) {
<div class="te-acs-sep"></div>
<button type="button" class="te-btn" (click)="printStatement()">
<mat-icon>print</mat-icon> Statement
</button>
}
<!-- Alerts -->
@if (accountAlerts.length > 0) {
<div class="te-acs-sep"></div>
@@ -1465,51 +1326,32 @@
<div class="ws-group__fields">
<div class="ws-f ws-f--type">
<label class="ws-f__lbl">TYPE</label>
<select
class="ws-f__ctl"
[(ngModel)]="row.typ"
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@for (t of typOptions; track t.value) {
<option [value]="t.value">{{ t.label }}</option>
}
<select class="ws-f__ctl" [(ngModel)]="row.typ">
<option value="~">~</option>
<option value="Withdrawal Slip">Withdrawal Slip</option>
<option value="Cheque">Cheque</option>
<option value="Disbursement">Disbursement</option>
<option value="Enter Bank Transfer">Enter Bank Transfer</option>
<option value="DD">DD (Demand Draft)</option>
</select>
</div>
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl">BRANCH</label>
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
<select class="ws-f__ctl" [(ngModel)]="row.br">
<option [ngValue]="1">001 · Main</option>
<option [ngValue]="2">002 · M.G. Road</option>
<option [ngValue]="14">014 · Station Rd</option>
<option [ngValue]="27">027 · Industrial</option>
</select>
</div>
@if (row.typ === 'DD') {
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl ws-f__lbl--accent" title="Sponsor Bank">SPONSOR BANK</label>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.sponsorBankId"
(click)="$event.stopPropagation()"
>
@if (sponsorBanks.length === 0) {
<option [ngValue]="null">-- Select Bank --</option>
}
@for (sb of sponsorBanks; track sb.id) {
<option [ngValue]="sb.id">{{ sb.name }}</option>
}
</select>
</div>
}
<div class="ws-f ws-f--paytype">
<label class="ws-f__lbl ws-f__lbl--accent">TXN TP.</label>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.paymentTypeId"
(click)="$event.stopPropagation()"
[class.ws-f__ctl--req]="!row.paymentTypeId"
>
<option [ngValue]="null">— Select —</option>
@for (pt of paymentTypesForRow(row); track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option>
}
@@ -1520,11 +1362,9 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
@@ -1547,14 +1387,7 @@
[value]="row.acNo"
(input)="onAcNoInput(row, i, $any($event.target).value)"
(blur)="onAcNoBlur(row, i)"
[placeholder]="
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
"
[readonly]="row.typ === 'DD'"
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
[title]="
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
"
placeholder="Account number"
/>
</div>
<div class="ws-f ws-f--ecsno">
@@ -1573,10 +1406,7 @@
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
row.acTyp === 'GL' && row.typ !== 'Disbursement' && row.typ !== 'Enter Bank Transfer'
"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
@@ -1602,16 +1432,14 @@
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl">
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
@if (!isClerk && (activeWithdrawalRequest || activeClosureRequest)) {
<span class="ws-amt-locked-badge" title="Amount locked — approved request">🔒</span>
@if (!isClerk && activeWithdrawalRequest) {
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
}
</label>
<div
class="ws-amt-wrap"
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
[class.ws-amt-wrap--locked]="
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
"
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
>
@if (row.acTyp !== 'SHR') {
<span class="ws-amt-prefix">₹</span>
@@ -1623,10 +1451,8 @@
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[class.ws-amt-input--locked]="
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
"
[readonly]="!isClerk && !!activeWithdrawalRequest"
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
/>
</div>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
@@ -1637,9 +1463,7 @@
>
}
</div>
@if (
row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
) {
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl">RESOLUTION NO.</label>
<input
@@ -1652,7 +1476,7 @@
<label class="ws-f__lbl">MEETING DATE</label>
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
</div>
} @else if (isClerk) {
} @else {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl">CHEQUE NO.</label>
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
@@ -1667,10 +1491,8 @@
<input
class="ws-f__ctl"
[(ngModel)]="row.tokNo"
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[class.ws-f__ctl--readonly]="
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
"
[readonly]="!isClerk && !!activeWithdrawalRequest"
[class.ws-f__ctl--readonly]="!isClerk && !!activeWithdrawalRequest"
placeholder="Enter token"
(blur)="onTokNoBlur(row)"
/>
@@ -1683,19 +1505,14 @@
<div class="ws-group__fields">
<div class="ws-f ws-f--desc">
<label class="ws-f__lbl">DESCRIPTION</label>
<input
class="ws-f__ctl"
[(ngModel)]="row.description"
(click)="$event.stopPropagation()"
placeholder="Narration / remarks"
/>
<input class="ws-f__ctl" [(ngModel)]="row.description" placeholder="Narration / remarks" />
</div>
</div>
</div>
</div>
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
@if (isDdRow(row)) {
@if (isDdCashGlRow(row)) {
<div class="ws-group-row ws-group-row--mt">
<div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
@@ -1709,7 +1526,6 @@
type="number"
[value]="row.ddCharges ?? ''"
(input)="row.ddCharges = +$any($event.target).value"
(click)="$event.stopPropagation()"
placeholder="0.00"
/>
</div>
@@ -1730,7 +1546,7 @@
</div>
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
{{ isDdAccountDebitRow(row) ? 'TOTAL TO DEBIT' : 'TOTAL TO COLLECT' }}
TOTAL TO COLLECT
</label>
<div class="ws-amt-wrap ws-amt-wrap--locked">
<span class="ws-amt-prefix">₹</span>
@@ -1742,23 +1558,6 @@
/>
</div>
</div>
<div class="ws-f" style="margin-left: auto; align-self: flex-end">
<button
type="button"
class="te-dd-issue-btn"
[disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]="
isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
"
>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
<span>Fill DD Details &amp; Send for Approval</span>
</button>
</div>
</div>
</div>
</div>
@@ -1856,22 +1655,9 @@
class="ws-btn ws-btn--ghost"
type="button"
(click)="router.navigate(['/clerk/my-disbursement-requests'])"
title="View my fund requests — Disbursement, Collection, Fund Transfer and Bank Transfer"
title="View my disbursement requests"
>
Disbursements &amp; Transfer
</button>
}
@if (canShowAccountActions) {
<button
class="ws-btn ws-btn--action"
type="button"
(click)="openAccountActions()"
title="Account actions for the loaded account"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
Action
My Disbursements
</button>
}
</div>
@@ -1895,91 +1681,6 @@
}
</div>
<!-- ── ACCOUNT ACTIONS (clerk) ──────────────────────────────────────────── -->
@if (showAccountActions) {
<div class="te-confirm-overlay" (click)="closeAccountActions()">
<div class="te-confirm-box te-aa-box" (click)="$event.stopPropagation()">
<div class="te-confirm-header te-confirm-header--action">
<span class="te-confirm-icon">⚡</span>
<span>Account Actions</span>
</div>
<div class="te-confirm-body">
<div class="te-aa-account">
<span class="te-aa-account__no">{{ accountDetails?.accountNo }}</span>
<span class="te-aa-account__name">{{ clientName }}</span>
</div>
<label class="te-aa-check">
<input type="checkbox" [(ngModel)]="closeAccountChecked" (change)="onCloseAccountToggle()" />
<span>
<strong>Close this account</strong>
<small>Pays out the full balance in cash and closes the account, after manager approval.</small>
</span>
</label>
@if (closeAccountChecked) {
@if (closurePreviewLoading) {
<div class="te-aa-calc te-aa-calc--loading">Posting interest to the account…</div>
} @else if (closurePreviewError) {
<div class="te-aa-calc te-aa-calc--error">{{ closurePreviewError }}</div>
} @else if (closurePreview) {
<div class="te-aa-calc">
<div class="te-aa-calc__row">
<span>Current Balance</span>
<span>₹{{ closurePreview.balance | number: '1.2-2' }}</span>
</div>
@if (closurePreview.interest < 0) {
<!-- Negative only when this run corrects interest that was over-posted earlier: the
delta is against the stored balance, so calling it "interest" would read as the
member being charged. The Payable figure below is the authoritative one either way. -->
<div class="te-aa-calc__row">
<span>Interest correction</span>
<span>− ₹{{ -closurePreview.interest | number: '1.2-2' }}</span>
</div>
} @else {
<div class="te-aa-calc__row">
<span>Interest to date</span>
<span>+ ₹{{ closurePreview.interest | number: '1.2-2' }}</span>
</div>
}
<div class="te-aa-calc__row te-aa-calc__row--total">
<span>Payable on Closure</span>
<span>₹{{ closurePreview.total | number: '1.2-2' }}</span>
</div>
@if (!closurePreview.upToDate) {
<div class="te-aa-calc__warn">
Interest could not be calculated just now, so this shows the current balance only — the actual payout
will be higher once interest is added at approval.
</div>
}
<div class="te-aa-calc__note">
Interest has been posted to the account as of {{ closurePreview.asOfDate }} — this is the account's real
balance now, not an estimate. Cash is handed over rounded to whole rupees.
</div>
</div>
<div class="te-aa-next">
This amount will be filled into <strong>AMOUNT</strong> on the slip. Enter <strong>TOK. NO.</strong> and
press <strong>Save</strong> to send it for the Bank Manager's approval.
</div>
}
}
</div>
<div class="te-confirm-actions">
<button class="te-btn" type="button" (click)="closeAccountActions()">Cancel</button>
<button
class="te-btn te-btn--primary"
type="button"
[disabled]="closeAccountChecked && (!closurePreview || closurePreviewLoading)"
(click)="applyCloseAccount()"
>
Save
</button>
</div>
</div>
</div>
}
<!-- ── REJECT DIALOG (manager review) ───────────────────────────────────── -->
@if (showRejectDialog) {
<div class="te-confirm-overlay" (click)="cancelRejectReview()">
@@ -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;
@@ -2350,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;
@@ -3281,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;
@@ -3384,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,115 +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';
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 = new Date();
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,237 +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 vr-no-print">
<div>
<div class="pg-eyebrow">CASHIER · REPORT</div>
<h1 class="pg-title">{{ title }}</h1>
<p class="pg-sub">{{ subtitle }}</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>
@if (report?.canSeeAllUsers) {
<label class="pg-field">
<span>User</span>
<input
type="text"
[(ngModel)]="usernameFilter"
(keyup.enter)="load()"
placeholder="All users"
autocomplete="off"
/>
</label>
}
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Run
</button>
@if (report && count > 0) {
<button type="button" class="pg-btn" (click)="print()"><mat-icon>print</mat-icon> Print</button>
}
</div>
</header>
@if (error) {
<div class="pg-alert pg-alert--error vr-no-print">{{ error }}</div>
}
@if (loading) {
<div class="pg-empty vr-no-print">Loading…</div>
}
@if (report && !loading) {
<div class="vr-period">
{{ report.from | date: 'dd MMM yyyy' }}
@if (report.from !== report.to) {
— {{ report.to | date: 'dd MMM yyyy' }}
}
· {{ count }} {{ noun }}{{ count === 1 ? '' : 's' }} · ₹{{ totalAmount | number: '1.2-2' }}
@if (report.username) {
· {{ report.username }}
}
<!-- Said plainly rather than left to be inferred from a missing filter: a cashier should know
this is their own work and not the branch's. -->
@if (!report.canSeeAllUsers) {
<span class="vr-scope-note">Your own vouchers</span>
}
<!-- The total on a cash register means something narrower than the whole voucher book, so the
screen says which one it is instead of leaving the figure ambiguous. -->
@if (cashOnly) {
<span class="vr-scope-note">Cash only</span>
}
</div>
<!-- A voucher with no CR/DR prefix belongs to neither report. Say so, so the two reports not
adding up to the day's total is explained rather than left to be puzzled over. -->
@if (unclassifiedCount > 0) {
<div class="pg-alert vr-no-print">
{{ unclassifiedCount }} voucher{{ unclassifiedCount === 1 ? '' : 's' }} in this period
{{ unclassifiedCount === 1 ? 'carries' : 'carry' }} no CR or DR prefix, so
{{ unclassifiedCount === 1 ? 'it appears' : 'they appear' }} on neither the Received nor the Payment report.
</div>
}
<!-- Which part of the total has notes and coins counted behind it. Shown on the cash register
as well as the full one: a voucher with no breakdown is still real money, so it stays in
the total, and what the drawer can be checked against is stated instead of assumed. -->
@if (uncountedCount > 0) {
<div class="pg-alert vr-no-print">
{{ uncountedCount }} of these {{ noun }}s {{ uncountedCount === 1 ? 'has' : 'have' }} no denomination breakdown
recorded, totalling ₹{{ uncountedTotal | number: '1.2-2' }}. Some registers pay out without counting notes
against the voucher, so these are still cash — ₹{{ countedTotal | number: '1.2-2' }} of the total has a
breakdown behind it.
</div>
}
<!-- Explains the blank Debit/Credit cells instead of leaving them looking like lost data. -->
@if (missingSidesCount > 0) {
<div class="pg-alert vr-no-print">
{{ missingSidesCount }} of these {{ noun }}s {{ missingSidesCount === 1 ? 'has' : 'have' }} no debit or credit
recorded. The sides are attached when a request is approved, so vouchers raised before that step was added keep
their number and amount but show no accounts. Newly raised {{ noun }}s carry both sides.
</div>
}
@if (count === 0) {
<div class="pg-empty">No {{ noun }}s were raised in this period.</div>
} @else {
<div class="vr-summaries">
<div class="pg-card">
<div class="pos-section-label">By type</div>
<table class="pg-table">
<thead>
<tr>
<th>Type</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of byType; track row.type) {
<tr>
<td>{{ row.type }}</td>
<td class="num">{{ row.count }}</td>
<td class="num">{{ row.total | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Only worth showing to someone who can see more than one person's work. -->
@if (report.canSeeAllUsers) {
<div class="pg-card">
<div class="pos-section-label">By user</div>
<table class="pg-table">
<thead>
<tr>
<th>User</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of byUser; track row.username) {
<tr>
<td>{{ row.username }}</td>
<td class="num">{{ row.count }}</td>
<td class="num">{{ row.total | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<div class="pg-card">
<div class="pos-section-label">{{ isReceived ? 'Receipts' : 'Payments' }}</div>
<table class="pg-table vr-rows">
<thead>
<tr>
<th>Voucher No</th>
<th>Type</th>
<th>Debit</th>
<th>Credit</th>
<th class="num">Amount</th>
<th>User</th>
<!-- Two dates, not one, on the system register.
A voucher's timestamp is the minute it was written; the posting behind it carries
the business date. A nightly run finishing after midnight writes today's voucher
for yesterday's work, so a reader opening this screen today is looking at last
night's postings — and without the ledger date they would take the timestamp for
the day the money moved. -->
@if (systemOnly) {
<th>Ledger date</th>
}
<th>Time</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.voucherNo) {
<tr>
<td>
{{ row.voucherNo }}
@if (row.verificationStatus === 'EXCEPTION') {
<mat-icon class="vr-flag" title="An exception was raised against this voucher"
>error_outline</mat-icon
>
}
</td>
<td>{{ row.voucherType || '—' }}</td>
<!-- A voucher raised before its sides were recorded has neither. Say that once,
across both cells, rather than showing two dashes that read as "nowhere". -->
@if (!hasSides(row)) {
<td colspan="2" class="vr-nosides">Sides not recorded</td>
} @else {
<td>
<div class="vr-party">{{ row.debitParty || '—' }}</div>
@if (row.debitAccount) {
<div class="vr-acct">{{ row.debitAccount }}</div>
}
</td>
<td>
<div class="vr-party">{{ row.creditParty || '—' }}</div>
@if (row.creditAccount) {
<div class="vr-acct">{{ row.creditAccount }}</div>
}
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
@if (row.extraLines > 0) {
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
}
</td>
}
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
<td>{{ row.username || '—' }}</td>
@if (systemOnly) {
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
{{ row.ledgerDate ? (row.ledgerDate | date: 'dd MMM yyyy') : '—' }}
@if (row.postedAfterClose) {
<span class="vr-priorflag" title="Posted after that day had closed">after close</span>
}
</td>
}
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
@@ -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/.
*/
/* Page chrome, cards and tables come from cashier-shared.scss. Only the register itself is here. */
.vr-period {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
font-size: 13px;
color: #374151;
}
.vr-scope-note {
padding: 2px 8px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 999px;
color: #1e40af;
font-size: 11px;
}
/* The two summaries sit side by side where there is room and stack where there is not, so a
manager reads the totals before the rows on any screen. */
.vr-summaries {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
margin-bottom: 14px;
}
.vr-rows {
.vr-flag {
margin-left: 4px;
font-size: 15px;
width: 15px;
height: 15px;
color: #b91c1c;
vertical-align: text-bottom;
}
.vr-party {
color: #111827;
}
.vr-acct,
.vr-more {
margin-top: 2px;
font-size: 11px;
color: #6b7280;
}
// Spans the Debit and Credit cells for a voucher raised before its sides were recorded.
.vr-nosides {
font-size: 11px;
font-style: italic;
color: #9ca3af;
}
}
/* Printing a report should put the register on paper — the filters are how you reached it. */
@media print {
.vr-no-print {
display: none !important;
}
}
/* The ledger date on the system register. Marked when it differs from the day the voucher was
written: that is the whole reason the column exists, and a date the reader has to compare against
the Time column by eye is a date they will misread. */
.vr-ledgerdate {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.vr-ledgerdate--prior {
color: #5b21b6;
}
.vr-priorflag {
display: block;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #7c3aed;
}
@@ -1,351 +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 { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
/**
* Which side of the register a screen shows.
*
* 'CR' is the Received report (money in), 'DR' the Payment report (money out). Set from the route,
* so the two screens are the same component pointed at opposite sides.
*/
export type VoucherSide = 'CR' | 'DR' | 'ALL';
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow {
voucherNo: string;
voucherType: string | null;
amount: number | null;
createdAt: string | null;
/** The business date the posting belongs to, which is not always the day it was written. */
ledgerDate?: string | null;
/** True when the voucher was written after the day it posts against had already closed. */
postedAfterClose?: boolean;
userId: number | null;
username: string | null;
branchName: string | null;
verificationStatus: string | null;
debitParty: string | null;
debitAccount: string | null;
creditParty: string | null;
creditAccount: string | null;
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
extraLines: number;
/**
* Whether a denomination breakdown was recorded against this voucher.
*
* True proves cash was counted. False does not prove the opposite: several registers pay real
* money out without recording a breakdown, so this marks a row as unconfirmed, not as non-cash.
*/
denominationCounted: boolean;
}
interface VoucherReport {
from: string;
to: string;
username: string | null;
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
canSeeAllUsers: boolean;
/** Whether the server restricted the register to cash movements. */
cashOnly: boolean;
vouchers: VoucherRow[];
count: number;
totalAmount: number;
}
/**
* The vouchers raised over a day or a month, one row each.
*
* A cashier sees their own work; a clerk, head cashier or manager sees everyone's and can narrow to
* one user. That is decided by the server, not here — this screen only shows or hides the filter.
*/
@Component({
selector: 'mifosx-voucher-report',
templateUrl: './voucher-report.component.html',
styleUrls: [
'../cashier-shared.scss',
'./voucher-report.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class VoucherReportComponent implements OnInit {
private service = inject(CashierService);
private route = inject(ActivatedRoute);
/** Day is the working default; a month is what you ask for at a close. */
period: 'DAY' | 'MONTH' = 'DAY';
/** Anchor date — the day itself, or any day inside the month wanted. */
anchor = this.todayIso();
usernameFilter = '';
typeFilter = '';
/** Which side this screen is: Received (CR) or Payment (DR). Comes from the route's data. */
side: VoucherSide = 'CR';
/**
* Whether this screen is the cash register rather than the whole voucher register.
*
* A cashier handles notes and coins, so entries that cannot be cash — an interest posting, a
* clearing settlement, a cheque book fee — are left out, and everything that could have crossed
* the counter stays in. The filter errs towards keeping a row: a book entry on the register can
* be seen and recognised, whereas cash that never appears is invisible until it turns up as an
* unexplained difference at the end of the day.
*/
cashOnly = false;
/**
* Whether this register is the counter's own — only the people who hold a drawer.
*
* These two screens reconcile the counter: what came in over it and what went out. Everyone
* else's work is their own to account for, and each person reads it on My Vouchers. Mixed in here
* it buried the cashier rows the registers exist for and left the totals matching no drawer.
*/
cashiersOnly = false;
/**
* Whether this register is the system's own postings rather than anybody's work.
*
* Interest a nightly run credited, a charge taken from a balance, an escheat, a journal keyed
* straight to the ledger. These belong to no member of staff, so My Vouchers cannot show them —
* it is scoped to the reader — and the two counter registers deliberately exclude them. That left
* no screen answering "what did the system post last night".
*/
systemOnly = false;
report: VoucherReport | null = null;
loading = false;
error: string | null = null;
ngOnInit(): void {
this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR';
this.cashOnly = this.route.snapshot.data['cashOnly'] === true;
this.cashiersOnly = this.route.snapshot.data['cashiersOnly'] === true;
this.systemOnly = this.route.snapshot.data['systemOnly'] === true;
this.load();
}
get isReceived(): boolean {
return this.side === 'CR';
}
get title(): string {
if (this.systemOnly) {
return 'System Vouchers';
}
return this.isReceived ? 'Received Report' : 'Payment Report';
}
get subtitle(): string {
if (this.systemOnly) {
return 'What the system posted on its own — interest, charges, escheats and journals. Nobody raised these, so they appear on no one’s own list.';
}
// Said plainly, because a register that silently omits the reader's own entries is worse than
// one that explains why: their work is on My Vouchers, and the line points them there.
if (this.cashiersOnly) {
return this.isReceived
? 'Cash taken in at the counter by each cashier. Other staff read their own work on My Vouchers.'
: 'Cash paid out at the counter by each cashier. Other staff read their own work on My Vouchers.';
}
if (this.cashOnly) {
return this.isReceived
? 'Cash taken in over a day or a month, and who took it — book entries left out.'
: 'Cash paid out over a day or a month, and who paid it — book entries left out.';
}
return this.isReceived
? 'Every receipt voucher raised over a day or a month — money taken in, and who took it.'
: 'Every payment voucher raised over a day or a month — money paid out, and who paid it.';
}
/** The word for one row on this side, for counts and the empty state. */
get noun(): string {
if (this.systemOnly) {
return 'posting';
}
return this.isReceived ? 'receipt' : 'payment';
}
load(): void {
this.loading = true;
this.error = null;
const { from, to } = this.range();
this.service
.voucherReport(
from,
to,
this.usernameFilter.trim() || undefined,
this.typeFilter.trim() || undefined,
this.cashOnly,
this.cashiersOnly,
this.systemOnly
)
.pipe(
catchError((err: any) => {
this.error =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
'The voucher report could not be loaded.';
return of(null);
})
)
.subscribe((res: VoucherReport | null) => {
this.loading = false;
this.report = res;
});
}
/** 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 [
year,
month
] = this.anchor.split('-').map(Number);
// Day 0 of the next month is the last day of this one, which is how a 28/30/31-day month is
// ended correctly without a table of month lengths.
const lastDay = new Date(year, month, 0).getDate();
const mm = String(month).padStart(2, '0');
return { from: `${year}-${mm}-01`, to: `${year}-${mm}-${String(lastDay).padStart(2, '0')}` };
}
private todayIso(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
/**
* The rows belonging to this screen's side.
*
* The side is read off the first segment of the voucher number — `CR/001/2608/0003` — which the
* server stamps when it raises the voucher: a deposit credits the member and gets CR, everything
* that takes money out gets DR. That prefix is the authoritative record of the side, so it is
* matched directly rather than re-deriving the side from the voucher type here — a new type added
* on the server then lands on the correct report with no change needed on this screen.
*/
get rows(): VoucherRow[] {
const all = this.report?.vouchers ?? [];
return this.side === 'ALL' ? all : all.filter((row) => this.sideOf(row) === this.side);
}
/**
* Whether a voucher has its two sides recorded against it.
*
* The registers raise a voucher when the request is made and only attach its sides once it is
* approved and posted, so a voucher raised before that step existed carries a number but no
* lines. Those rows are labelled rather than shown as an empty dash, which reads like the money
* went nowhere.
*/
hasSides(row: VoucherRow): boolean {
return !!(row.debitParty || row.creditParty);
}
/** How many rows on this side are missing their debit/credit lines. */
get missingSidesCount(): number {
return this.rows.filter((row) => !this.hasSides(row)).length;
}
/** A voucher's side, from its number's prefix. Null when the number is missing or unrecognised. */
private sideOf(row: VoucherRow): VoucherSide | null {
const no = (row.voucherNo ?? '').toUpperCase();
if (no.startsWith('CR/')) {
return 'CR';
}
if (no.startsWith('DR/')) {
return 'DR';
}
return null;
}
/** How many rows on this side — not the server's count, which spans both. */
get count(): number {
return this.rows.length;
}
/** Total for this side only. */
get totalAmount(): number {
return this.rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
}
/**
* The part of this side's total whose notes and coins were actually counted.
*
* Always at most `totalAmount`: a voucher can be real cash and still be missing its breakdown,
* because the register that raised it records none. So this is the confirmed part of the cash,
* not the whole of it, and the gap is reported rather than treated as book entries.
*/
get countedTotal(): number {
return this.rows.filter((row) => row.denominationCounted).reduce((sum, row) => sum + (row.amount ?? 0), 0);
}
/** Rows carrying no denomination breakdown — unconfirmed against the drawer, not non-cash. */
get uncountedCount(): number {
return this.rows.filter((row) => !row.denominationCounted).length;
}
/** How much of this side's total has no note-and-coin breakdown behind it. */
get uncountedTotal(): number {
return this.totalAmount - this.countedTotal;
}
/**
* Vouchers whose number carries neither prefix, so they appear on neither report.
*
* Counted and shown rather than silently dropped: a voucher that vanishes from both sides of the
* register is exactly the kind of thing a cashier must be told about, not left to discover when
* the two reports do not add up to the day.
*/
get unclassifiedCount(): number {
const all = this.report?.vouchers ?? [];
return all.filter((row) => this.sideOf(row) === null).length;
}
/** Totals per user, for the summary a manager reads before the rows. */
get byUser(): { username: string; count: number; total: number }[] {
const rows = this.rows;
const tally = new Map<string, { username: string; count: number; total: number }>();
for (const row of rows) {
const key = row.username ?? '—';
const entry = tally.get(key) ?? { username: key, count: 0, total: 0 };
entry.count += 1;
entry.total += row.amount ?? 0;
tally.set(key, entry);
}
return [...tally.values()].sort((a, b) => b.total - a.total);
}
/** Totals per voucher type — deposits against withdrawals against journals. */
get byType(): { type: string; count: number; total: number }[] {
const rows = this.rows;
const tally = new Map<string, { type: string; count: number; total: number }>();
for (const row of rows) {
const key = row.voucherType ?? '—';
const entry = tally.get(key) ?? { type: key, count: 0, total: 0 };
entry.count += 1;
entry.total += row.amount ?? 0;
tally.set(key, entry);
}
return [...tally.values()].sort((a, b) => b.total - a.total);
}
print(): void {
window.print();
}
}
@@ -9,12 +9,7 @@
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Cheque Book' | translate }} #{{ book.id }}</h2>
<div>
<button mat-stroked-button (click)="printIssuanceSlip()">
<fa-icon icon="print"></fa-icon> {{ 'labels.buttons.Print' | translate }}
</button>
<button mat-raised-button [routerLink]="['/chequebook', 'books']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
<button mat-raised-button [routerLink]="['/chequebook', 'books']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
<mat-card class="info-card">
@@ -20,7 +20,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../chequebook.service';
import { LeafDetailDialogComponent } from './leaf-detail-dialog/leaf-detail-dialog.component';
import { buildChequeIssuanceSlipPdf } from './cheque-issuance-slip-pdf';
type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
@@ -86,11 +85,6 @@ export class ChequeBookDetailComponent implements OnInit {
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
}
printIssuanceSlip(): void {
const doc = buildChequeIssuanceSlipPdf(this.book);
doc.save(`cheque-book-issuance-${this.book.id}.pdf`);
}
startAction(action: LeafAction): void {
this.activeAction = this.activeAction === action ? null : action;
this.actionForm.reset();
@@ -1,120 +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/.
*/
/**
* A one-page issuance-slip PDF for a cheque book already issued to an account — the "Cheque Book"
* print action was previously a dead menu stub. This is a record of the issuance itself (which
* book/series, leaf range, issued-to account, issue date, branch), not a physical cheque leaf.
*/
import { jsPDF } from 'jspdf';
const BANK_NAME = 'Nexora Co-operative Bank Ltd.';
const C_TEXT: [number, number, number] = [
15,
23,
42
];
const C_MUTED: [number, number, number] = [
100,
116,
139
];
const C_BORDER: [number, number, number] = [
226,
232,
240
];
const ACCENT: [number, number, number] = [
30,
64,
175
];
function formatDate(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return String(iso);
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
export interface ChequeIssuanceSlipBook {
id: number | string;
accountNo?: string;
clientName?: string;
accountType?: string;
officeName?: string;
seriesCode?: string;
rangeStart?: string | number;
rangeEnd?: string | number;
noOfBooks?: number;
status?: string;
issueDate?: string;
}
export function buildChequeIssuanceSlipPdf(book: ChequeIssuanceSlipBook): jsPDF {
const doc = new jsPDF({ unit: 'mm', format: 'a5' });
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 12;
let y = 16;
doc.setFillColor(...ACCENT);
doc.rect(0, 0, pageWidth, 4, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(14);
doc.setTextColor(...C_TEXT);
doc.text(BANK_NAME, margin, y);
y += 6;
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(...C_MUTED);
doc.text('Cheque Book Issuance Slip', margin, y);
y += 8;
doc.setDrawColor(...C_BORDER);
doc.line(margin, y, pageWidth - margin, y);
y += 8;
const row = (label: string, value: string) => {
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.setTextColor(...C_MUTED);
doc.text(label, margin, y);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10.5);
doc.setTextColor(...C_TEXT);
doc.text(value, margin + 45, y);
y += 7;
};
row('Issuance ID', String(book.id ?? '—'));
row('Account No.', book.accountNo ?? '—');
row('Account Holder', book.clientName ?? '—');
row('Account Type', book.accountType ?? '—');
row('Branch', book.officeName ?? '—');
row('Series', book.seriesCode ?? '—');
row('Leaf Range', book.rangeStart != null && book.rangeEnd != null ? `${book.rangeStart} – ${book.rangeEnd}` : '—');
row('No. of Books', String(book.noOfBooks ?? '—'));
row('Status', book.status ?? '—');
row('Issue Date', formatDate(book.issueDate));
y += 6;
doc.setDrawColor(...C_BORDER);
doc.line(margin, y, pageWidth - margin, y);
y += 14;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(...C_MUTED);
doc.text('Issued by: ______________________', margin, y);
doc.text('Received by: ______________________', margin, y + 16);
return doc;
}
@@ -6,21 +6,16 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="dri" [class.dri--dialog]="isDialog">
<!-- ── HEADER ── -->
<div class="dri">
<!-- ── NAV BAR ── -->
<nav class="cashier-nav cashier-nav--clerk">
<span class="dri-header-title">
{{
isBankTransferMode
? 'Bank Transfer Review'
: isCollectionMode
? 'Collection Review'
: isFundTransferMode
? 'Fund Transfer Review'
: 'Disbursement Review'
}}
</span>
<span class="cashier-nav__esc-hint" (click)="backToInbox()">{{ isDialog ? '✕ Close' : '← Back to Inbox' }}</span>
<button type="button" class="cashier-nav__item cashier-nav__item--primary cashier-nav__item--active">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M2 12h20" />
</svg>
{{ isBankTransferMode ? 'Bank Transfer Review' : isCollectionMode ? 'Collection Review' : 'Disbursement Review' }}
</button>
<span class="cashier-nav__esc-hint" (click)="backToInbox()">← Back to Inbox</span>
</nav>
<!-- ── LOADING ── -->
@@ -164,7 +159,7 @@
<td class="cell-mono">{{ entry.accountNo }}</td>
<td>{{ entry.accountName }}</td>
<td>
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
<span class="dri-type-badge">{{ entry.accountType }}</span>
</td>
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
</tr>
@@ -202,7 +197,7 @@
<td class="cell-mono">{{ entry.accountNo }}</td>
<td>{{ entry.accountName }}</td>
<td>
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
<span class="dri-type-badge">{{ entry.accountType }}</span>
</td>
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
</tr>
@@ -238,7 +233,7 @@
<td class="cell-mono">{{ entry.accountNo }}</td>
<td>{{ entry.accountName }}</td>
<td>
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
<span class="dri-type-badge">{{ entry.accountType }}</span>
</td>
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
</tr>
@@ -36,16 +36,6 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
flex-direction: column;
min-height: 100vh;
background: $bg-page;
/* Opened as a popup rather than a full page — sizes to its own content (capped so a long
entries table still scrolls inside the dialog) instead of stretching to the viewport. */
&--dialog {
min-height: 0;
max-height: 85vh;
overflow-y: auto;
width: min(760px, 92vw);
border-radius: 10px;
}
}
// ── Nav bar ────────────────────────────────────────────────────────────────
@@ -58,6 +48,29 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
border-bottom: 1px solid $border;
box-shadow: $shadow-sm;
&__item {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.15s;
&--primary {
background: $brand-light;
color: $brand;
}
&--active {
background: $brand;
color: #fff;
}
}
&__esc-hint {
margin-left: auto;
font-size: 12px;
@@ -75,12 +88,6 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
}
}
.dri-header-title {
font-size: 14px;
font-weight: 700;
color: $text-mid;
}
// ── Loading / empty ────────────────────────────────────────────────────────
.dri-loading {
display: flex;
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatIconModule } from '@angular/material/icon';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
@@ -34,8 +33,7 @@ interface CreditEntry {
CommonModule,
FormsModule,
MatSnackBarModule,
MatIconModule,
MatDialogModule
MatIconModule
],
templateUrl: './disbursement-review.component.html',
styleUrl: './disbursement-review.component.scss'
@@ -45,11 +43,6 @@ export class DisbursementReviewComponent implements OnInit {
private router = inject(Router);
private service = inject(ClerkDisbursementService);
private snackBar = inject(MatSnackBar);
/** Present only when opened as a popup from the requests list; absent when reached by direct
* route (e.g. a bookmarked/shared link). Both entry points stay supported — this only changes
* how "done" is signalled: closing the dialog vs. navigating back to the inbox route. */
private dialogRef = inject(MatDialogRef<DisbursementReviewComponent>, { optional: true });
private dialogData = inject<{ requestId: number } | null>(MAT_DIALOG_DATA, { optional: true });
requestId: number | null = null;
request: any = null;
@@ -60,17 +53,9 @@ export class DisbursementReviewComponent implements OnInit {
showRejectForm = false;
rejectionReason = '';
get isDialog(): boolean {
return !!this.dialogRef;
}
ngOnInit(): void {
if (this.dialogData?.requestId) {
this.requestId = this.dialogData.requestId;
} else {
const id = this.route.snapshot.params['id'];
this.requestId = id != null ? Number(id) : null;
}
const id = this.route.snapshot.params['id'];
this.requestId = id != null ? Number(id) : null;
if (this.requestId) this.loadRequest();
}
@@ -108,11 +93,9 @@ export class DisbursementReviewComponent implements OnInit {
} else {
const msg = this.isBankTransferMode
? 'Bank transfer approved successfully.'
: this.isFundTransferMode
? 'Fund transfer approved successfully.'
: 'Disbursement approved successfully.';
: 'Disbursement approved successfully.';
this.snackBar.open(msg, 'Close', { duration: 3000 });
this.finish();
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
}
});
}
@@ -129,31 +112,15 @@ export class DisbursementReviewComponent implements OnInit {
this.snackBar.open(res.error, 'Close', { duration: 5000 });
} else {
this.snackBar.open('Request rejected.', 'Close', { duration: 3000 });
this.finish();
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
}
});
}
/** "Done" for this screen — closes the popup (telling the list to refresh) when opened as a
* dialog, or returns to the inbox route otherwise. */
private finish(): void {
if (this.dialogRef) {
this.dialogRef.close({ refreshed: true });
} else {
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
}
}
get isBankTransferMode(): boolean {
return this.request?.request_type === 'BANK_TRANSFER';
}
/** Mechanically identical to a Disbursement (same 1 GL debit -> N account credits flow) — only
* the label differs, so a checker doesn't read this as a loan payout. */
get isFundTransferMode(): boolean {
return this.request?.request_type === 'FUND_TRANSFER';
}
get isCollectionMode(): boolean {
if (this.isBankTransferMode) return false;
return this.creditEntries.some((e) => e.dbCr === 'Dr');
@@ -176,24 +143,6 @@ export class DisbursementReviewComponent implements OnInit {
return this.drEntries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
}
/**
* Human-readable account type for the badge. The stored value is the backend's own code
* (SAVINGS / CURRENT / FIXED_DEPOSIT / RECURRING_DEPOSIT / SHARE / LOAN / GL), so this only
* changes presentation -- a checker should read "Fixed Deposit", not "FIXED_DEPOSIT".
*/
accountTypeLabel(accountType: string): string {
const labels: Record<string, string> = {
SAVINGS: 'Savings',
CURRENT: 'Current',
FIXED_DEPOSIT: 'Fixed Deposit',
RECURRING_DEPOSIT: 'Recurring Deposit',
SHARE: 'Share',
LOAN: 'Loan',
GL: 'GL'
};
return labels[(accountType ?? '').toUpperCase()] ?? accountType;
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'dri-status--pending',
@@ -222,10 +171,6 @@ export class DisbursementReviewComponent implements OnInit {
}
backToInbox(): void {
if (this.dialogRef) {
this.dialogRef.close();
} else {
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
}
this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
}
}
+31 -26
View File
@@ -20,36 +20,37 @@ import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
export class DividendClaimRequestService {
private http = inject(HttpClient);
/**
* Records a claim for an unclaimed dividend, collected as a Demand Draft.
*
* A dividend is only ever paid out as a draft — the savings-credit, cash and loan-set-off routes
* this endpoint once accepted are retired, so the payload carries just what a draft needs: how
* much, drawn in whose favour, and payable where.
*/
submit(
lockId: number,
claim: {
ddApplyAmount: number;
ddBeneficiaryName: string;
ddPayableAtBranch: string;
ddPayableAtCity: string;
/**
* The leaflet the clerk picked off the shelf. Sent so the draft is drawn on that physical
* form — without it the server takes the next available one, and the number the clerk wrote
* on the counterfoil would not be the number in the books.
*/
leafletId?: number;
breakdown: {
loanId?: number;
loanApplyAmount?: number;
savingsAccountId?: number;
savingsApplyAmount?: number;
cashApplyAmount?: number;
tokNo?: string;
}
): Observable<any> {
return this.http.post('/clerk/dividend-claim-requests', {
lockId,
ddApplyAmount: String(claim.ddApplyAmount),
ddBeneficiaryName: claim.ddBeneficiaryName,
ddPayableAtBranch: claim.ddPayableAtBranch,
ddPayableAtCity: claim.ddPayableAtCity,
leafletId: claim.leafletId ?? null
});
const body: any = { lockId };
if (breakdown.loanId != null) {
body.loanId = breakdown.loanId;
}
if (breakdown.loanApplyAmount != null) {
body.loanApplyAmount = String(breakdown.loanApplyAmount);
}
if (breakdown.savingsAccountId != null) {
body.savingsAccountId = breakdown.savingsAccountId;
}
if (breakdown.savingsApplyAmount != null) {
body.savingsApplyAmount = String(breakdown.savingsApplyAmount);
}
if (breakdown.cashApplyAmount != null) {
body.cashApplyAmount = String(breakdown.cashApplyAmount);
}
if (breakdown.tokNo != null) {
body.tokNo = breakdown.tokNo;
}
return this.http.post('/clerk/dividend-claim-requests', body);
}
// Silenced: onTokNoBlur() in Transaction Entry probes this only after the withdrawal-request
@@ -67,6 +68,10 @@ export class DividendClaimRequestService {
});
}
getOverdueLoans(clientId: number): Observable<any> {
return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`);
}
list(status = 'PENDING'): Observable<any> {
return this.http.get('/clerk/dividend-claim-requests', { params: { status } });
}
@@ -46,69 +46,14 @@
</div>
}
<!-- One list at a time. The three answer unrelated questions and a clerk is only ever in one of
them; stacked, the one they came for was usually below the fold. Counts ride on the tabs so a
list worth opening — a blocked member, a lock nearing forfeiture — is visible without opening
it. -->
<div class="dd-tabs" role="tablist">
<button
type="button"
role="tab"
class="dd-tab"
[class.dd-tab--active]="activeTab === 'declarations'"
[attr.aria-selected]="activeTab === 'declarations'"
(click)="activeTab = 'declarations'"
>
Declarations
<span class="dd-tab__count">{{ declarations.length }}</span>
</button>
<button
type="button"
role="tab"
class="dd-tab"
[class.dd-tab--active]="activeTab === 'overdue'"
[attr.aria-selected]="activeTab === 'overdue'"
(click)="activeTab = 'overdue'"
>
Overdue EMI
<span class="dd-tab__count" [class.dd-tab__count--alert]="rows.length > 0">{{ rows.length }}</span>
</button>
<button
type="button"
role="tab"
class="dd-tab"
[class.dd-tab--active]="activeTab === 'locks'"
[attr.aria-selected]="activeTab === 'locks'"
(click)="activeTab = 'locks'"
>
Lock History
<span class="dd-tab__count">{{ locks.length }}</span>
</button>
</div>
<div class="pg-card" [hidden]="activeTab !== 'declarations'">
<div class="pg-card">
<div class="pos-section-label">Dividend Declarations — Every Dividend Ever Declared, Tracked to Completion</div>
<div class="dd-toolbar">
<div class="dd-search">
<input
type="search"
class="dd-search__input"
placeholder="Search product, period or status…"
[(ngModel)]="declarationSearch"
name="declarationSearch"
/>
@if (declarationSearch) {
<button type="button" class="dd-search__clear" (click)="declarationSearch = ''" aria-label="Clear search">
&times;
</button>
}
</div>
<span class="dd-toolbar__count">
{{ visibleDeclarations.length }} of {{ declarations.length }} declaration{{
declarations.length === 1 ? '' : 's'
}}
</span>
<button type="button" class="pg-btn" [disabled]="declarationsLoading" (click)="loadDeclarations()">Refresh</button>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="declarationsLoading" (click)="loadDeclarations()">
Refresh
</button>
</label>
</div>
<table class="pg-table">
<thead>
@@ -130,12 +75,8 @@
<tr>
<td colspan="6" class="pg-empty">No dividends have been declared yet.</td>
</tr>
} @else if (visibleDeclarations.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No declaration matches “{{ declarationSearch }}”.</td>
</tr>
} @else {
@for (d of visibleDeclarations; track d.declarationId) {
@for (d of declarations; track d.declarationId) {
<tr>
<td>{{ d.productName }}</td>
<td>{{ d.periodStartDate }} → {{ d.periodEndDate }}</td>
@@ -154,27 +95,12 @@
</table>
</div>
<div class="pg-card" [hidden]="activeTab !== 'overdue'">
<div class="pg-card" style="margin-top: 16px">
<div class="pos-section-label">Dividend Dashboard — Locked Dividends for Members with Overdue EMI</div>
<div class="dd-toolbar">
<div class="dd-search">
<input
type="search"
class="dd-search__input"
placeholder="Search member name or share account no…"
[(ngModel)]="overdueSearch"
name="overdueSearch"
/>
@if (overdueSearch) {
<button type="button" class="dd-search__clear" (click)="overdueSearch = ''" aria-label="Clear search">
&times;
</button>
}
</div>
<span class="dd-toolbar__count">
{{ visibleOverdueRows.length }} of {{ rows.length }} member{{ rows.length === 1 ? '' : 's' }}
</span>
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
</label>
</div>
<table class="pg-table">
<thead>
@@ -196,19 +122,10 @@
<tr>
<td colspan="6" class="pg-empty">No members with a locked dividend and an overdue EMI right now.</td>
</tr>
} @else if (visibleOverdueRows.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No member matches “{{ overdueSearch }}”.</td>
</tr>
} @else {
@for (row of visibleOverdueRows; track row.lockId) {
@for (row of rows; track row.lockId) {
<tr>
<td>
{{ row.clientName }}
@if (row.shareAccountNo) {
<div class="dd-account-no">{{ row.shareAccountNo }}</div>
}
</td>
<td>{{ row.clientName }}</td>
<td class="num">{{ row.lockedAmount | number: '1.2-2' }}</td>
<td>{{ row.lockedDate }}</td>
<td>{{ row.releaseDueDate }}</td>
@@ -264,7 +181,7 @@
</table>
</div>
<div class="pg-card" [hidden]="activeTab !== 'locks'">
<div class="pg-card" style="margin-top: 16px">
<div class="pos-section-label">All Dividend Locks — Full 3-Year Lock History</div>
<div class="inv-form-grid" style="padding: 12px 16px; gap: 8px">
<div class="dd-filter-group">
@@ -279,69 +196,21 @@
</button>
}
</div>
<div class="dd-search" style="margin-left: auto">
<input
type="search"
class="dd-search__input"
placeholder="Search member name or share account no…"
[(ngModel)]="lockSearch"
name="lockSearch"
/>
@if (lockSearch) {
<button type="button" class="dd-search__clear" (click)="lockSearch = ''" aria-label="Clear search">
&times;
</button>
}
</div>
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
<label class="inv-form-field" style="align-self: end; margin-left: auto">
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
</label>
</div>
<!-- Financial-year chips, built from the locks themselves: a new declaration adds its year here
on its own, and a year with nothing left to show stops being offered. Shown even for a single
year, so the filter is visibly there before a second year exists to use it on. -->
@if (lockYears.length > 0) {
<div class="dd-year-filter">
<span class="dd-year-filter__label">Financial year</span>
<div class="dd-filter-group">
<button
type="button"
class="dd-filter-btn"
[class.dd-filter-btn--active]="lockYearFilter === 'ALL'"
(click)="setLockYearFilter('ALL')"
>
All years
</button>
@for (year of lockYears; track year) {
<button
type="button"
class="dd-filter-btn"
[class.dd-filter-btn--active]="lockYearFilter === year"
(click)="setLockYearFilter(year)"
>
FY {{ year }}
</button>
}
</div>
<span class="dd-year-filter__total">
{{ visibleLocks.length }} lock{{ visibleLocks.length === 1 ? '' : 's' }} ·
{{ visibleLocksTotal | number: '1.2-2' }}
</span>
</div>
}
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<!-- Which declaration this lock came from. A member can hold several unclaimed dividends at
once — this year's plus an earlier year they never collected — and amount alone cannot
tell them apart. -->
<th>Dividend</th>
<th>Savings Account</th>
<th class="num">Amount</th>
<th>Locked Date</th>
<th>Release Due</th>
<th>Status</th>
<th>Applied Loan</th>
<th>Demand Draft</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -349,34 +218,16 @@
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (locksLoaded && visibleLocks.length === 0) {
} @else if (locksLoaded && locks.length === 0) {
<tr>
<td colspan="8" class="pg-empty">
@if (lockSearch) {
No dividend lock matches “{{ lockSearch }}”.
} @else {
No dividend locks match this filter.
}
</td>
<td colspan="8" class="pg-empty">No dividend locks match this filter.</td>
</tr>
} @else {
@for (l of visibleLocks; track l.lockId) {
@for (l of locks; track l.lockId) {
<tr>
<td>{{ l.clientName }}</td>
<td>
{{ l.clientName }}
@if (l.shareAccountNo) {
<div class="dd-account-no">{{ l.shareAccountNo }}</div>
}
</td>
<td>
@if (l.financialYear) {
<div class="dd-dividend-year">FY {{ l.financialYear }}</div>
<div class="dd-dividend-period">
{{ l.productName }} · {{ l.periodStartDate }} → {{ l.periodEndDate }}
</div>
} @else {
—
}
<span class="pg-chip">{{ l.savingsAccountNo }}</span>
</td>
<td class="num">{{ l.amount | number: '1.2-2' }}</td>
<td>{{ l.lockedDate }}</td>
@@ -385,21 +236,179 @@
<span class="dd-badge" [ngClass]="lockStatusClass(l.status)">{{ l.status | titlecase }}</span>
</td>
<td>{{ l.appliedLoanAccountNo || '—' }}</td>
<!-- Once a draft has been raised the clerk needs its number, not another Claim button:
it is what goes on the counterfoil and what the member asks about when they return.
Until then, the button hands off to the DD inventory to raise one. -->
<td>
@if (l.claimDdNumber && l.claimStatus === 'APPROVED') {
<span class="dd-dd-number">{{ l.claimDdNumber }}</span>
} @else if (l.claimStatus === 'PENDING') {
<span class="dd-badge dd-badge--pending">DD {{ l.claimDdNumber }} — awaiting approval</span>
} @else if (l.status === 'UNCLAIMED') {
<button type="button" class="pg-btn dd-apply-confirm" (click)="claimAsDemandDraft(l)">
Claim by DD
@if (l.status === 'UNCLAIMED') {
<button type="button" class="pg-btn" (click)="toggleClaim(l)">
{{ expandedClaimLockId === l.lockId ? 'Cancel' : 'Claim' }}
</button>
}
</td>
</tr>
@if (expandedClaimLockId === l.lockId) {
<tr class="dd-apply-row">
<td colspan="8">
<div class="dd-apply-panel dd-apply-panel--wrap">
<div class="dd-apply-panel-title">
Unclaimed amount: {{ l.amount | number: '1.2-2' }} — split it below (loan, savings, cash)
</div>
@if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) {
<label class="inv-form-field">
<span>Apply to Overdue Loan</span>
<select [(ngModel)]="claimLoanId[l.lockId]">
@for (loan of claimOverdueLoans[l.lockId]; track loan.loanId) {
<option [ngValue]="loan.loanId">
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Loan Amount</span>
<input type="number" [(ngModel)]="claimLoanAmount[l.lockId]" min="0" step="0.01" />
</label>
} @else {
<div class="pg-empty-inline">No overdue loan for this client.</div>
}
<label class="inv-form-field">
<span>Credit to Savings</span>
<input type="number" [(ngModel)]="claimSavingsAmount[l.lockId]" min="0" step="0.01" />
</label>
@if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) {
<label class="inv-form-field">
<span>Savings Account ID (no linked account — enter one to credit)</span>
<input type="number" [(ngModel)]="claimSavingsAccountOverride[l.lockId]" min="1" step="1" />
</label>
}
<label class="inv-form-field">
<span>Cash</span>
<input type="number" [(ngModel)]="claimCashAmount[l.lockId]" min="0" step="0.01" />
</label>
@if (claimCashAmount[l.lockId] > 0) {
<label class="inv-form-field">
<span>Token No. (for cashier hand-over)</span>
<input type="text" [(ngModel)]="claimTokNo[l.lockId]" />
</label>
}
<div class="dd-apply-panel-total">
Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }}
</div>
@if (claimError) {
<div class="dd-apply-error">{{ claimError }}</div>
}
<button
type="button"
class="pg-btn dd-apply-confirm"
[disabled]="claimSubmitting"
(click)="submitClaim(l)"
>
{{ claimSubmitting ? 'Submitting…' : 'Submit Claim Request' }}
</button>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<div class="pos-section-label">Dividend Claim Requests — Pending Review</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="claimRequestsLoading" (click)="loadClaimRequests()">
Refresh
</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<th>Savings Account</th>
<th class="num">To Loan</th>
<th class="num">To Savings</th>
<th class="num">Cash</th>
<th class="num">Unclaimed Balance</th>
<th>Client Loan Status</th>
<th></th>
</tr>
</thead>
<tbody>
@if (claimRequestsLoading) {
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (claimRequestsLoaded && claimRequests.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No dividend claim requests pending review.</td>
</tr>
} @else {
@for (req of claimRequests; track req.id) {
<tr>
<td>{{ req.client_name }}</td>
<td>
<span class="pg-chip">{{ req.savings_account_no }}</span>
</td>
<td class="num">
{{
req.loan_apply_amount > 0
? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')'
: '—'
}}
</td>
<td class="num">{{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }}</td>
<td class="num">
{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}
@if (req.cash_apply_amount > 0 && req.tok_no) {
<div class="pg-empty-inline">Tok: {{ req.tok_no }}</div>
}
</td>
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
<td>
@if (req.has_overdue_loan) {
<span class="dd-badge dd-badge--failed">Has Overdue EMI</span>
} @else {
<span class="dd-badge dd-badge--posted">No Overdue Loan</span>
}
</td>
<td>
<button type="button" class="pg-btn" [disabled]="claimActioningId === req.id" (click)="approveClaim(req)">
{{ claimActioningId === req.id ? 'Approving…' : 'Approve' }}
</button>
<button type="button" class="pg-btn" (click)="toggleRejectClaim(req)">
{{ expandedRejectClaimId === req.id ? 'Cancel' : 'Reject' }}
</button>
@if (claimActionError[req.id]) {
<div class="dd-retry-error">{{ claimActionError[req.id] }}</div>
}
</td>
</tr>
@if (expandedRejectClaimId === req.id) {
<tr class="dd-apply-row">
<td colspan="8">
<div class="dd-apply-panel">
<label class="inv-form-field">
<span>Rejection Reason</span>
<input
type="text"
[(ngModel)]="rejectReason[req.id]"
placeholder="Why is this claim being rejected?"
/>
</label>
<button
type="button"
class="pg-btn dd-apply-confirm"
[disabled]="claimActioningId === req.id"
(click)="rejectClaim(req)"
>
{{ claimActioningId === req.id ? 'Rejecting…' : 'Confirm Reject' }}
</button>
</div>
</td>
</tr>
}
}
}
</tbody>
@@ -169,236 +169,3 @@
color: #fff;
}
}
/* Which declaration a lock belongs to. The financial year leads because that is how a member asks
for it ("my 2026-27 dividend"); the product and exact period sit under it for the clerk who needs
to be certain, without crowding the row. */
.dd-dividend-year {
font-weight: 600;
font-size: 13px;
color: #1e293b;
white-space: nowrap;
}
.dd-dividend-period {
font-size: 11.5px;
color: #64748b;
white-space: nowrap;
}
/* Financial-year chips. Sits below the status chips as a second, narrower axis over the same list —
status is fetched from the server, year filters what came back. */
.dd-year-filter {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 0 16px 12px;
&__label {
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
}
&__total {
margin-left: auto;
font-size: 12.5px;
color: #475569;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
/* The DD number just drawn. Approving refreshes the pending list, so the row it came from is gone
by the time the clerk reads it — and that number is what goes on the counterfoil. */
.dd-issued-banner {
display: flex;
align-items: center;
gap: 12px;
margin: 12px 16px 0;
padding: 10px 14px;
border: 1px solid #86efac;
border-radius: 6px;
background: #f0fdf4;
color: #166534;
font-size: 13.5px;
&__main {
flex: 1;
}
&__close {
border: none;
background: none;
color: #166534;
font-size: 20px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
&:hover {
color: #14532d;
}
}
}
/* One toolbar per table: search on the left where the eye lands first, the row count next to it so
a narrowed table says how much it is hiding, and Refresh pushed to the far right. */
.dd-toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
&__count {
margin-left: auto;
font-size: 12.5px;
color: #475569;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
.dd-search {
position: relative;
flex: 1 1 260px;
max-width: 360px;
&__input {
width: 100%;
padding: 7px 30px 7px 11px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 13px;
color: #0f172a;
background: #fff;
&::placeholder {
color: #94a3b8;
}
&:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
}
/* The browser's own clear affordance would sit under ours. */
&::-webkit-search-cancel-button {
appearance: none;
}
}
/* Only rendered while there is something to clear, so it never reads as a dead control. */
&__clear {
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
font-size: 17px;
line-height: 1;
cursor: pointer;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
}
}
/* The share account number under the member's name — the thing a clerk matches against the
passbook in front of them, so it has to be readable but must not outweigh the name. */
.dd-account-no {
margin-top: 2px;
font-size: 11.5px;
color: #64748b;
font-variant-numeric: tabular-nums;
}
/* Horizontal tab strip: one list on screen at a time, chosen here.
Scrolls rather than wraps on a narrow window, so the strip stays one line and the tab positions
a clerk has learned do not move. */
.dd-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
padding-bottom: 2px;
border-bottom: 1px solid #e2e8f0;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.dd-tab {
display: inline-flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
padding: 9px 14px;
border: 0;
/* The active marker is this underline, so every tab reserves the space for it — otherwise the
strip would shift by 2px each time the selection moved. */
border-bottom: 2px solid transparent;
background: transparent;
font-size: 12.5px;
font-weight: 600;
color: #64748b;
white-space: nowrap;
cursor: pointer;
&:hover {
color: #1e40af;
}
&--active {
color: #1e40af;
border-bottom-color: #2563eb;
}
}
.dd-tab__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: 9999px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
/* A member blocked by an overdue EMI, or a claim waiting on a manager, is work outstanding —
coloured so it reads as something to open rather than a neutral tally. */
&--alert {
background: #fef3c7;
color: #92400e;
}
}
/* The draft number a member's dividend was paid out on. Monospaced-by-figure so a clerk reading it
against the counterfoil can compare digit by digit. */
.dd-dd-number {
font-size: 12.5px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
@@ -9,21 +9,17 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { DividendDashboardService } from '../dividend-dashboard.service';
import { DividendClaimRequestService } from '../dividend-claim-request.service';
/**
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year lock
* history, and members whose unclaimed dividend sits alongside an overdue loan EMI (with a
* proactive "Apply to Loan" action).
*
* Claiming a dividend hands off to the DD inventory, because a dividend is only ever collected as a
* Demand Draft. The draft is then approved on the DD Issuance Approvals screen alongside every
* other draft, so there is no separate claim queue here — the lock history shows the DD number once
* it has been drawn.
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year
* lock history, members whose unclaimed dividend sits alongside an overdue loan EMI (with a
* proactive "Apply to Loan" action), and the maker-checker claim workflow for members who come to
* claim their dividend directly (split across loan, savings, or cash).
*/
@Component({
selector: 'mifosx-dividend-dashboard',
@@ -41,20 +37,7 @@ import { DividendDashboardService } from '../dividend-dashboard.service';
})
export class DividendDashboardComponent implements OnInit {
private service = inject(DividendDashboardService);
private router = inject(Router);
/**
* Which list the clerk is looking at.
*
* The three lists answer unrelated questions — what has been declared, who is blocked by an
* overdue EMI, and the full lock history — and a clerk is only ever in one of them. Stacked on one
* page they made the screen long enough that the section a clerk came for was usually below the
* fold, so they are tabbed instead.
*
* Every list is still loaded on init rather than on first view: the counts in the tab strip have
* to be right before a tab is opened, or the strip would be advertising numbers it does not know.
*/
activeTab: 'declarations' | 'overdue' | 'locks' = 'declarations';
private claimRequestService = inject(DividendClaimRequestService);
rows: any[] = [];
loading = false;
@@ -73,72 +56,181 @@ export class DividendDashboardComponent implements OnInit {
declarationsLoading = false;
declarationsLoaded = false;
/**
* Free-text search, one box per table.
*
* Kept separate rather than shared because the three tables answer different questions and a
* clerk is usually in one of them: narrowing the lock history to a member should not blank out
* the declarations table above it, which has nothing to do with that member.
*
* Filtering is done here rather than server-side because every row is already loaded — these are
* a society's dividends, not a transaction ledger — so a round trip per keystroke would be slower
* than the filter itself and would blank the table while it ran.
*/
declarationSearch = '';
overdueSearch = '';
lockSearch = '';
/** Every lock the status filter returned, before the year filter narrows it. */
locks: any[] = [];
locksLoading = false;
locksLoaded = false;
lockStatusFilter = 'ALL';
/**
* Financial years that actually have locks, newest first — derived from the data rather than
* listed anywhere, so a new declaration adds its year on its own and a year whose locks are all
* gone stops being offered. No maintenance as years roll over.
*/
lockYears: string[] = [];
lockYearFilter = 'ALL';
expandedClaimLockId: number | null = null;
claimOverdueLoans: Record<number, any[]> = {};
claimLoanId: Record<number, number | null> = {};
claimLoanAmount: Record<number, number | null> = {};
claimSavingsAmount: Record<number, number | null> = {};
claimSavingsAccountOverride: Record<number, number | null> = {};
claimCashAmount: Record<number, number | null> = {};
claimTokNo: Record<number, string> = {};
claimSubmitting = false;
claimError: string | null = null;
claimRequests: any[] = [];
claimRequestsLoading = false;
claimRequestsLoaded = false;
claimActioningId: number | null = null;
claimActionError: Record<number, string> = {};
expandedRejectClaimId: number | null = null;
rejectReason: Record<number, string> = {};
ngOnInit(): void {
this.loadSummary();
this.loadDeclarations();
this.load();
this.loadLocks();
this.loadClaimRequests();
}
/**
* Takes the clerk to the DD inventory to draw the draft this dividend is paid out as.
*
* A dividend is only ever collected as a Demand Draft, so the claim is really a request to issue
* one — and the place a draft is issued is the leaflet inventory, where the clerk can see which
* numbered leaflet they are about to commit. Gathering the draft's details on this dashboard
* instead meant filling a second, thinner version of the DD form and never seeing the DD number
* until after approval.
*
* The dividend travels in the query string rather than in a service, so a reloaded or
* bookmarked page still knows which lock it is drawing against instead of silently issuing an
* ordinary counter draft.
*/
claimAsDemandDraft(lock: any): void {
this.router.navigate(
[
'/sponsor-dd',
'inventory'
],
{
queryParams: {
dividendLockId: lock.lockId,
dividendAmount: Number(lock.amount),
dividendBeneficiary: lock.clientName ?? '',
dividendFinancialYear: lock.financialYear ?? ''
toggleClaim(lock: any): void {
if (this.expandedClaimLockId === lock.lockId) {
this.expandedClaimLockId = null;
return;
}
this.expandedClaimLockId = lock.lockId;
this.claimError = null;
this.claimLoanId[lock.lockId] = null;
this.claimLoanAmount[lock.lockId] = null;
this.claimSavingsAmount[lock.lockId] = Number(lock.amount);
this.claimSavingsAccountOverride[lock.lockId] = null;
this.claimCashAmount[lock.lockId] = null;
this.claimTokNo[lock.lockId] = '';
this.claimRequestService
.getOverdueLoans(lock.clientId)
.pipe(catchError(() => of([])))
.subscribe((loans: any[]) => {
this.claimOverdueLoans[lock.lockId] = loans ?? [];
if (loans && loans.length > 0) {
this.claimLoanId[lock.lockId] = loans[0].loanId;
const loanAmt = Math.min(Number(lock.amount), Number(loans[0].overdueAmount));
this.claimLoanAmount[lock.lockId] = loanAmt;
this.claimSavingsAmount[lock.lockId] = Number((Number(lock.amount) - loanAmt).toFixed(2));
}
}
});
}
claimTotal(lock: any): number {
return (
Number(this.claimLoanAmount[lock.lockId] || 0) +
Number(this.claimSavingsAmount[lock.lockId] || 0) +
Math.floor(Number(this.claimCashAmount[lock.lockId] || 0))
);
}
submitClaim(lock: any): void {
const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0);
const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0);
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), while a
// dividend share often works out to paise — floor to the nearest rupee so the denomination
// total the cashier counts can always match exactly; any leftover paise stays unclaimed.
const cashAmount = Math.floor(Number(this.claimCashAmount[lock.lockId] || 0));
const total = loanAmount + savingsAmount + cashAmount;
if (total <= 0) {
this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.';
return;
}
if (total > Number(lock.amount)) {
this.claimError = `Total (${total}) cannot exceed the unclaimed amount of ${lock.amount}.`;
return;
}
if (loanAmount > 0 && !this.claimLoanId[lock.lockId]) {
this.claimError = 'Select a loan to apply the loan amount towards.';
return;
}
if (savingsAmount > 0 && !lock.savingsAccountId && !this.claimSavingsAccountOverride[lock.lockId]) {
this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.';
return;
}
const tokNo = this.claimTokNo[lock.lockId]?.trim();
if (cashAmount > 0 && !tokNo) {
this.claimError = 'Enter a Token No. for the cash portion — the cashier will use it to hand over the cash.';
return;
}
this.claimSubmitting = true;
this.claimError = null;
this.claimRequestService
.submit(lock.lockId, {
loanId: loanAmount > 0 ? this.claimLoanId[lock.lockId] : undefined,
loanApplyAmount: loanAmount > 0 ? loanAmount : undefined,
savingsAccountId:
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined,
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined,
tokNo: cashAmount > 0 ? tokNo : undefined
})
.subscribe({
next: () => {
this.claimSubmitting = false;
this.expandedClaimLockId = null;
this.loadClaimRequests();
},
error: (err: any) => {
this.claimSubmitting = false;
this.claimError = err?.error?.message || 'Failed to submit claim request.';
}
});
}
loadClaimRequests(): void {
this.claimRequestsLoading = true;
this.claimRequestService
.list('PENDING')
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.claimRequests = res ?? [];
this.claimRequestsLoading = false;
this.claimRequestsLoaded = true;
});
}
approveClaim(req: any): void {
this.claimActioningId = req.id;
delete this.claimActionError[req.id];
this.claimRequestService.approve(req.id).subscribe({
next: () => {
this.claimActioningId = null;
this.loadClaimRequests();
this.loadLocks();
this.loadSummary();
},
error: (err: any) => {
this.claimActioningId = null;
this.claimActionError[req.id] = err?.error?.message || 'Failed to approve claim.';
}
});
}
toggleRejectClaim(req: any): void {
this.expandedRejectClaimId = this.expandedRejectClaimId === req.id ? null : req.id;
}
rejectClaim(req: any): void {
const reason = this.rejectReason[req.id];
if (!reason || !reason.trim()) {
this.claimActionError[req.id] = 'Rejection reason is required.';
return;
}
this.claimActioningId = req.id;
delete this.claimActionError[req.id];
this.claimRequestService.reject(req.id, reason).subscribe({
next: () => {
this.claimActioningId = null;
this.expandedRejectClaimId = null;
this.loadClaimRequests();
},
error: (err: any) => {
this.claimActioningId = null;
this.claimActionError[req.id] = err?.error?.message || 'Failed to reject claim.';
}
});
}
loadSummary(): void {
this.summaryLoading = true;
this.service
@@ -162,33 +254,6 @@ export class DividendDashboardComponent implements OnInit {
});
}
/**
* Whether any of a row's searchable fields contains the term.
*
* Substring rather than prefix matching, because a clerk reading an account number off a passbook
* often types the distinctive tail of it rather than the branch prefix every account shares. Null
* fields are skipped — a lock with no share account still has to be findable by member name.
*/
private matches(term: string, ...fields: (string | number | null | undefined)[]): boolean {
const needle = term.trim().toLowerCase();
if (!needle) {
return true;
}
return fields.some((field) => field != null && String(field).toLowerCase().includes(needle));
}
/** Declarations the table renders. Product-level rows, so there is no member to search on. */
get visibleDeclarations(): any[] {
return this.declarations.filter((d) =>
this.matches(this.declarationSearch, d.productName, d.periodStartDate, d.periodEndDate, d.status)
);
}
/** Members with an overdue EMI, narrowed to the one the clerk is dealing with. */
get visibleOverdueRows(): any[] {
return this.rows.filter((r) => this.matches(this.overdueSearch, r.clientName, r.shareAccountNo));
}
declarationStatusLabel(status: string): string {
return status === 'PENDING_APPROVAL' ? 'Pending Approval' : 'Approved';
}
@@ -204,54 +269,16 @@ export class DividendDashboardComponent implements OnInit {
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.locks = res ?? [];
this.rebuildLockYears();
this.locksLoading = false;
this.locksLoaded = true;
});
}
/**
* Rebuilds the year chips from whatever locks came back, newest first.
*
* A year the current status filter has no locks for disappears from the chips, so the selected
* year is reset to ALL when that happens — otherwise the table would show nothing with no
* indication why.
*/
private rebuildLockYears(): void {
this.lockYears = [...new Set(this.locks.map((l) => l.financialYear).filter((y) => !!y))].sort((a, b) =>
b.localeCompare(a)
);
if (this.lockYearFilter !== 'ALL' && !this.lockYears.includes(this.lockYearFilter)) {
this.lockYearFilter = 'ALL';
}
}
/**
* Locks the table actually renders — the status filter is applied server-side, the year and the
* search here. All three narrow together, so a clerk can hold a year and still search within it.
*/
get visibleLocks(): any[] {
return this.locks.filter(
(l) =>
(this.lockYearFilter === 'ALL' || l.financialYear === this.lockYearFilter) &&
this.matches(this.lockSearch, l.clientName, l.shareAccountNo, l.productName, l.appliedLoanAccountNo)
);
}
/** Total of what is on screen, so a year's worth of dividend can be read off directly. */
get visibleLocksTotal(): number {
return this.visibleLocks.reduce((sum, l) => sum + (Number(l.amount) || 0), 0);
}
setLockStatusFilter(status: string): void {
this.lockStatusFilter = status;
this.loadLocks();
}
setLockYearFilter(year: string): void {
this.lockYearFilter = year;
}
lockStatusClass(status: string): string {
switch (status) {
case 'UNCLAIMED':
@@ -1,79 +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">CLERK · CLOSURE</div>
<h1 class="pg-title">My Closure Requests</h1>
<p class="pg-sub">
Account closure requests you have submitted, and their approval / cashier status. To close an account, use the
"Close this account" action on the account's own Transaction Entry screen.
</p>
</div>
</header>
<div class="pg-card">
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Client</th>
<th>Payout Mode</th>
<th>Token No</th>
<th class="num">Closing Amount</th>
<th>Submitted At</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="7" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && requests.length === 0) {
<tr>
<td colspan="7" class="pg-empty">No closure requests found.</td>
</tr>
} @else {
@for (r of requests; track r.id) {
<tr>
<td>
<span class="pg-chip">{{ r.account_no }}</span>
</td>
<td>{{ r.client_name }}</td>
<td>{{ r.payment_type_name || 'Cash' }}</td>
<td>{{ r.tok_no || '—' }}</td>
<td class="num">
@if (r.closing_amount != null) {
₹{{ r.closing_amount | number: '1.2-2' }}
} @else {
—
}
</td>
<td>{{ formatDateTime(r.submitted_at) }}</td>
<td>
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
@@ -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 { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { ClerkClosureRequestService } from '../../cashier/clerk-closure-request.service';
/** Clerk Dashboard "My Closure Requests" — lists the Cash account-closure requests this clerk has
* submitted (from the account's own Transaction Entry screen) and their approval/disbursement
* status. Mirrors MyShareWithdrawalRequestsComponent. There is no "New Closure Request" button
* here -- submission happens in-context on the account's own screen, not from this list. */
@Component({
selector: 'mifosx-my-closure-requests',
templateUrl: './my-closure-requests.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class MyClosureRequestsComponent implements OnInit {
private service = inject(ClerkClosureRequestService);
requests: any[] = [];
totalRecords = 0;
loading = false;
loaded = false;
filterStatus = '';
get statusOptions(): { value: string; label: string }[] {
return [
{ value: '', label: 'All' },
{ value: 'PENDING', label: 'Pending' },
{ value: 'APPROVED', label: 'Approved — awaiting cashier' },
{ value: 'REJECTED', label: 'Rejected' },
{ value: 'CLOSED', label: 'Closed' }
];
}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getMyRequests(this.filterStatus || undefined, 0, 50)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.requests = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
this.loading = false;
this.loaded = true;
});
}
onFilterChange(): void {
this.load();
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'inv-badge--active',
APPROVED: 'inv-badge--matured',
REJECTED: 'inv-badge--written_off',
CLOSED: 'inv-badge--matured'
};
return `inv-badge ${map[status] ?? ''}`;
}
statusLabel(status: string): string {
const map: Record<string, string> = {
PENDING: 'Pending Approval',
APPROVED: 'Awaiting Cashier',
REJECTED: 'Rejected',
CLOSED: 'Closed'
};
return map[status] ?? status;
}
formatDateTime(val: any): string {
if (!val) return '—';
try {
const d = new Date(val);
return d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return String(val);
}
}
}
@@ -12,15 +12,13 @@
<div class="mdr-header__left">
<mat-icon class="mdr-header__icon">swap_horiz</mat-icon>
<div>
<div class="mdr-header__title">Disbursements &amp; Transfer</div>
<div class="mdr-header__sub">
Disbursement, Collection, Fund Transfer and Bank Transfer requests you submitted.
</div>
<div class="mdr-header__title">My Disbursement Requests</div>
<div class="mdr-header__sub">Track your submitted GL disbursement requests.</div>
</div>
</div>
<button type="button" class="mdr-btn mdr-btn--new" (click)="newDisbursement()">
<mat-icon>add</mat-icon>
New Request
New Disbursement
</button>
</div>
@@ -101,7 +99,7 @@
<p class="mdr-empty__text">You have not submitted any disbursement requests yet.</p>
<button type="button" class="mdr-btn mdr-btn--new" style="margin-top: 12px" (click)="newDisbursement()">
<mat-icon>add</mat-icon>
New Request
New Disbursement
</button>
</div>
} @else {
@@ -172,9 +170,7 @@
<td class="cell-mono">{{ ce.accountNo || '—' }}</td>
<td>{{ ce.accountName || '—' }}</td>
<td>
<span class="mdr-actype">{{
ce.accountType ? accountTypeLabel(ce.accountType) : '—'
}}</span>
<span class="mdr-actype">{{ ce.accountType || '—' }}</span>
</td>
<td class="num mdr-ce-amount">{{ ce.amount | number: '1.2-2' }}</td>
</tr>
@@ -177,29 +177,7 @@ export class MyDisbursementRequestsComponent implements OnInit {
getCreditEntries(req: any): any[] {
try {
const arr = typeof req.credit_entries === 'string' ? JSON.parse(req.credit_entries) : req.credit_entries;
const entries: any[] = Array.isArray(arr)
? arr.map((e: any) => ({ ...e, amount: parseFloat(e.amount) || 0 }))
: [];
// Collection embeds its GL leg inside credit_entries (it can collect from several
// accounts into the one GL, so the GL has to be one row among them). Disbursement and
// Fund Transfer instead name their GL as the request's own top-level source — dr_amount
// is its amount — because there is exactly one, so it never needed a row of its own. That
// meant the DR/CR breakdown here only ever showed the credit side for those two types,
// looking like half the slip was missing. Adding it back as the first row here, rather
// than in credit_entries itself, keeps the stored JSON exactly what approve() posts from.
if ((req.request_type === 'DISBURSEMENT' || req.request_type === 'FUND_TRANSFER') && req.gl_account_no) {
return [
{
dbCr: 'Dr',
accountNo: req.gl_account_no,
accountName: req.gl_account_name,
accountType: 'GL',
amount: parseFloat(req.dr_amount) || 0
},
...entries
];
}
return entries;
return Array.isArray(arr) ? arr.map((e: any) => ({ ...e, amount: parseFloat(e.amount) || 0 })) : [];
} catch {
return [];
}
@@ -209,23 +187,6 @@ export class MyDisbursementRequestsComponent implements OnInit {
return this.getCreditEntries(req).length;
}
/**
* Human-readable account type for the badge. The stored value is the backend's own code
* (SAVINGS / CURRENT / FIXED_DEPOSIT / RECURRING_DEPOSIT / SHARE / LOAN / GL); presentation only.
*/
accountTypeLabel(accountType: string): string {
const labels: Record<string, string> = {
SAVINGS: 'Savings',
CURRENT: 'Current',
FIXED_DEPOSIT: 'Fixed Deposit',
RECURRING_DEPOSIT: 'Recurring Deposit',
SHARE: 'Share',
LOAN: 'Loan',
GL: 'GL'
};
return labels[(accountType ?? '').toUpperCase()] ?? accountType;
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'mdr-status--pending',
@@ -394,7 +394,7 @@
}
</div>
<!-- Minor / Guardian Mapping (mandatory for a minor -- see MINOR_AGE_THRESHOLD) -->
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
<div class="guardian-section">
<!-- Header bar -->
@@ -56,12 +56,6 @@ import {
ContactOtpBlockComponent,
ContactOtpState
} from 'app/clients/edit-client/contact-otp-block/contact-otp-block.component';
import {
MINOR_AGE_THRESHOLD,
SENIOR_CITIZEN_AGE_THRESHOLD,
isMinorAge,
isSeniorCitizenAge
} from 'app/clients/shared/client-age.util';
/**
* Create Client Component
@@ -305,13 +299,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
];
/** Calculated age detail string (Y/M/D) */
calculatedAgeDetail: string | null = null;
/** Whether client is a minor — threshold from the shared age util, derived from Date of Birth */
/** Whether client is a minor (age < 18) */
isMinor = false;
/** Whether client is a senior citizen — threshold from the shared age util, derived from DOB */
/** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */
isSeniorCitizen = false;
/** Exposed for template hints so the displayed rule can never drift from the applied rule. */
readonly minorAgeThreshold = MINOR_AGE_THRESHOLD;
readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD;
/** Age breakdown for DB storage */
ageBreakdown = { years: 0, months: 0, days: 0 };
@@ -1246,8 +1237,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
this.ageBreakdown = { years, months, days };
const wasMinor = this.isMinor;
this.isMinor = isMinorAge(years);
this.isSeniorCitizen = isSeniorCitizenAge(years);
this.isMinor = years < 18;
this.isSeniorCitizen = years >= 60;
// Add/remove guardian form group based on minor status
if (this.isMinor && !wasMinor) {
this.createClientForm.addControl(
@@ -2432,17 +2423,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
{ upTo: null, category: 3, label: 'above ₹10 crore' }
];
/**
* A numeric rule bound counts as configured only when it is present and above zero.
*
* Migration 0448 replaced every unset income/turnover NULL with 0.0, so "absent" now arrives as
* either. Every comparison in calculatedRisk already pairs its check with `> 0` for that reason;
* this keeps the criteria-less test reading the columns the same way.
*/
private static isConfigured(bound: number | string | null | undefined): boolean {
return bound != null && parseFloat(String(bound)) > 0;
}
/** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */
riskCategoryLabel(category: number): string {
if (category === 3) {
@@ -2529,32 +2509,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Evaluate rules from DB (ordered by orderPosition ASC)
if (this.riskRules && this.riskRules.length > 0) {
for (const rule of this.riskRules) {
// Only KYC profile rules categorise a customer at onboarding. The same table also holds
// ~221 AML transaction-monitoring scenarios ("Large cash transaction", "Additional RFIs
// Rule - 91", ...) which describe behaviour and may only fire against posted transactions,
// as AML alerts — RBI Master Direction on KYC separates risk categorisation (Section 12)
// from transaction monitoring (Section 13). ruleKind, set on the row, is what tells them
// apart; it replaced an inference from "this rule has no criteria set", which migration
// 0448 silently broke by writing 0.0 over those NULLs. That let rule 91 match a salaried
// client on an income band it was never meant to carry and pre-flag them for AML.
if (rule.ruleKind === 'AML') {
continue;
}
// Backstop for a rule row predating rule_kind: a rule naming no occupation, no legal form
// and no income or turnover band constrains nothing, so it would match every customer.
// Treat 0 as unset — that is how the comparisons below read it, and what 0448 left behind.
// Skip transaction/AML rules that have no KYC criteria
if (
rule.ruleKind == null &&
rule.legalFormId == null &&
rule.occupationCategoryCvId == null &&
rule.occupationCvId == null &&
!ClientGeneralStepComponent.isConfigured(rule.minMonthlyIncome) &&
!ClientGeneralStepComponent.isConfigured(rule.maxMonthlyIncome) &&
!ClientGeneralStepComponent.isConfigured(rule.minAnnualIncome) &&
!ClientGeneralStepComponent.isConfigured(rule.maxAnnualIncome) &&
!ClientGeneralStepComponent.isConfigured(rule.minAnnualTurnover) &&
!ClientGeneralStepComponent.isConfigured(rule.maxAnnualTurnover)
rule.minMonthlyIncome == null &&
rule.maxMonthlyIncome == null &&
rule.minAnnualIncome == null &&
rule.maxAnnualIncome == null &&
rule.minAnnualTurnover == null &&
rule.maxAnnualTurnover == null
) {
continue;
}
@@ -2602,12 +2567,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
return {
category: rule.riskCategory,
reason: rule.reason,
// A profile rule sets the risk category; it does not raise an AML flag. High Risk means
// enhanced due diligence and a 2-yearly KYC refresh (RBI MD Sections 12 and 38), not
// suspicion — an alert belongs to observed transaction behaviour (Section 13), so a
// customer who has transacted nothing yet stays CLEAR. A bank that genuinely wants a
// rule to flag on match can still say so explicitly via the rule's own amlCheckStatus.
amlCheckStatus: rule.amlCheckStatus || 'CLEAR',
amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'),
bannedEntityMatch: rule.bannedEntityMatch || 'NO',
willfulDefaulter: rule.willfulDefaulter || 'NO',
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
@@ -195,7 +195,7 @@
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
"
>
{{ client.isMinor ? 'Yes' : 'No' }}
{{ client.isMinor ? 'Yes (Age < 18)' : 'No' }}
</span>
</span>
</div>
@@ -187,124 +187,6 @@
}
<div class="ckyc-upload-grid">
<!-- ID Proof Document -->
<mat-card class="ckyc-upload-card">
<mat-card-content>
<div class="ckyc-upload-header">
<mat-icon class="ckyc-upload-icon">badge</mat-icon>
<span>{{ 'labels.inputs.ID Proof Document' | translate }}</span>
</div>
@if (existing.ID_PROOF.present) {
<div class="ckyc-existing">
<div class="ckyc-existing__head">
<fa-icon icon="check-circle" class="ckyc-existing__ok"></fa-icon>
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.ID_PROOF.source }}</span>
@if (existing.ID_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.ID_PROOF.previewUrl && isImage('ID_PROOF')) {
<img [src]="existing.ID_PROOF.previewUrl" alt="ID proof" class="ckyc-existing__thumb" />
}
@if (existing.ID_PROOF.previewUrl) {
<button mat-stroked-button type="button" class="ckyc-existing__view" (click)="openDocument('ID_PROOF')">
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ID_PROOF.name }}</span>
@if (existing.ID_PROOF.documentKey) {
<span class="ckyc-existing__key">{{ existing.ID_PROOF.documentKey }}</span>
}
@if (existing.ID_PROOF.fileCount && existing.ID_PROOF.fileCount > 0) {
<span class="ckyc-existing__count">
{{ existing.ID_PROOF.fileCount }}
{{ existing.ID_PROOF.fileCount === 1 ? 'file' : 'files' }}
</span>
}
</div>
<button
mat-stroked-button
type="button"
class="ckyc-existing__replace"
(click)="toggleReplace('ID_PROOF')"
>
<fa-icon [icon]="existing.ID_PROOF.expanded ? 'times' : 'upload'"></fa-icon>
{{ existing.ID_PROOF.expanded ? 'Cancel replace' : 'Upload new version' }}
</button>
</div>
}
@if (!existing.ID_PROOF.present || existing.ID_PROOF.expanded) {
<p class="ckyc-upload-hint">{{ 'labels.inputs.Accepted formats' | translate }}: PNG, JPEG, JPG, PDF</p>
<div class="ckyc-file-row">
<input
type="file"
#idProofInput
accept=".png,.jpeg,.jpg,.pdf"
(change)="onFileSelect($event, 'ID_PROOF')"
[style.display]="'none'"
/>
<mat-form-field appearance="outline" class="ckyc-file-name-field">
<input
matInput
[readonly]="true"
[value]="idProofFile?.name || ''"
[placeholder]="'labels.text.NoFileSelected' | translate"
/>
</mat-form-field>
<button mat-stroked-button type="button" (click)="idProofInput.click()">
<fa-icon icon="folder-open" size="lg"></fa-icon>
&nbsp;{{ 'labels.buttons.Browse' | translate }}
</button>
</div>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!idProofFile || uploading['ID_PROOF']"
(click)="uploadDocument('ID_PROOF')"
>
@if (uploading['ID_PROOF']) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
{{ 'labels.buttons.Upload' | translate }}
}
</button>
@if (uploadErrors['ID_PROOF']) {
<div class="ckyc-upload-error">
<mat-icon>error_outline</mat-icon>
<span>{{ uploadErrors['ID_PROOF'] }}</span>
</div>
}
@if (uploadResponses['ID_PROOF']) {
<div class="ckyc-upload-response">
<p>
<b>{{ 'labels.inputs.Status' | translate }}:</b> {{ uploadResponses['ID_PROOF'].status }}
</p>
<p>
<b>{{ 'labels.inputs.Request ID' | translate }}:</b> {{ uploadResponses['ID_PROOF'].requestId }}
</p>
<p>
<b>{{ 'labels.inputs.Acknowledgement Number' | translate }}:</b>
{{ uploadResponses['ID_PROOF'].acknowledgementNumber }}
</p>
<p>
<b>{{ 'labels.inputs.Processing Status' | translate }}:</b>
{{ uploadResponses['ID_PROOF'].processingStatus }}
</p>
</div>
}
}
</mat-card-content>
</mat-card>
<!-- Address Proof Document -->
<mat-card class="ckyc-upload-card">
<mat-card-content>
@@ -320,25 +202,8 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
@if (existing.ADDRESS_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.ADDRESS_PROOF.previewUrl && isImage('ADDRESS_PROOF')) {
<img [src]="existing.ADDRESS_PROOF.previewUrl" alt="Address proof" class="ckyc-existing__thumb" />
}
@if (existing.ADDRESS_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('ADDRESS_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ADDRESS_PROOF.name }}</span>
@@ -443,25 +308,8 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
@if (existing.PHOTO_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.PHOTO_PROOF.previewUrl && isImage('PHOTO_PROOF')) {
<img [src]="existing.PHOTO_PROOF.previewUrl" alt="Photo proof" class="ckyc-existing__thumb" />
}
@if (existing.PHOTO_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('PHOTO_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="id-card"></fa-icon>
<span>{{ existing.PHOTO_PROOF.name }}</span>
@@ -565,22 +413,10 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span>
@if (existing.CLIENT_IMAGE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.CLIENT_IMAGE.previewUrl) {
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_IMAGE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<button
mat-stroked-button
@@ -673,9 +509,6 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span>
@if (existing.CLIENT_SIGNATURE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.CLIENT_SIGNATURE.previewUrl) {
@@ -685,17 +518,6 @@
class="ckyc-existing__thumb ckyc-existing__thumb--wide"
/>
}
@if (existing.CLIENT_SIGNATURE.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_SIGNATURE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-signature"></fa-icon>
<span>{{ existing.CLIENT_SIGNATURE.name }}</span>
@@ -638,35 +638,6 @@
}
}
/* Opens the uploaded artefact full size in a new tab. Sits directly under the thumbnail, and is
the only way to inspect a PDF - which cannot render as an inline <img>. */
.ckyc-existing__view {
align-self: flex-start;
color: #1d6fd0;
border-color: #bcd8f5;
background: #fff;
font-size: 13px;
font-weight: 600;
&:hover {
background: #eef6ff;
}
}
/* Shown when a document row exists but its file could not be fetched from the content store,
so "Already available" is not silently misleading. */
.ckyc-existing__warn {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 10px;
background: #fdecec;
border: 1px solid #f3c2c2;
color: #a12b2b;
font-size: 11px;
font-weight: 600;
}
.ckyc-existing {
display: flex;
flex-direction: column;
@@ -9,7 +9,7 @@
/** Angular Imports */
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subject, takeUntil, catchError, of } from 'rxjs';
import { Subject, takeUntil, catchError, of, forkJoin } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
@@ -28,60 +28,8 @@ interface ExistingArtefact {
documentKey?: string;
fileCount?: number;
expanded?: boolean;
/** Set when the stored file could not be fetched - the row exists but its content is gone. */
previewUnavailable?: boolean;
/** Stored MIME type, so the card knows whether it can render an inline thumbnail. */
mimeType?: string;
}
/**
* Multipart `name` / `description` sent for each CKYC artefact type.
*
* The platform has no CKYC-specific document API; these are stored through the generic
* DocumentApiResource, whose contract carries no document-type field. The artefact type therefore
* travels in `name`, which is the same mechanism the client signature already uses - so
* `CLIENT_SIGNATURE` deliberately reuses the existing `clientSignature` value that
* loadExistingArtefacts() (and uploadClientSignatureImage()) match on. Changing that string would
* orphan every previously uploaded signature.
*/
export type CkycDocumentType = 'ID_PROOF' | 'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
const CKYC_DOCUMENT_META: Record<CkycDocumentType, { name: string; description: string }> = {
ID_PROOF: { name: 'ckycIdProof', description: 'CKYC ID proof' },
ADDRESS_PROOF: { name: 'ckycAddressProof', description: 'CKYC address proof' },
PHOTO_PROOF: { name: 'ckycPhotoProof', description: 'CKYC photo proof' },
CLIENT_IMAGE: { name: 'ckycClientImage', description: 'CKYC client photograph' },
CLIENT_SIGNATURE: { name: 'clientSignature', description: 'Client signature' }
};
const CKYC_DOCUMENT_TYPES = Object.keys(CKYC_DOCUMENT_META) as CkycDocumentType[];
/**
* Client-side upload guards, kept deliberately in step with the backend so a file that passes here
* is not rejected server-side:
* - 5 MB matches `spring.servlet.multipart.max-file-size`
* - the extension list is the intersection of the backend `fineract.content.regex-whitelist`
* with what these cards can actually render.
* `accept=` on the input is only a file-picker hint and is trivially bypassed, so the real check
* lives in onFileSelect().
*/
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const IMAGE_EXTENSIONS = [
'png',
'jpeg',
'jpg'
];
const DOCUMENT_EXTENSIONS = [
...IMAGE_EXTENSIONS,
'pdf'
];
/** Types whose card renders an inline <img> preview, so a PDF would not display. */
const IMAGE_ONLY_TYPES: CkycDocumentType[] = [
'CLIENT_IMAGE',
'CLIENT_SIGNATURE'
];
@Component({
selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html',
@@ -131,9 +79,8 @@ export class CkycTabComponent implements OnInit, OnDestroy {
return this.panVerified && this.aadhaarVerified;
}
/** CKYC artefacts already uploaded for this client, keyed by artefact type. */
existing: Record<CkycDocumentType, ExistingArtefact> = {
ID_PROOF: { present: false },
/** Pre-existing artefacts detected from client data */
existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = {
ADDRESS_PROOF: { present: false },
PHOTO_PROOF: { present: false },
CLIENT_IMAGE: { present: false },
@@ -141,7 +88,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
};
/** File references */
idProofFile: File | null = null;
addressProofFile: File | null = null;
photoProofFile: File | null = null;
clientImageFile: File | null = null;
@@ -149,7 +95,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
/** Per-document upload state */
uploading: { [key: string]: boolean } = {
ID_PROOF: false,
ADDRESS_PROOF: false,
PHOTO_PROOF: false,
CLIENT_IMAGE: false,
@@ -161,7 +106,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
/** Per-document error messages */
uploadErrors: { [key: string]: string | null } = {
ID_PROOF: null,
ADDRESS_PROOF: null,
PHOTO_PROOF: null,
CLIENT_IMAGE: null,
@@ -263,110 +207,98 @@ export class CkycTabComponent implements OnInit, OnDestroy {
}
/**
* Loads every CKYC artefact from the client's documents (`m_document`), which is the single
* source of truth for this screen: each card matches the `name` its own upload writes, so what
* the checker sees is exactly what CKYC stored.
*
* Deliberately does NOT read the profile image or client identifiers any more. Those belong to
* client registration and the Identities section; reading them here showed artefacts this screen
* had never uploaded, and hid the ones it had. Those other screens are untouched.
*
* A client can accumulate several rows for one artefact (older uploads pre-date replacement, and
* the signature is also written by client registration), so the newest row - highest `id` - wins.
* Fetch photo (profile image), signature (client document),
* ID proof and address proof (client identifiers) so already-uploaded
* artefacts are shown here instead of forcing re-upload.
*/
private loadExistingArtefacts(): void {
this.checkingExisting = true;
this.clientsService
.getClientDocuments(this.clientId)
.pipe(
takeUntil(this.destroy$),
catchError(() => of([]))
)
.subscribe((documents: any) => {
forkJoin({
photo: this.clientsService.getClientProfileImage(this.clientId).pipe(catchError(() => of(null))),
documents: this.clientsService.getClientDocuments(this.clientId).pipe(catchError(() => of([]))),
identifiers: this.clientsService.getClientIdentifiers(this.clientId).pipe(catchError(() => of([]))),
proofTypes: this.clientsService.getKycProofTypes().pipe(catchError(() => of({ idProofs: [], addressProofs: [] })))
})
.pipe(takeUntil(this.destroy$))
.subscribe(({ photo, documents, identifiers, proofTypes }) => {
this.checkingExisting = false;
const list: any[] = Array.isArray(documents) ? documents : (documents?.pageItems ?? []);
for (const type of CKYC_DOCUMENT_TYPES) {
const expectedName = CKYC_DOCUMENT_META[type].name;
const doc = list
.filter((d: any) => d?.name === expectedName)
.sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0))[0];
if (!doc) {
// No upload yet for this artefact - leave the card in its empty state.
this.existing[type] = { present: false };
continue;
}
this.existing[type] = {
// 1. Photo / image of client — from m_client.image_id
if (photo && typeof photo === 'string' && photo.length > 0) {
this.existing.CLIENT_IMAGE = {
present: true,
source: 'CKYC upload',
name: doc.fileName || expectedName,
documentId: doc.id,
mimeType: doc.type
source: 'Client registration',
previewUrl: photo,
name: 'Profile photo'
};
}
// 2. Signature — client document with name='clientSignature'. Multiple rows can exist
// for clients who re-cropped/replaced their signature before uploads became single-slot,
// so pick the highest id (latest) rather than the first unordered match.
const signatureDoc = (documents as any[])
?.filter((d: any) => d?.name === 'clientSignature')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (signatureDoc) {
this.existing.CLIENT_SIGNATURE = {
present: true,
source: 'Client registration',
name: signatureDoc.fileName || 'Client signature',
documentId: signatureDoc.id
};
// Fetch blob for preview
this.clientsService
.getClientSignatureImage(this.clientId, signatureDoc.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (blob) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
}
},
error: () => undefined
});
}
// 3 & 4. ID proof + Address proof — client identifiers classified via proof-types
const idProofIds = new Set<string>((proofTypes?.idProofs || []).map((p: any) => String(p.id)));
const addressProofIds = new Set<string>((proofTypes?.addressProofs || []).map((p: any) => String(p.id)));
const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || [];
for (const identity of idList) {
const docTypeId = String(identity?.documentType?.id ?? '');
const isActive =
!identity?.status ||
identity?.status?.code === 'clientIdentifierStatusType.active' ||
identity?.status === 'clientIdentifierStatusType.active' ||
identity?.status === 'Active';
if (!isActive) continue;
const proofName = identity?.documentType?.name || 'Identifier';
// Address proof documentKey is auto-generated as "<ProofName> - <clientId>"
// in identities-tab; hide it when it's just that synthetic key.
const rawKey = identity?.documentKey || '';
const isSyntheticKey = rawKey.startsWith(`${proofName} - `);
const common = {
present: true,
source: 'Identities section',
name: proofName,
documentKey: isSyntheticKey ? '' : rawKey,
documentId: identity?.id,
fileCount: (identity?.documents || []).length
};
this.loadPreview(type, doc.id);
}
});
}
/**
* Fetches the stored file so the card can show a thumbnail. A failure here is reported on the
* card rather than swallowed: the usual cause is a `m_document` row whose file is no longer in
* the content store, and silently showing "Already available" with no image hides that.
*/
private loadPreview(type: CkycDocumentType, documentId: string | number): void {
this.clientsService
// silent: a missing stored file returns 500, and the card below already reports that. Without
// this the global error interceptor pops an "Internal Server Error" modal on every page load
// and after every upload, for a failure the user cannot act on.
.downloadClientDocument(this.clientId, String(documentId), true)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (blob && blob.size > 0) {
this.existing[type].previewUrl = URL.createObjectURL(blob);
} else {
// A 200 with an empty body still means there is nothing to show. Without this the card
// would sit there claiming "Already available" with no thumbnail and no explanation.
this.existing[type].previewUnavailable = true;
if (idProofIds.has(docTypeId) && !this.existing.PHOTO_PROOF.present) {
this.existing.PHOTO_PROOF = common;
} else if (addressProofIds.has(docTypeId) && !this.existing.ADDRESS_PROOF.present) {
this.existing.ADDRESS_PROOF = common;
}
},
error: () => {
this.existing[type].previewUnavailable = true;
}
});
}
/**
* True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls
* back to the "View document" button alone rather than rendering a broken image.
*/
isImage(documentType: CkycDocumentType): boolean {
const entry = this.existing[documentType];
if (entry?.mimeType) {
return entry.mimeType.startsWith('image/');
}
// Older rows can have no stored MIME type - fall back to the file extension.
const extension = (entry?.name?.split('.').pop() || '').toLowerCase();
return IMAGE_EXTENSIONS.includes(extension);
}
/**
* Opens the uploaded document full size in a new tab. Uses the blob already fetched for the
* thumbnail, so it works for PDFs too and costs no extra request.
*/
openDocument(documentType: CkycDocumentType): void {
const url = this.existing[documentType]?.previewUrl;
if (url) {
window.open(url, '_blank', 'noopener');
}
}
toggleReplace(documentType: string): void {
const entry = (this.existing as any)[documentType];
if (entry) entry.expanded = !entry.expanded;
@@ -377,38 +309,11 @@ export class CkycTabComponent implements OnInit, OnDestroy {
this.selectedAccountType = this.selectedAccount?.accountType || '';
}
/** Extensions a given card can both store and render. */
private allowedExtensions(documentType: string): string[] {
return IMAGE_ONLY_TYPES.includes(documentType as CkycDocumentType) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS;
}
onFileSelect(event: any, documentType: string): void {
const file = event.target?.files?.[0];
if (!file) return;
/**
* Rejects a file the backend would refuse (or the card could not render) before it is uploaded,
* so the user gets a clear message instead of a raw server error. Returns null when the file is
* acceptable.
*/
private validationError(file: File, documentType: string): string | null {
const allowed = this.allowedExtensions(documentType);
const extension = (file.name.split('.').pop() || '').toLowerCase();
if (!allowed.includes(extension)) {
return `Unsupported file type ".${extension}". Allowed: ${allowed.map((e) => e.toUpperCase()).join(', ')}.`;
}
if (file.size > MAX_UPLOAD_BYTES) {
const mb = (file.size / (1024 * 1024)).toFixed(1);
return `File is ${mb} MB. The maximum allowed size is 5 MB.`;
}
if (file.size === 0) {
return 'The selected file is empty.';
}
return null;
}
private setFile(documentType: string, file: File | null): void {
switch (documentType) {
case 'ID_PROOF':
this.idProofFile = file;
break;
case 'ADDRESS_PROOF':
this.addressProofFile = file;
break;
@@ -422,28 +327,11 @@ export class CkycTabComponent implements OnInit, OnDestroy {
this.clientSignatureFile = file;
break;
}
}
onFileSelect(event: any, documentType: string): void {
const file = event.target?.files?.[0];
if (!file) return;
const error = this.validationError(file, documentType);
if (error) {
this.setFile(documentType, null);
this.uploadErrors[documentType] = error;
event.target.value = '';
return;
}
this.setFile(documentType, file);
this.uploadErrors[documentType] = null;
}
getFile(documentType: string): File | null {
switch (documentType) {
case 'ID_PROOF':
return this.idProofFile;
case 'ADDRESS_PROOF':
return this.addressProofFile;
case 'PHOTO_PROOF':
@@ -457,64 +345,38 @@ export class CkycTabComponent implements OnInit, OnDestroy {
}
}
uploadDocument(documentType: CkycDocumentType): void {
uploadDocument(documentType: string): void {
const file = this.getFile(documentType);
if (!file) return;
this.uploading[documentType] = true;
this.uploadErrors[documentType] = null;
// DocumentApiResource consumes multipart/form-data with body parts `file`, `name` and
// `description` (file + description mandatory). There is no document-type field in that
// contract, so the artefact type is carried in `name` - which is also the key
// loadExistingArtefacts() matches on when reading the document back.
const meta = CKYC_DOCUMENT_META[documentType];
const formData = new FormData();
formData.append('file', file);
formData.append('name', meta.name);
formData.append('description', meta.description);
formData.append('documentType', documentType);
// Replace in place when this artefact already has a usable document, so a re-upload updates the
// existing row (same document id, same content-store location) instead of stacking up another
// copy. Falls back to create for the first upload of an artefact.
//
// Deliberately create instead of replace when the stored file is missing: the backend's update
// path writes the new content relative to the row's existing `location` and never calls
// setLocation(), so replacing a row whose file is already gone writes the upload to a fresh
// folder while the row keeps pointing at the missing one - the artefact would stay broken and
// the user would get a success message. Creating a new row repairs the card instead.
const entry = this.existing[documentType];
const replacing = !!entry?.documentId && !entry?.previewUnavailable;
const existingId = entry?.documentId;
const request$ = replacing
? this.clientsService.replaceClientDocument(this.clientId, String(existingId), formData)
: this.clientsService.uploadCkycDocument(this.clientId, formData);
request$.pipe(takeUntil(this.destroy$)).subscribe({
next: (response: any) => {
this.uploading[documentType] = false;
this.uploadResponses[documentType] = response;
this.setFile(documentType, null);
this.alertService.alert({
type: 'success',
message:
response?.message || (replacing ? 'Document replaced successfully.' : 'Document uploaded successfully.')
});
// Re-read from m_document so the card reflects what was actually stored, rather than
// assuming the write succeeded exactly as sent.
this.loadExistingArtefacts();
},
error: (err: any) => {
this.uploading[documentType] = false;
this.uploadErrors[documentType] =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to upload document.';
}
});
this.clientsService
.uploadCkycDocument(this.clientId, formData)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.uploading[documentType] = false;
this.uploadResponses[documentType] = response;
this.alertService.alert({
type: 'success',
message: response?.message || 'Document uploaded successfully.'
});
},
error: (err: any) => {
this.uploading[documentType] = false;
this.uploadErrors[documentType] =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to upload document.';
}
});
}
ngOnDestroy(): void {
@@ -435,17 +435,12 @@
@if (!showClosedFixedAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="fixedDepositAccountsForDisplay | accountsFilter: 'saving' : 'open' : 'isFixed'">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isFixed'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number
accountNo="{{ element.fdAccountNumber || element.accountNo }}"
></mifosx-account-number>
@if (element.renewalCycleNo) {
<span class="fd-renewal-badge">Renewal {{ element.renewalCycleNo }}</span>
}
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
@@ -529,20 +524,12 @@
<!-- Closed Fixed Deposit Accounts -->
@if (showClosedFixedAccounts) {
<div class="table-wrapper">
<table
mat-table
[dataSource]="fixedDepositAccountsForDisplay | accountsFilter: 'saving' : 'closed' : 'isFixed'"
>
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isFixed'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number
accountNo="{{ element.fdAccountNumber || element.accountNo }}"
></mifosx-account-number>
@if (element.renewalCycleNo) {
<span class="fd-renewal-badge">Renewal {{ element.renewalCycleNo }}</span>
}
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
@@ -209,20 +209,6 @@ $red: #d93025;
gap: 0;
}
// Marks an FD row that is a renewal of an earlier term, since the row shows the original FD number
// rather than this term's own account number.
.fd-renewal-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 7px;
border-radius: 10px;
background-color: $gray-200;
color: $gray-700;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
// ── Table Wrapper ──
.table-wrapper {
border-radius: 0 0 8px 8px;
@@ -234,57 +234,7 @@ export class GeneralTabComponent implements OnDestroy {
}
get fixedDepositCount(): number {
return this.fixedDepositAccountsForDisplay.filter(
(a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit'
).length;
}
/**
* Accounts for the Fixed Deposit sections, with superseded renewal cycles folded away.
*
* An FD renewal closes one account and opens the next term as its own account row, so a renewed FD
* would otherwise appear twice on this page -- the matured term under Closed and the new term under
* Active -- reading as two unrelated deposits. A closed term is therefore dropped whenever a later
* cycle of the same FD (same `fdAccountNumber`) exists, leaving one row per FD that shows its
* current term. A chain that has genuinely ended keeps its final term, so it still lists as closed.
*
* Only FD rows are rewritten; savings, RD, current and share accounts pass through untouched.
*/
get fixedDepositAccountsForDisplay(): any[] {
// Memoised on the source array's identity. This getter is bound straight to two [dataSource] inputs, so
// returning a freshly built array on every change-detection pass would make the table re-bind and
// re-render its rows on every tick -- mouse moves included.
if (this.fdDisplayCacheSource !== this.savingAccounts) {
this.fdDisplayCacheSource = this.savingAccounts;
this.fdDisplayCache = this.foldSupersededFdCycles(this.savingAccounts);
}
return this.fdDisplayCache;
}
private fdDisplayCacheSource: any[] = null;
private fdDisplayCache: any[] = [];
private foldSupersededFdCycles(accounts: any[]): any[] {
const latestCycleByFd = new Map<string, number>();
for (const account of accounts) {
if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') {
continue;
}
const fdNumber = account.fdAccountNumber ?? account.accountNo;
const cycle = account.renewalCycleNo ?? 0;
if (cycle > (latestCycleByFd.get(fdNumber) ?? -1)) {
latestCycleByFd.set(fdNumber, cycle);
}
}
return accounts.filter((account: any) => {
if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') {
return true;
}
const fdNumber = account.fdAccountNumber ?? account.accountNo;
const cycle = account.renewalCycleNo ?? 0;
return cycle === latestCycleByFd.get(fdNumber);
});
return this.savingAccounts.filter((a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit').length;
}
get recurringDepositCount(): number {
@@ -280,82 +280,6 @@
</span>
</div>
<!--
The OTP exchange, directly under the row that started it.
It used to sit at the very bottom of the tab, past the PAN-Aadhaar seeding panel: clicking
Re-Verify appeared to do nothing, because the input it opened was off-screen. Keeping it
against the Aadhaar row means the field is where the officer is already looking.
-->
@if (showAadhaarReVerify && !aadhaarOtpSent) {
<div class="kv-otp-bar kv-otp-bar--inline">
<mat-form-field appearance="outline" class="kv-otp-input">
<mat-label>Enter 12-digit Aadhaar</mat-label>
<input
matInput
[(ngModel)]="aadhaarFullNumber"
[ngModelOptions]="{ standalone: true }"
maxlength="12"
type="password"
/>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="button"
class="kv-otp-btn"
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
@if (verifying.aadhaar) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Send OTP
}
</button>
<button mat-button type="button" class="kv-otp-btn" (click)="cancelAadhaarReVerify()">Cancel</button>
</div>
}
@if (aadhaarOtpSent) {
<div class="kv-otp-bar kv-otp-bar--inline">
<span class="kv-otp-label"><mat-icon>sms</mat-icon> OTP sent</span>
<mat-form-field appearance="outline" class="kv-otp-input">
<mat-label>Enter OTP</mat-label>
<input
matInput
[(ngModel)]="aadhaarOtp"
[ngModelOptions]="{ standalone: true }"
maxlength="6"
autocomplete="one-time-code"
/>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="button"
class="kv-otp-btn"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Verify
}
</button>
<button
mat-stroked-button
type="button"
class="kv-otp-btn"
[disabled]="verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
Resend
</button>
<button mat-button type="button" class="kv-otp-btn" (click)="cancelAadhaarReVerify()">Cancel</button>
</div>
}
<!--
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
only reason Aadhaar carries no KYC exception.
@@ -641,6 +565,73 @@
</div>
}
<!-- Aadhaar OTP inline (shown below table when re-verifying) -->
@if (showAadhaarReVerify && !aadhaarOtpSent) {
<div class="kv-otp-bar">
<mat-form-field appearance="outline" class="kv-otp-input">
<mat-label>Enter 12-digit Aadhaar</mat-label>
<input
matInput
[(ngModel)]="aadhaarFullNumber"
[ngModelOptions]="{ standalone: true }"
maxlength="12"
type="password"
/>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="button"
class="kv-otp-btn"
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
@if (verifying.aadhaar) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Send OTP
}
</button>
</div>
}
@if (aadhaarOtpSent) {
<div class="kv-otp-bar">
<span class="kv-otp-label"><mat-icon>sms</mat-icon> OTP sent</span>
<mat-form-field appearance="outline" class="kv-otp-input">
<mat-label>Enter OTP</mat-label>
<input
matInput
[(ngModel)]="aadhaarOtp"
[ngModelOptions]="{ standalone: true }"
maxlength="6"
autocomplete="one-time-code"
/>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="button"
class="kv-otp-btn"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Verify
}
</button>
<button
mat-stroked-button
type="button"
class="kv-otp-btn"
[disabled]="verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
Resend
</button>
</div>
}
@if (verificationError.aadhaar) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.aadhaar }}
@@ -1137,69 +1128,6 @@
</mat-select>
</mat-form-field>
<!-- Officially Valid Document. Shown when one is on file or the alert is asking for
it, so re-collecting an expired document is done on this form rather than
sending the officer somewhere else. -->
@if (kycData?.kycDocumentName || kycDocumentNeedsUpdate) {
<div class="full-span kyc-doc-fields">
<div class="kyc-doc-fields__head">
<h4 class="kyc-doc-fields__title">Officially Valid Document</h4>
@if (kycDocumentNeedsUpdate) {
<span class="kyc-doc-fields__flag">Re-collect from the customer</span>
}
</div>
<div class="kyc-doc-fields__grid">
<mat-form-field appearance="outline">
<mat-label>Document</mat-label>
<mat-select formControlName="kycDocumentName">
<mat-option value="Aadhaar Card">Aadhaar Card</mat-option>
<mat-option value="Passport">Passport</mat-option>
<mat-option value="Driving Licence">Driving Licence</mat-option>
<mat-option value="Voter ID">Voter ID</mat-option>
<mat-option value="NREGA Job Card">NREGA Job Card</mat-option>
<mat-option value="Letter from National Population Register">Letter from NPR</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Document Number</mat-label>
<input matInput formControlName="kycDocumentNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Issued On</mat-label>
<input
matInput
[matDatepicker]="docIssuePicker"
[max]="maxDate"
formControlName="kycDocIssueDate"
/>
<mat-datepicker-toggle matSuffix [for]="docIssuePicker"></mat-datepicker-toggle>
<mat-datepicker #docIssuePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Valid Till</mat-label>
<input matInput [matDatepicker]="docExpiryPicker" formControlName="kycDocExpiryDate" />
<mat-datepicker-toggle matSuffix [for]="docExpiryPicker"></mat-datepicker-toggle>
<mat-datepicker #docExpiryPicker></mat-datepicker>
<mat-hint>Blank if it does not expire</mat-hint>
</mat-form-field>
</div>
<!-- The scans live with the document itself, on the identifier the customer's
original was attached to. Re-scanning is done there rather than duplicated
here, so both sides, the cropper and the replace-in-place all behave the
same way they did when the document was first collected. -->
<a class="kyc-doc-scans-link" [routerLink]="['/clients', clientId, 'edit']">
<mat-icon>photo_library</mat-icon>
<span>Replace the scanned front / back of this document</span>
<mat-icon class="kyc-doc-scans-link__go">chevron_right</mat-icon>
</a>
</div>
}
<!-- KYC Exception Flag -->
<div
class="full-span exception-controls-row"
@@ -856,18 +856,6 @@
margin-bottom: 12px;
}
// Sitting inside the table, directly under the Aadhaar row it belongs to. Indented and tinted so
// it reads as that row opening up rather than as a new section, and the flat top edge joins it to
// the row above.
.kv-otp-bar--inline {
margin: 0 0 8px 28px;
background: #eff6ff;
border-color: #bfdbfe;
border-top-left-radius: 0;
border-top-right-radius: 0;
flex-wrap: wrap;
}
.kv-otp-label {
display: flex;
align-items: center;
@@ -1588,96 +1576,3 @@
}
}
}
// ── Officially valid document, on the edit form ────────────────────────────
// Set apart from the identity fields above it because it is one document with its own
// provenance — a type, a number, and the two dates that decide whether it is still valid —
// rather than four unrelated inputs.
.kyc-doc-fields {
margin: 4px 0 20px;
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.kyc-doc-fields__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.kyc-doc-fields__title {
margin: 0;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #475569;
}
.kyc-doc-fields__flag {
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: #92400e;
background: #fef3c7;
}
// Four across on a wide screen, folding to two and then one. The dates sit next to each other
// at every width, since it is the pair that has to be read together.
.kyc-doc-fields__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
@media (width <= 1280px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (width <= 640px) {
grid-template-columns: minmax(0, 1fr);
}
mat-form-field {
width: 100%;
}
}
.kyc-doc-scans-link {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
padding: 10px 12px;
border: 1px dashed #cbd5e1;
border-radius: 6px;
font-size: 13px;
color: #334155;
text-decoration: none;
background: #fff;
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: #94a3b8;
background: #f1f5f9;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #64748b;
}
}
.kyc-doc-scans-link__go {
margin-left: auto;
}
@@ -126,12 +126,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
1,
[Validators.required]
],
// The officially valid document, so a lapsed one can be re-collected on this form. The
// backend has always accepted these four; only the fields were missing.
kycDocumentName: [''],
kycDocumentNumber: [''],
kycDocIssueDate: [null],
kycDocExpiryDate: [null],
panNumber: [
'',
[Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
@@ -182,10 +176,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
// the full 12-digit number to re-verify, so don't pre-fill.
aadhaarNumber: '',
gstinNumber: data.gstinNumber || '',
kycDocumentName: data.kycDocumentName || '',
kycDocumentNumber: data.kycDocumentNumber || '',
kycDocIssueDate: this.parseApiDate(data.kycDocIssueDate),
kycDocExpiryDate: this.parseApiDate(data.kycDocExpiryDate),
remarks: data.remarks || '',
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
@@ -365,41 +355,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
* stored record; either way the customer has cleared that check. Verifying one of two
* outstanding items narrows the exception to the remaining one rather than closing it.
*/
/**
* The officially valid document, for the update payload.
*
* Only sent when a document is named, so an officer editing the risk category on a client who
* never had an OVD recorded does not silently blank the other three columns.
*/
private buildDocumentFields() {
const name = (this.kycForm.value.kycDocumentName || '').trim();
if (!name) {
return {};
}
const dateFormat = 'dd MMMM yyyy';
return {
kycDocumentName: name,
kycDocumentNumber: (this.kycForm.value.kycDocumentNumber || '').trim() || null,
kycDocIssueDate: this.kycForm.value.kycDocIssueDate
? this.dateUtils.formatDate(this.kycForm.value.kycDocIssueDate, dateFormat)
: null,
// Blank is meaningful: a passport expires, a PAN card does not.
kycDocExpiryDate: this.kycForm.value.kycDocExpiryDate
? this.dateUtils.formatDate(this.kycForm.value.kycDocExpiryDate, dateFormat)
: null
};
}
/** True while the banner is asking for a document, so the form shows the fields to supply it. */
get kycDocumentNeedsUpdate(): boolean {
return this.showKycDocumentAlert;
}
/** Today, as the latest an existing document can have been issued. */
get maxDate(): Date {
return new Date();
}
private buildExceptionFields() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
@@ -437,7 +392,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
remarks: this.kycForm.value.remarks || null,
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildExceptionFields()
};
@@ -524,12 +478,11 @@ export class KycTabComponent implements OnInit, OnDestroy {
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
// A PAN that now verifies makes any earlier declaration moot. That write already
// re-derives the exception columns, so it settles the exception too.
// A PAN that now verifies makes any earlier declaration moot.
if (this.panDeclarationTaken) {
this.writePanDeclaration(null);
} else {
this.syncExceptionAfterVerify();
this.loadKycData();
}
} else {
this.verificationStatus.pan = 'FAILED';
@@ -707,60 +660,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
});
}
/**
* Re-derives the exception columns once a verification has actually passed.
*
* `saveKycBeforeVerify()` writes the exception state *before* the vendor is called, when the
* check is by definition still outstanding — so a check that then succeeds leaves the record
* carrying an exception nobody closes. The KYC tab reads `panVerified` and shows "Verified"
* while the exceptions register keeps reading `isException`, which is why a client could sit in
* the Pending tab with every document verified.
*
* Called on each verification success. `buildExceptionFields()` already prefers the in-session
* `verificationStatus` over the stored record, so the freshly passed check counts even though
* `kycData` has not been reloaded yet; `hadException` still reads the stored value, which is
* what tells it to close the entry rather than leave every flag off.
*
* Verifying one of two outstanding items narrows the exception instead of closing it — that
* falls out of the derivation, so this needs no special case for it.
*/
private syncExceptionAfterVerify(): void {
if (!this.kycId) {
this.loadKycData();
return;
}
const exceptionFields = this.buildExceptionFields();
// Nothing recorded and nothing outstanding — no write would change anything.
if (this.kycData?.isException !== true && !exceptionFields.isException) {
this.loadKycData();
return;
}
this.clientsService
.updateClientKyc(this.clientId, this.kycId, {
locale: 'en',
dateFormat: 'dd MMMM yyyy',
riskCategory: this.kycForm.value.riskCategory || 1,
...exceptionFields
})
.pipe(takeUntil(this.destroy$))
.subscribe({
// The verification itself succeeded either way, so a failure here must not read as a
// failed check — surface it as the save problem it is and still refresh.
next: () => this.loadKycData(),
error: (err: any) => {
this.errorMessage =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Verification succeeded but the KYC exception could not be updated.';
this.loadKycData();
}
});
}
/**
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
*
@@ -887,12 +786,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationError.aadhaar = null;
}
/** Abandons the OTP exchange, so a mis-click can be backed out of without reloading the tab. */
cancelAadhaarReVerify(): void {
this.resetOtpState();
this.verificationError.aadhaar = null;
}
/** Step 1: Send OTP to Aadhaar-linked mobile */
sendAadhaarOtp(): void {
const aadhaar = this.isEditMode ? this.kycForm?.get('aadhaarNumber')?.value : this.aadhaarFullNumber;
@@ -962,7 +855,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
if (this.aadhaarReceiptTaken) {
this.writeAadhaarReceipt(null);
} else {
this.syncExceptionAfterVerify();
this.loadKycData();
}
} else {
this.verificationStatus.aadhaar = 'FAILED';
@@ -105,7 +105,7 @@
[class.pd-verify-badge--verified]="!clientViewData.isMinor"
[class.pd-verify-badge--pending]="clientViewData.isMinor"
>
{{ clientViewData.isMinor ? 'Yes' : 'No' }}
{{ clientViewData.isMinor ? 'Yes (Age < 18)' : 'No' }}
</span>
</div>
</div>
+7 -97
View File
@@ -16,7 +16,7 @@ import { Observable, of, throwError } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { map, catchError } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import { environment } from 'environments/environment';
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
/**
@@ -536,17 +536,6 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/identifiers`, identifierData);
}
/**
* Updates an identifier in place — the document number, its type, or the description.
*
* Without this the only way to record a re-collected document was to post a new identifier, so
* a customer whose Aadhaar was re-verified ended up with two rows carrying the same number and
* the same scans, and nothing saying which was current.
*/
updateClientIdentifier(clientId: string, identifierId: string, identifierData: any) {
return this.http.put(`/clients/${clientId}/identifiers/${identifierId}`, identifierData);
}
deleteClientIdentifier(clientId: string, identifierId: string) {
return this.http.delete(`/clients/${clientId}/identifiers/${identifierId}`);
}
@@ -569,19 +558,8 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/documents`);
}
/**
* Fetches a client document's stored file.
*
* `silent` opts the request out of ErrorHandlerInterceptor's alert. Pass it when the caller
* renders its own failure state - notably thumbnail fetches, where a `m_document` row whose file
* is no longer in the content store returns 500 and would otherwise pop a modal the user can do
* nothing about. The interceptor still rethrows, so the caller's error callback continues to run.
*/
downloadClientDocument(parentEntityId: string, documentId: string, silent = false) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, {
responseType: 'blob',
...(silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {})
});
downloadClientDocument(parentEntityId: string, documentId: string) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' });
}
uploadClientDocument(clientId: string, documentData: any) {
@@ -1003,28 +981,12 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
}
/**
* Uploads a single CKYC artefact against the client, using the platform's generic document
* endpoint (DocumentApiResource, POST /v1/{entityType}/{entityId}/documents). There is no
* CKYC-specific document API on the backend - the earlier `/clients/{id}/ckyc/documents` path
* was never implemented and returned 404.
*
* The multipart body must carry `file`, `name` and `description`; the caller builds those so the
* stored document stays identifiable by CKYC artefact type. See ckyc-tab.component.ts.
*/
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/documents`, formData);
getClientCkyc(clientId: string): Observable<any> {
return this.http.get(`/clients/${clientId}/ckyc`);
}
/**
* Replaces the content of an existing client document in place (DocumentApiResource PUT
* /v1/{entityType}/{entityId}/documents/{documentId}), keeping the same document id.
*
* Used by the CKYC screen's "Upload new version" so a re-upload updates the existing artefact
* rather than adding another row. Takes the same multipart body as the create call.
*/
replaceClientDocument(clientId: string, documentId: string, formData: FormData): Observable<any> {
return this.http.put(`/clients/${clientId}/documents/${documentId}`, formData);
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/ckyc/documents`, formData);
}
/**
@@ -1081,56 +1043,4 @@ export class ClientsService {
getKycContactLog(clientId: string | number): Observable<any[]> {
return this.http.get<any[]>(`/kyc/exceptions/${clientId}/contact-log`);
}
/**
* Clients whose periodic re-KYC due date has passed, oldest first.
*
* Drives the expiry worklist. A client drops out of this list the moment their re-KYC is
* completed, because renewing pushes the expiry date into the future.
*/
getLapsedKyc(): Observable<any[]> {
return this.http.get<any[]>('/kyc/exceptions/lapsed');
}
/**
* Savings accounts the dormancy job has marked.
*
* @param subStatus 100 inactive, 200 dormant, 300 escheat; omit for all three
*/
getDormantAccounts(subStatus?: number): Observable<any[]> {
const params = subStatus ? new HttpParams().set('subStatus', String(subStatus)) : undefined;
return this.http.get<any[]>('/savingsaccounts/dormant', { params });
}
/**
* Records that the escheated balances of the given accounts were remitted to RBI's DEAF.
*
* The transfer itself happens outside this system; this only writes down that it was made, which
* is what lets the escheat report tell remitted money from money still sitting with the bank.
* Accounts that are not escheated, or already recorded, are skipped server-side — the result
* carries `recorded` and `skipped` counts.
*/
recordDeafRemittance(data: {
savingsAccountIds: number[];
remittedOn: string;
deafReference?: string;
remarks?: string;
locale?: string;
dateFormat?: string;
}): Observable<any> {
return this.http.post('/savingsaccounts/deaf-remittance', {
locale: 'en',
dateFormat: 'yyyy-MM-dd',
...data
});
}
/** Records that a customer claimed a remitted balance back and the bank recovered it from DEAF. */
refundDeafRemittance(savingsAccountId: number, refundedOn: string): Observable<any> {
return this.http.post(`/savingsaccounts/deaf-remittance/${savingsAccountId}/refund`, {
locale: 'en',
dateFormat: 'yyyy-MM-dd',
refundedOn
});
}
}
@@ -1079,15 +1079,12 @@ export class EditClientComponent {
*/
private saveKycDocProof(clientId: string) {
const payload = this.clientKycStep?.kycDocPayload;
// A document number corrected without re-scanning the document is still a change worth
// saving, so this no longer requires files — it used to return here and the new number was
// silently dropped.
if (!payload?.proofId) return;
if (!payload?.proofId || !payload?.files?.length) return;
const proofName = payload.proof?.name || 'KYC Document';
const uploadFiles = (identifierId: string) => {
for (const entry of payload.files || []) {
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
formData.append('file', entry.file);
@@ -1095,26 +1092,8 @@ export class EditClientComponent {
}
};
// A document already on file is updated in place, not posted again.
//
// Re-collecting a document used to create a second identifier carrying the same number and
// the same scans, with nothing to say which of the two was current — the client in front of
// this was left with two identical Aadhaar rows. The document the customer renewed is the
// one already recorded, so its number and type are written back to that row and any new
// scans are attached to it.
const existingId = this.clientKycStep?.existingKycIdentifierId;
if (existingId) {
this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => {
const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'Active' };
if (documentTypeId != null) {
update.documentTypeId = documentTypeId;
}
this.clientsService.updateClientIdentifier(clientId, existingId, update).subscribe({
// The scans go up either way: failing to rewrite the number must not cost the images.
next: () => uploadFiles(existingId),
error: () => uploadFiles(existingId)
});
});
if (this.clientKycStep?.existingKycIdentifierId) {
uploadFiles(this.clientKycStep.existingKycIdentifierId);
return;
}
@@ -230,23 +230,6 @@ describe('deriveKycExceptionFields', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
});
it('closes a record left open by a verification that passed after the exception was written', () => {
// A verification writes the exception before calling the vendor, so a check that then passes
// leaves `isException` set with the stale reason still loaded into the form's reason list.
// Saving such a record has to close it, not re-raise it off its own stale reason.
expect(
deriveKycExceptionFields({
...existingException(true, true, KYC_EXCEPTION_PAN),
manualReasons: [KYC_EXCEPTION_PAN]
})
).toEqual({
isException: false,
exceptionReason: KYC_EXCEPTION_PAN,
exceptionResolved: true,
exceptionResolvedOn: TODAY
});
});
});
describe('re-opening', () => {
@@ -11,7 +11,7 @@ import { UntypedFormGroup } from '@angular/forms';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, filter, switchMap, takeUntil, timeout, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { environment } from '../../../environments/environment';
import { environment } from 'environments/environment';
import { ClientsService } from '../clients.service';
import {
EXTERNAL_ID_STATUS_KEYS,
-97
View File
@@ -1,97 +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/.
*/
/**
* Single source of truth for every age-derived client classification.
*
* These rules were previously re-implemented as bare literals in ten separate components (the client
* stepper plus each product's account-details step), which is how a client could end up classified one
* way on the client screen and another way on the account screen -- and, in the FD case, how a
* 24-year-old ended up with a senior-citizen interest rate. Anything that needs to know whether a
* client is a minor or a senior citizen must derive it from here rather than comparing ages inline.
*/
/** Below this age a client is a minor: guardian mapping required, restricted KYC/OVD document set. */
export const MINOR_AGE_THRESHOLD = 10;
/** At or above this age a client qualifies for senior-citizen benefits (higher deposit rates). */
export const SENIOR_CITIZEN_AGE_THRESHOLD = 60;
/**
* Age at which a minor account must be converted to a regular one. Deliberately independent of
* {@link MINOR_AGE_THRESHOLD}: legal majority in India is 18 regardless of the age at which this CBS
* chooses to stop applying minor-specific onboarding rules.
*/
export const AGE_OF_MAJORITY = 18;
/** Parses the backend's date-of-birth representation -- either a `[yyyy, m, d]` array or a date string. */
export function parseClientDob(dob: any): Date | null {
if (!dob) return null;
if (Array.isArray(dob) && dob.length >= 3) {
return new Date(dob[0], dob[1] - 1, dob[2]);
}
const parsed = new Date(dob);
return isNaN(parsed.getTime()) ? null : parsed;
}
/**
* Completed years between `dob` and `asOf`. Pass the tenant's business date as `asOf` wherever it is
* available, so a back-dated or forward-dated business date classifies the client the same way the
* rest of the system does.
*/
export function calculateAge(dob: Date, asOf?: Date | null): number {
const today = asOf ?? new Date();
let age = today.getFullYear() - dob.getFullYear();
const monthDiff = today.getMonth() - dob.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
age--;
}
return age;
}
/** True when the age qualifies as a minor. `null` (unknown DOB) is never a minor. */
export function isMinorAge(age: number | null | undefined): boolean {
return age !== null && age !== undefined && age < MINOR_AGE_THRESHOLD;
}
/** True when the age qualifies for senior-citizen benefits. `null` (unknown DOB) never qualifies. */
export function isSeniorCitizenAge(age: number | null | undefined): boolean {
return age !== null && age !== undefined && age >= SENIOR_CITIZEN_AGE_THRESHOLD;
}
/**
* Whether a client record represents a natural person. Only a natural person can be a minor or a
* senior citizen -- an entity/company has no age, so age-derived benefits must never apply to one
* even if some incorporation date happens to be stored against it.
*/
export function isIndividualClient(client: any): boolean {
const legalForm = client?.legalForm;
if (!legalForm) return true; // absent legal form: treat as individual, matching the backend default
const code = String(legalForm.code ?? '').toLowerCase();
if (code) return code.includes('individual') && !code.includes('nonindividual');
const value = String(legalForm.value ?? legalForm.name ?? '').toLowerCase();
return value === 'individual';
}
/**
* The senior-citizen decision for a whole client record: individual, known DOB, and old enough.
* Returns the derived age alongside the verdict so callers can show "why" without recomputing it.
*/
export function deriveSeniorCitizenStatus(
client: any,
asOf?: Date | null
): { isSeniorCitizen: boolean; age: number | null; isIndividual: boolean } {
const isIndividual = isIndividualClient(client);
const dob = parseClientDob(client?.dateOfBirth);
const age = dob ? calculateAge(dob, asOf) : null;
return {
isSeniorCitizen: isIndividual && isSeniorCitizenAge(age),
age,
isIndividual
};
}
+1 -15
View File
@@ -47,12 +47,7 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
private handleError(response: HttpErrorResponse, request: HttpRequest<any>): Observable<HttpEvent<any>> {
const status = response.status;
const errorBody = response.error;
// defaultUserMessage first: it is the sentence written for the person reading it, while
// developerMessage is for the log. Falling back to response.message last, since that is
// Angular's own "Http failure response for <url>: 409 Conflict" — a description of the
// transport, not of what went wrong, and useless to whoever is looking at the dialog.
let errorMessage =
errorBody?.defaultUserMessage || errorBody?.developerMessage || errorBody?.message || response.message;
let errorMessage = errorBody?.developerMessage || response.message;
if (errorBody?.errors?.[0]) {
errorMessage = errorBody.errors[0].defaultUserMessage || errorBody.errors[0].developerMessage;
}
@@ -109,15 +104,6 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
type: this.translate.instant('error.resource.not.found'),
message: errorMessage || 'Resource does not exist!'
};
} else if (status === 409) {
// A 409 is not a failure of permission or of the request itself — the action is simply not
// allowed in the record's current state (already approved, maker cannot check their own work,
// a guarantee still outstanding). The server always explains which, so its message is shown
// as-is under a title that does not contradict it.
alert = {
type: 'Action Not Allowed',
message: errorMessage || 'This action is not allowed in the current state.'
};
} else if (status === 500) {
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
} else if (status === 501) {
@@ -1,228 +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, OnDestroy } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { fromEvent, Subscription, BehaviorSubject } from 'rxjs';
import { KeyboardShortcutsConfiguration } from '../../keyboards-shortcut-config';
import { AuthenticationService } from '../authentication/authentication.service';
import { UserPreferenceService } from './user-preference.service';
import { DocumentationLinksService } from '../../shared/services/documentation-links.service';
export interface ShortcutItem {
title: string;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
key: string;
route: string;
id?: string;
custom?: boolean;
action?: string;
}
@Injectable({
providedIn: 'root'
})
export class KeyboardShortcutService implements OnDestroy {
private router = inject(Router);
private activatedRoute = inject(ActivatedRoute);
private authenticationService = inject(AuthenticationService);
private userPreferenceService = inject(UserPreferenceService);
private documentationLinks = inject(DocumentationLinksService);
private keydownSubscription: Subscription;
private activeShortcutsSubject = new BehaviorSubject<ShortcutItem[]>([]);
public activeShortcuts$ = this.activeShortcutsSubject.asObservable();
constructor() {
this.keydownSubscription = fromEvent<KeyboardEvent>(window, 'keydown').subscribe((event) => {
this.handleKeydown(event);
});
}
ngOnDestroy(): void {
if (this.keydownSubscription) {
this.keydownSubscription.unsubscribe();
}
}
public initShortcuts(): void {
const credentials = this.authenticationService.getCredentials();
if (!credentials || !credentials.userId) {
this.loadDefaults();
return;
}
this.userPreferenceService.getPreference(credentials.userId, 'keyboard_shortcuts').subscribe({
next: (data) => {
if (data && data.value) {
try {
const parsed = JSON.parse(data.value);
if (Array.isArray(parsed)) {
parsed.forEach((shortcut: ShortcutItem) => {
if (shortcut.custom && shortcut.action === 'navigate' && shortcut.route) {
shortcut.route = this.normalizeRouteStr(shortcut.route);
}
});
}
this.activeShortcutsSubject.next(parsed);
} catch (e) {
console.error('Error parsing shortcuts', e);
this.loadDefaults();
}
} else {
this.loadDefaults();
}
},
error: () => {
this.loadDefaults();
}
});
}
public loadDefaults(): void {
const config = new KeyboardShortcutsConfiguration();
this.activeShortcutsSubject.next(config.buttonsArray);
}
public updateShortcuts(shortcuts: ShortcutItem[]): void {
const credentials = this.authenticationService.getCredentials();
if (credentials && credentials.userId) {
const cleanedShortcuts = shortcuts.map((s) => {
const clean: any = {
title: s.title,
ctrlKey: s.ctrlKey,
shiftKey: s.shiftKey,
altKey: s.altKey,
key: s.key,
route: s.route
};
if (s.id !== undefined) clean.id = s.id;
if (s.custom !== undefined) clean.custom = s.custom;
if (s.action !== undefined) clean.action = s.action;
return clean;
});
this.userPreferenceService
.updatePreference(credentials.userId, 'keyboard_shortcuts', JSON.stringify(cleanedShortcuts))
.subscribe(() => {
this.activeShortcutsSubject.next(shortcuts);
});
} else {
this.activeShortcutsSubject.next(shortcuts);
}
}
public resetToDefaults(): void {
const credentials = this.authenticationService.getCredentials();
const config = new KeyboardShortcutsConfiguration();
if (credentials && credentials.userId) {
this.userPreferenceService
.updatePreference(credentials.userId, 'keyboard_shortcuts', JSON.stringify(config.buttonsArray))
.subscribe(() => {
this.activeShortcutsSubject.next(config.buttonsArray);
});
} else {
this.activeShortcutsSubject.next(config.buttonsArray);
}
}
private handleKeydown(event: KeyboardEvent): void {
const activeShortcuts = this.activeShortcutsSubject.getValue();
const routeD = activeShortcuts.find(
(x) =>
x.ctrlKey === event.ctrlKey &&
x.shiftKey === event.shiftKey &&
x.altKey === event.altKey &&
x.key.toLowerCase() === event.key.toLowerCase()
);
if (routeD !== undefined) {
event.preventDefault(); // Prevent default browser actions for matched shortcuts
if (routeD.custom) {
if (routeD.action === 'navigate' && routeD.route) {
this.router.navigate([routeD.route]);
}
} else {
switch (routeD.id) {
case 'logout':
this.authenticationService.logout().subscribe(() => this.router.navigate(['/login'], { replaceUrl: true }));
break;
case 'help':
this.documentationLinks.open('userManual');
break;
case 'runReport':
const runReportElement = document.getElementById('runReport');
if (runReportElement) runReportElement.click();
break;
case 'cancel':
const cancelEl = document.querySelector('[data-shortcut-action="cancel"]') as HTMLElement;
if (cancelEl) {
cancelEl.click();
} else {
const cancelButtons = Array.from(document.querySelectorAll('button')).filter(
(el) => el.textContent && el.textContent.trim() === 'Cancel'
);
if (cancelButtons.length > 0) cancelButtons[0].click();
}
break;
case 'submit':
const submitEl = document.querySelector('[data-shortcut-action="submit"]') as HTMLElement;
if (submitEl) {
submitEl.click();
} else {
const submitButtons = Array.from(document.querySelectorAll('button')).filter(
(el) => el.textContent && el.textContent.trim() === 'Submit'
);
if (submitButtons.length > 0) submitButtons[0].click();
}
break;
default:
this.router.navigate([routeD.route], { relativeTo: this.activatedRoute });
}
}
}
}
public normalizeRouteStr(route: string | undefined | null): string {
if (!route) return '';
let r = route.trim();
try {
if (r.startsWith('http://') || r.startsWith('https://')) {
const url = new URL(r);
if (url.hash && url.hash.startsWith('#')) {
let path = url.hash.substring(1);
if (!path.startsWith('/')) {
path = '/' + path;
}
return path;
}
return url.pathname + url.search;
}
} catch (e) {
// Ignore
}
if (r.includes('#/')) {
r = r.substring(r.indexOf('#/') + 1);
}
if (/^[a-zA-Z0-9]+:/.test(r)) {
return '/';
}
if (!r.startsWith('/')) {
r = '/' + r;
}
return r;
}
}
@@ -1,161 +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, OnDestroy } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { AuthenticationService } from '../authentication/authentication.service';
import { KeyboardShortcutService } from './keyboard-shortcut.service';
import { UserPreferenceService } from './user-preference.service';
import { KeyboardShortcutsDialogComponent } from '../../shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component';
import { filter, take } from 'rxjs/operators';
import { Subscription } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class ShortcutSuggestionService implements OnDestroy {
private router = inject(Router);
private titleService = inject(Title);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private authenticationService = inject(AuthenticationService);
private keyboardShortcutService = inject(KeyboardShortcutService);
private userPreferenceService = inject(UserPreferenceService);
private readonly FREQUENCY_THRESHOLD = 5;
private readonly PREF_KEY = 'shortcut_suggestions_state';
private routerSubscription!: Subscription;
private state: { dismissed: string[] } = { dismissed: [] };
private get userId(): number | null {
const creds = this.authenticationService.getCredentials();
return creds ? creds.userId : null;
}
private getVisitsKey(): string {
return `route_visits_${this.userId}`;
}
private getVisits(): Record<string, number> {
const key = this.getVisitsKey();
if (!key) return {};
const val = localStorage.getItem(key);
return val ? JSON.parse(val) : {};
}
private saveVisits(visits: Record<string, number>) {
const key = this.getVisitsKey();
if (key) {
localStorage.setItem(key, JSON.stringify(visits));
}
}
private isSuggestionOpen = false;
public init() {
const uid = this.userId;
if (uid) {
this.userPreferenceService.getPreference(uid, this.PREF_KEY).subscribe({
next: (data) => {
if (data && data.value) {
try {
this.state = JSON.parse(data.value);
if (!this.state.dismissed) this.state.dismissed = [];
} catch (e) {
this.state = { dismissed: [] };
}
}
},
error: () => {}
});
}
this.routerSubscription = this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe((event: any) => {
this.handleNavigation(event.urlAfterRedirects);
});
}
private handleNavigation(url: string) {
if (!this.userId) return;
let path = url.split('?')[0];
path = this.keyboardShortcutService.normalizeRouteStr(path);
const visits = this.getVisits();
visits[path] = (visits[path] || 0) + 1;
this.saveVisits(visits);
}
public getUsageData(): { route: string; pageName: string; visits: number; hasShortcut: boolean }[] {
const visits = this.getVisits();
const result: { route: string; pageName: string; visits: number; hasShortcut: boolean }[] = [];
// Get current shortcuts (sync)
let currentShortcuts: any[] = [];
this.keyboardShortcutService.activeShortcuts$.pipe(take(1)).subscribe((s) => (currentShortcuts = s));
for (const route of Object.keys(visits)) {
const count = visits[route];
const hasShortcut = currentShortcuts.some(
(s) =>
(s.custom && s.action === 'navigate' && this.keyboardShortcutService.normalizeRouteStr(s.route) === route) ||
(!s.custom && this.keyboardShortcutService.normalizeRouteStr(s.route) === route)
);
let pageName = route.split('/').pop() || route;
// Capitalize and clean
pageName = pageName.replace(/-/g, ' ');
pageName = pageName.charAt(0).toUpperCase() + pageName.slice(1);
result.push({ route, pageName, visits: count, hasShortcut });
}
return result.sort((a, b) => b.visits - a.visits);
}
public markAsDismissed(route: string) {
if (!this.state.dismissed.includes(route)) {
this.state.dismissed.push(route);
const uid = this.userId;
if (uid) {
this.userPreferenceService.updatePreference(uid, this.PREF_KEY, JSON.stringify(this.state)).subscribe();
}
}
}
public openCreateShortcutDialog(pageName: string, route: string) {
this.dialog
.open(KeyboardShortcutsDialogComponent, {
data: {
prefill: {
title: pageName,
route: route,
action: 'navigate'
}
}
})
.afterClosed()
.subscribe((saved) => {
if (saved) {
this.markAsDismissed(route);
}
});
}
ngOnDestroy() {
if (this.routerSubscription) {
this.routerSubscription.unsubscribe();
}
}
}
@@ -1,28 +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 } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class UserPreferenceService {
private http = inject(HttpClient);
getPreference(userId: number, preferenceName: string): Observable<{ name: string; value: string }> {
const timestamp = new Date().getTime();
return this.http.get<{ name: string; value: string }>(
`/users/${userId}/preferences/${preferenceName}?_t=${timestamp}`
);
}
updatePreference(userId: number, preferenceName: string, value: string): Observable<{ name: string; value: string }> {
return this.http.put<{ name: string; value: string }>(`/users/${userId}/preferences/${preferenceName}`, { value });
}
}
+20 -204
View File
@@ -86,7 +86,7 @@
<line x1="9" y1="14" x2="15" y2="14" />
<line x1="9" y1="18" x2="13" y2="18" />
</svg>
Withdrawal Request
My Requests
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
@@ -97,7 +97,7 @@
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
Disbursements &amp; Transfer
My Disbursements
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
@@ -112,17 +112,6 @@
</svg>
My Share Requests
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/closure-requests')"
[routerLink]="['/clerk/closure-requests']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
My Closure Requests
</a>
@if (clerkAccountLoaded) {
<div class="cashier-nav__item-wrap">
<a
@@ -184,44 +173,6 @@
Today's Activity
</a>
}
<!-- Outside the clerkAccountLoaded guard: the voucher register is the day's work, not one
account's, so it must be reachable before any account has been pulled up. -->
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/my-work')"
[routerLink]="['/clerk/my-work']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
My Vouchers
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/received-report')"
[routerLink]="['/clerk/received-report']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 3h16v18l-3-2-2 2-3-2-3 2-2-2-3 2z" />
<line x1="8" y1="9" x2="16" y2="9" />
<line x1="8" y1="13" x2="14" y2="13" />
</svg>
Received Vouchers
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/payment-report')"
[routerLink]="['/clerk/payment-report']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 3h16v18l-3-2-2 2-3-2-3 2-2-2-3 2z" />
<line x1="8" y1="9" x2="16" y2="9" />
<line x1="8" y1="13" x2="14" y2="13" />
<line x1="12" y1="5" x2="12" y2="7" />
</svg>
Payment Vouchers
</a>
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav>
} @else {
@@ -283,23 +234,6 @@
>
<fa-icon icon="history"></fa-icon> Cash History
</a>
<a class="cashier-nav__item" [routerLink]="['/cashier/my-work']" routerLinkActive="cashier-nav__item--active">
<fa-icon icon="user-check"></fa-icon> My Vouchers
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/received-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="list"></fa-icon> Received
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/payment-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="list"></fa-icon> Payment
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/closing-report']"
@@ -473,7 +407,7 @@
════════ END OLD ORDER ════════
-->
@if (!hideShellLayout && menuAccessReady) {
@if (!hideShellLayout) {
<div class="main-header">
<!-- Row 1 — Client Master · CFCFRMS · Approvals · Cashier · Clearing · Deposits · Advances · Share · NPA · General Ledger · Sub Ledger -->
<div class="nav-row r1">
@@ -496,13 +430,14 @@
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="clerkMenu" *mifosxHasPermission="'MENU_CLERK'">
<a
class="ni"
tabindex="0"
routerLink="/clerk/dashboard"
(click)="hideShellLayout = true"
*mifosxHasPermission="'MENU_CLERK'"
>
<fa-icon icon="id-card" class="ni-ico"></fa-icon>Clerk
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="branchOpsMenu" *mifosxHasPermission="'MENU_BRANCH_OPS'">
<fa-icon icon="building" class="ni-ico"></fa-icon>Branch Ops
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="clearingMenu" *mifosxHasPermission="'MENU_CLEARING'">
<fa-icon icon="exchange" class="ni-ico"></fa-icon>Clearing
@@ -524,12 +459,7 @@
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
[matMenuTriggerFor]="loanOriginationMenu"
*mifosxHasPermission="'MENU_LOAN_ORIGINATION'"
>
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
@@ -591,7 +521,7 @@
class="ni"
tabindex="0"
(click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false"
*mifosxHasPermission="'MENU_INVESTMENT'"
*mifosxHasPermission="'MENU_RBI'"
>
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
@@ -652,9 +582,6 @@
</mat-menu>
<mat-menu #reportsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
@@ -674,29 +601,12 @@
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
<mat-divider></mat-divider>
<!-- Listed here as well as under Branch Ops: an officer looking for the day's figures reaches
for Reports, and the same screen answers both. -->
<button mat-menu-item routerLink="/branch-ops/day-book" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Day Book &amp; Officer Verification
</button>
<button mat-menu-item routerLink="/branch-ops/end-of-day" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> End of the Day Report
</button>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry &amp; Outreach
</button>
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant &amp; DEAF
</button>
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
@@ -760,7 +670,7 @@
<!-- Audit & Compliance -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Audit &amp; Compliance</div>
<button mat-menu-item tabindex="0" [routerLink]="['/organization/institution-profile']">
<button mat-menu-item tabindex="0">
<fa-icon icon="building" class="menu-item-icon" style="color: #7c3aed"></fa-icon>
Bank Profile
</button>
@@ -1083,79 +993,6 @@
}
<!-- Sub Menu Dropdowns -->
<!-- Branch-level operations. Starts with the day register; other branch-wide controls belong
here rather than under Cashier, which is one role's workspace. -->
<mat-menu #branchOpsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
<!-- The day book carries the Officer Verification Sheet — each person's credit, debit and net
for the day. It was reachable only from the Clerk and Cashier menus, which is the one
place a principal officer signing the day off does not look. -->
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book &amp; Verification
</button>
<!-- The signed sheet, as against the day book's working list: the day's modes, the drawer
against the books, and what is still open. This is what goes in the file. -->
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</mat-menu>
<!-- Clerk was a bare link straight into the fullscreen dashboard, which left the clerk's other
screens reachable only from inside that fullscreen nav. Anything a clerk needs from the
normal shell — an open voucher exception above all — belongs here. -->
<mat-menu #clerkMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<!-- The clerk reads the day as a sheet to sign off, not as a working list of vouchers: the
day's summary, the counter counted against the books, and what is still open. The day
book's own row-by-row view stays reachable from Branch Ops and from the Cashier menu. -->
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Disbursements &amp; Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-work']">
<fa-icon icon="user-check" class="menu-item-icon"></fa-icon> My Vouchers
</button>
<!-- Sits under My Vouchers because it answers the same question for the one worker who has no
own list: the system. A nightly run belongs to nobody, so it appears on no person's page. -->
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/system-vouchers']">
<fa-icon icon="robot" class="menu-item-icon"></fa-icon> System Vouchers
</button>
<!-- A clerk supervises the counter, so they need to see what each cashier is holding and what
went through their drawer. Both screens already return every cashier for a non-cashier
caller (CashierRoleHelper scopes only a cashier to themselves) — they were simply not
reachable from this menu. -->
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/received-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Received Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/payment-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Payment Vouchers
</button>
</mat-menu>
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
@@ -1190,9 +1027,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
@@ -1236,9 +1070,6 @@
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/savings-daily-interest']">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Savings Daily Interest
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
</button>
@@ -1285,6 +1116,12 @@
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/issue-approvals']" tabindex="0">
<fa-icon icon="check-double" class="menu-item-icon"></fa-icon> DD Issuance Approvals
</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/reprint-approvals']" tabindex="0">
<fa-icon icon="print" class="menu-item-icon"></fa-icon> DD Reprint Approvals
</button>
</mat-menu>
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
@@ -1789,7 +1626,7 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="print" class="menu-item-icon" style="color: #15803d"></fa-icon> FD / Renewal
</button>
<button mat-menu-item routerLink="/letter-mgmt/share-member" tabindex="0">
<button mat-menu-item tabindex="0">
<fa-icon icon="coins" class="menu-item-icon" style="color: #15803d"></fa-icon> Share Member
</button>
<button mat-menu-item tabindex="0">
@@ -1831,17 +1668,6 @@
Hypothecation Certificate
</button>
</div>
<!-- Registers & Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Registers &amp; Reports</div>
<button mat-menu-item routerLink="/letter-mgmt/membership-register" tabindex="0">
<fa-icon icon="book" class="menu-item-icon" style="color: #15803d"></fa-icon> Membership Register
</button>
<button mat-menu-item routerLink="/letter-mgmt/share-allotment-report" tabindex="0">
<fa-icon icon="coins" class="menu-item-icon" style="color: #15803d"></fa-icon> Share Allotment Report
</button>
</div>
<!-- Others -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Others</div>
@@ -2084,16 +1910,6 @@
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS
</a>
<!-- Named for what it reports rather than a form number, so a clerk can find it without
already knowing which return covers membership. -->
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Members & Share Capital Return')"
routerLink="/rbi/share-capital"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="users" class="gl-icon" style="color: #4f46e5"></fa-icon> Members &amp; Share Capital
</a>
</div>
<!-- CIMS / XBRL Returns -->
<div class="gl-group">
-11
View File
@@ -13,7 +13,6 @@ import { MatDialog } from '@angular/material/dialog';
import { Router, NavigationEnd } from '@angular/router';
import { AuthenticationService } from '../authentication/authentication.service';
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
import { TenantMenuAccessService } from '../../directives/has-permission/tenant-menu-access.service';
/** rxjs Imports */
import { Observable, Subscription } from 'rxjs';
@@ -67,7 +66,6 @@ export class ShellComponent implements OnInit, OnDestroy {
private router = inject(Router);
private authService = inject(AuthenticationService);
private clerkUiStateService = inject(ClerkUiStateService);
private tenantMenuAccessService = inject(TenantMenuAccessService);
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver
@@ -81,10 +79,6 @@ export class ShellComponent implements OnInit, OnDestroy {
progressBar$: Subscription;
/** Hides nav/sidebar for full-screen cashier routes. */
hideShellLayout = false;
/** True once this tenant's own menu-access ceiling has loaded — the nav waits on this so a menu
* the tenant has turned off is never shown for an instant before disappearing. See
* TenantMenuAccessService. */
menuAccessReady = false;
private routeSub: Subscription;
/** Account Facilities — populated from ClerkUiStateService when account loads */
@@ -149,11 +143,6 @@ export class ShellComponent implements OnInit, OnDestroy {
this.cdr.detectChanges();
});
this.tenantMenuAccessService.load().subscribe(() => {
this.menuAccessReady = true;
this.cdr.detectChanges();
});
// Cashier-only users: hide shell. Clerk/dashboard: also fullscreen by default.
const checkLayout = () => {
const url = this.router.url;
-37
View File
@@ -1,37 +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/.
*/
/**
* A date from the Fineract API as `yyyy-mm-dd`, or '' when there is none.
*
* Fineract serialises dates as `[year, month, day]` arrays, not strings, and reading one as a
* string gives `"2026,6,12"` — which renders literally on screen, and which `new Date()` turns
* into an Invalid Date, so every day-count off it comes out NaN. Two reports have already shipped
* that bug: the dormancy report sat on "Loading..." forever when `toISOString()` threw inside a
* subscribe callback, and the re-KYC report showed "2026,6,12 · Due in NaN days" with all three
* stage counters stuck on zero.
*
* Strings are passed through, since some endpoints are configured to return them.
*/
export function toIsoDate(value: any): string {
if (!value) {
return '';
}
if (Array.isArray(value)) {
const [
year,
month,
day
] = value;
if (!year || !month || !day) {
return '';
}
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
return String(value).slice(0, 10);
}
@@ -367,18 +367,15 @@
}
<!-- Compliance / AML Action Triage -->
<div
class="compliance-triage"
style="margin-top: 16px; padding: 12px; background: #fff8f8; border: 1px dashed #f5c2c2; border-radius: 8px"
>
<h4 style="margin: 0 0 8px; font-weight: 600; color: #b91c1c; display: flex; align-items: center; gap: 6px">
<div class="compliance-triage" style="margin-top: 16px; padding: 12px; background: #fff8f8; border: 1px dashed #f5c2c2; border-radius: 8px;">
<h4 style="margin: 0 0 8px; font-weight: 600; color: #b91c1c; display: flex; align-items: center; gap: 6px;">
Compliance & AML Triage
</h4>
<div style="display: flex; flex-direction: column; gap: 8px">
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{ standalone: true }">
<div style="display: flex; flex-direction: column; gap: 8px;">
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{standalone: true}">
Flag as suspicious (escalate to STR cases under RBI compliance)
</mat-checkbox>
<mat-checkbox [(ngModel)]="markAsWhitelisted" [ngModelOptions]="{ standalone: true }">
<mat-checkbox [(ngModel)]="markAsWhitelisted" [ngModelOptions]="{standalone: true}">
Mark as whitelisted (prevent future AML alerts for this account)
</mat-checkbox>
</div>
@@ -145,7 +145,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
cases = [];
}
}
const newRef = 101 + cases.length;
const newCase: any = {
reportType: 'STR',
@@ -171,10 +171,10 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
narrative: `CTS Cheque #${this.item.chequeNumber} for amount ₹${this.item.amount} flagged as suspicious by Clearing Officer during inward presentment review. Action: ${this.mode}. Reason/Notes: ${this.mode === 'RETURN' ? this.returnForm.value.reason : this.passForm.value.note || 'Manual compliance escalation'}`,
status: 'po-review'
};
cases.unshift(newCase);
localStorage.setItem('fiu_str_cases', JSON.stringify(cases));
const storedCtr = localStorage.getItem('fiu_ctr_txns');
if (storedCtr) {
try {
@@ -201,7 +201,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
whitelisted.push(account.toString());
localStorage.setItem('compliance_whitelisted_accounts', JSON.stringify(whitelisted));
}
const storedCtr = localStorage.getItem('fiu_ctr_txns');
if (storedCtr) {
try {
@@ -21,7 +21,6 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { CurrentAccountsService } from '../../current-accounts.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -41,7 +40,6 @@ export class AddChargeCurrentAccountComponent implements OnInit {
private router = inject(Router);
private dateUtils = inject(Dates);
private currentAccountsService = inject(CurrentAccountsService);
private facilityChargeFilter = inject(FacilityChargeFilterService);
private settingsService = inject(SettingsService);
/** Minimum Due Date allowed. */
@@ -68,9 +66,7 @@ export class AddChargeCurrentAccountComponent implements OnInit {
*/
constructor() {
this.route.data.subscribe((data: { currentAccountActionData: any }) => {
this.facilityChargeFilter
.excludeFacilityCharges(data.currentAccountActionData.chargeOptions)
.subscribe((charges: any[]) => (this.currentAccountChargeOptions = charges));
this.currentAccountChargeOptions = data.currentAccountActionData.chargeOptions;
});
this.currentAccountId = this.route.snapshot.params['currentAccountId'];
}
@@ -36,7 +36,6 @@ import { TranslateService } from '@ngx-translate/core';
import { MatButton, MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
import { ChargesFilterPipe } from '../../../pipes/charges-filter.pipe';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
@@ -74,7 +73,6 @@ export class CurrentAccountChargesStepComponent implements OnInit {
private dialog = inject(MatDialog);
private dateUtils = inject(Dates);
private translateService = inject(TranslateService);
private facilityChargeFilter = inject(FacilityChargeFilterService);
/** Current Account Product Template */
private _currentAccountProductTemplate: any;
@@ -82,9 +80,7 @@ export class CurrentAccountChargesStepComponent implements OnInit {
set currentAccountProductTemplate(value: any) {
this._currentAccountProductTemplate = value;
if (value) {
this.facilityChargeFilter
.excludeFacilityCharges(value.chargeOptions)
.subscribe((charges: any[]) => (this.chargeData = charges));
this.chargeData = value.chargeOptions || [];
// Only pre-populate from product template for new accounts
if (!this.currentAccountTemplate?.id) {
this.chargesDataSource = (value.charges || []).map((charge: any) => ({ ...charge, id: charge.chargeId }));
@@ -529,7 +529,7 @@
<mat-form-field appearance="outline" class="soa-field">
<mat-label>Age</mat-label>
<input matInput [value]="clientAge !== null ? clientAge + ' years' : '—'" readonly />
<mat-hint>Must be below {{ minorAgeThreshold }} years</mat-hint>
<mat-hint>Must be below 18 years</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="soa-field">
@@ -15,12 +15,6 @@ import { Dates } from 'app/core/utils/dates';
import { CurrentAccountsService } from 'app/current-accounts/current-accounts.service';
import { SettingsService } from 'app/settings/settings.service';
import { ClientsService } from 'app/clients/clients.service';
import {
AGE_OF_MAJORITY,
MINOR_AGE_THRESHOLD,
isMinorAge,
isSeniorCitizenAge
} from 'app/clients/shared/client-age.util';
import { MatDialog } from '@angular/material/dialog';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { MatIcon } from '@angular/material/icon';
@@ -105,8 +99,6 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
// ── Client info (auto-fetched) ─────────────────────────────────────────
clientDob: Date | null = null;
clientAge: number | null = null;
/** Exposed for tooltips so the displayed rule can never drift from the applied rule. */
readonly minorAgeThreshold = MINOR_AGE_THRESHOLD;
clientGender: string | null = null;
primaryClientName = '';
primaryClientCif = '';
@@ -452,10 +444,10 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
return;
}
if (this.clientAge === null) return;
if (isMinorAge(this.clientAge)) {
if (this.clientAge < 18) {
this.selectedAccountStatus = 'MINOR';
this.minorDob = this.clientDob;
} else if (isSeniorCitizenAge(this.clientAge)) {
} else if (this.clientAge >= 60) {
this.selectedAccountStatus = 'SENIOR_CITIZEN';
} else if (this.clientGender === 'female') {
this.selectedAccountStatus = 'INDIVIDUAL_FEMALE';
@@ -466,15 +458,15 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
isTileDisabled(value: string): boolean {
if (this.readOnly) return true;
if (value === 'MINOR') return !isMinorAge(this.clientAge);
if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge);
if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18;
if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60;
return false;
}
getTileTooltip(value: string): string {
if (value === 'MINOR' && !isMinorAge(this.clientAge))
return `Age ${this.clientAge} yrs — Minor only allowed below years`;
if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge))
if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18)
return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`;
if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60)
return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`;
if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age';
if (value === 'SENIOR_CITIZEN' && this.clientAge === null) return 'DOB not available — cannot verify age';
@@ -617,7 +609,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
const dob = this.minorDob ?? this.clientDob;
if (!dob) return '';
const d = new Date(dob);
d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY);
d.setFullYear(d.getFullYear() + 18);
return this.dateUtils.formatDate(d, 'M/d/yyyy') || '';
}
@@ -174,6 +174,18 @@
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.withdrawalFacility">
<div class="dig-card__icon"><i class="fas fa-arrow-right-arrow-left"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">Withdrawal Facility</span>
<span class="dig-card__status">{{ facilityConfig.withdrawalFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.withdrawalFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.depositFacilityKyc">
<div class="dig-card__icon"><i class="fas fa-id-card"></i></div>
<div class="dig-card__body">
@@ -24,6 +24,7 @@ const DEFAULT_FACILITY_CONFIG = {
neftFacility: false,
rtgsFacility: false,
interBankTotalCapFacility: false,
withdrawalFacility: false,
depositFacilityKyc: false,
virtualDebitCard: false,
internetBanking: false,
@@ -76,6 +76,7 @@ export class CurrentAccountPreviewStepComponent implements OnChanges {
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' },
{ key: 'internetBanking', label: 'Internet Banking' },
@@ -152,294 +152,46 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
}
printPassbook(): void {
const source = this.passbookContent?.nativeElement as HTMLElement;
if (!source) return;
/** Work on a copy so the on-screen statement is untouched */
const clone = source.cloneNode(true) as HTMLElement;
clone.querySelectorAll('.no-print').forEach((el: Element) => el.remove());
/** Material icon fonts are not available in the popup, so swap them for inline SVG */
clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => {
const insideLogo = !!el.closest('.bank-logo-circle');
if (insideLogo) {
el.outerHTML = PassbookTabComponent.BANK_LOGO_SVG;
} else {
el.remove();
}
});
const printWindow = window.open('', 'Passbook', 'height=800,width=1100');
if (!printWindow) return;
const content = this.passbookContent?.nativeElement?.innerHTML;
if (!content) return;
const printWindow = window.open('', 'Passbook', 'height=700,width=900');
printWindow.document.write(`
<html>
<head>
<meta charset="utf-8" />
<title>Current Account Passbook - ${this.accountNo}</title>
<style>${PassbookTabComponent.PRINT_STYLES}</style>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; padding: 20px; color: #333; }
.passbook-header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #1976d2; padding-bottom: 12px; }
.passbook-header h2 { color: #1976d2; font-size: 18px; margin-bottom: 4px; }
.passbook-header h3 { font-size: 14px; font-weight: 400; color: #666; }
.passbook-info-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px 16px; margin-bottom: 16px; padding: 10px; background: #f5f5f5; border-radius: 4px; }
.passbook-info-item { font-size: 12px; }
.passbook-info-item .label { font-weight: 600; color: #666; text-transform: uppercase; font-size: 10px; }
.passbook-info-item .value { color: #333; }
.passbook-balance-row { display: flex; justify-content: space-between; margin-bottom: 16px; padding: 8px 10px; background: #e3f2fd; border-radius: 4px; font-size: 13px; }
.passbook-balance-row .balance-item b { color: #1976d2; }
table { width: 100%; border-collapse: collapse; font-size: 11px; }
th { background: #1976d2; color: #fff; padding: 6px 8px; text-align: left; font-weight: 600; }
td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; }
tr:nth-child(even) { background: #fafafa; }
.text-right { text-align: right; }
.passbook-footer { margin-top: 16px; font-size: 10px; color: #999; text-align: center; border-top: 1px solid #e0e0e0; padding-top: 8px; }
.reversed { text-decoration: line-through; color: #999; }
</style>
</head>
<body>
<div class="statement-print-page">${clone.outerHTML}</div>
<script>
window.onafterprint = function () { window.close(); };
window.addEventListener('load', function () { window.focus(); window.print(); });
</script>
${content}
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
printWindow.close();
}
/** Material "account_balance" glyph, inlined so the print window needs no icon font */
private static readonly BANK_LOGO_SVG = `
<svg class="bank-logo-svg" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M4 10h3v7H4zM10.5 10h3v7h-3zM2 19h20v3H2zM17 10h3v7h-3zM12 1L2 6v2h20V6z" />
</svg>`;
/**
* Mirrors passbook-tab.component.scss so the printed statement looks like the
* on-screen one. Kept in sync manually - component styles are scoped and are
* not reachable from the popup window.
*/
private static readonly PRINT_STYLES = `
@page { size: A4 portrait; margin: 10mm; }
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
color: #333;
background: #fff;
}
.statement-print-page { padding: 0; }
.statement-wrapper {
background: #fff;
border: 1px solid #d0d7e3;
border-radius: 6px;
overflow: hidden;
}
/* Bank header */
.bank-header {
background: #1a3a6b;
padding: 14px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 4px solid #c8a951;
}
.bank-logo-area { display: flex; align-items: center; gap: 12px; }
.bank-logo-circle {
width: 44px;
height: 44px;
border-radius: 50%;
background: #c8a951;
display: flex;
align-items: center;
justify-content: center;
}
.bank-logo-svg { fill: #1a3a6b; display: block; }
.bank-name-block .bank-title {
color: #fff;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.3px;
}
.bank-name-block .bank-subtitle {
color: #c8a951;
font-size: 10px;
font-weight: 500;
letter-spacing: 1.5px;
text-transform: uppercase;
margin-top: 2px;
}
.statement-label { text-align: right; }
.statement-label .stmt-label-text {
color: #c8a951;
font-size: 12px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
}
.statement-label .stmt-period {
color: rgba(255, 255, 255, 0.75);
font-size: 10px;
margin-top: 3px;
}
/* Account details band */
.account-details-band {
background: #f0f3f9;
border-bottom: 1px solid #d0d7e3;
padding: 12px 20px;
}
.account-details-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px 24px;
}
.detail-block .detail-label {
font-size: 8.5px;
font-weight: 700;
color: #7a8aaa;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 2px;
}
.detail-block .detail-value { font-size: 12px; color: #222; }
.detail-block .detail-value.bold { font-weight: 600; }
.detail-block .detail-value.mono { font-family: 'Courier New', monospace; letter-spacing: 1px; }
.detail-block .detail-value.status-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
background: #eee;
color: #555;
}
.detail-block .detail-value.status-badge.active { background: #d4edda; color: #155724; }
/* Balance summary strip */
.balance-summary-strip {
display: flex;
align-items: stretch;
background: #1a3a6b;
}
.balance-box { flex: 1; padding: 10px 18px; text-align: center; }
.balance-box .balance-box-label {
font-size: 9px;
font-weight: 600;
color: rgba(255, 255, 255, 0.65);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 3px;
}
.balance-box .balance-box-value { font-size: 15px; font-weight: 700; color: #c8a951; }
.balance-divider { width: 1px; background: rgba(255, 255, 255, 0.15); margin: 6px 0; }
/* Transactions table */
.statement-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.statement-table thead { display: table-header-group; }
.statement-table tfoot { display: table-row-group; }
.statement-table tr { page-break-inside: avoid; break-inside: avoid; }
.statement-table thead tr {
background: #e8edf5;
border-top: 1px solid #d0d7e3;
border-bottom: 2px solid #1a3a6b;
}
.statement-table thead th {
padding: 8px 9px;
font-size: 10px;
font-weight: 700;
color: #1a3a6b;
text-transform: uppercase;
letter-spacing: 0.4px;
white-space: nowrap;
text-align: left;
}
.statement-table tbody tr { border-bottom: 1px solid #e8ecf3; }
.statement-table tbody tr:nth-child(even) { background: #f4f7fb; }
.statement-table tbody tr.reversed-row { opacity: 0.55; text-decoration: line-through; }
.statement-table tbody td { padding: 6px 9px; vertical-align: middle; color: #333; }
.statement-table tfoot .total-row { background: #e8edf5; border-top: 2px solid #1a3a6b; }
.statement-table tfoot .total-row td { padding: 8px 9px; }
/* Column widths */
.col-sr { width: 34px; }
.col-date { width: 100px; white-space: nowrap; }
.col-desc { min-width: 150px; }
.col-ref { width: 74px; }
.col-amount { width: 100px; }
.col-balance { width: 110px; }
/* Amount colors */
.debit-amount { color: #c0392b; font-weight: 500; }
.credit-amount { color: #1a7a3f; font-weight: 500; }
.balance-amount { color: #1a3a6b; font-weight: 600; font-family: 'Courier New', monospace; }
.total-label {
font-size: 11px;
font-weight: 700;
color: #1a3a6b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.closing-balance {
font-size: 13px;
font-weight: 700;
color: #1a3a6b;
font-family: 'Courier New', monospace;
}
/* Badges & misc */
.txn-desc { font-weight: 500; }
.reversed-badge {
display: inline-block;
margin-left: 6px;
font-size: 8px;
font-weight: 700;
color: #fff;
background: #e74c3c;
padding: 1px 5px;
border-radius: 3px;
letter-spacing: 0.5px;
}
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-muted { color: #999; }
.mono { font-family: 'Courier New', monospace; font-size: 10px; }
/* No data */
.no-data-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; }
/* Footer */
.statement-footer {
background: #f0f3f9;
border-top: 1px solid #d0d7e3;
padding: 10px 20px;
text-align: center;
}
.statement-footer .footer-note {
font-size: 9px;
color: #888;
margin-bottom: 3px;
font-style: italic;
}
.statement-footer .footer-meta { font-size: 9px; color: #aaa; letter-spacing: 0.3px; }
`;
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
@@ -43,6 +43,7 @@ const DEFAULT_FACILITY_CONFIG: Record<string, boolean> = {
neftFacility: false,
rtgsFacility: false,
interBankTotalCapFacility: false,
withdrawalFacility: false,
depositFacilityKyc: false,
virtualDebitCard: false,
internetBanking: false,
@@ -65,6 +66,7 @@ const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [
{ key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' },
{ key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' },
{ key: 'internetBanking', label: 'Internet Banking', icon: 'fa-globe' },

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