Compare commits

..
459 changed files with 15212 additions and 36796 deletions
+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 {
@@ -11,6 +11,10 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { AccountsListComponent } from './accounts-list/accounts-list.component';
import { PartyMasterComponent } from '../deposits/party-master/party-master.component';
import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component';
import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component';
import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component';
const routes: Routes = [
Route.withShell([
@@ -41,6 +45,28 @@ const routes: Routes = [
title: 'Recurring Deposit Accounts',
breadcrumb: 'Recurring Deposit Accounts'
}
},
{
path: 'dds',
component: PartyMasterComponent,
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' }
},
{
path: 'dds-collection',
component: DailyTransactionEntryComponent,
data: { title: 'Daily Transaction Entry', breadcrumb: 'Daily Transaction Entry' }
},
{
path: 'dds-reports',
data: { title: 'DDS Reports', breadcrumb: 'DDS Reports' },
children: [
{ path: '', component: DdsReportsComponent },
{
path: 'view/:reportName',
component: ReportViewerComponent,
data: { title: 'DDS Report', breadcrumb: 'reportName', routeParamBreadcrumb: 'reportName' }
}
]
}
]
}
-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,671 +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 (isLateClose) {
<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>
}
@if (isLateClose) {
<label class="pg-field bd-panel__wide">
<span>Why was this day left open? (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>
<div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitClose()" [disabled]="!canSubmitClose">
{{ isLateClose ? '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,297 +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;
}
}
}
@@ -1,612 +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;
}
get canSubmitClose(): boolean {
const hasFigure = this.closingCash != null || this.closeDenomTotal > 0;
if (!this.closeTarget || !hasFigure || this.closing) return false;
return !this.isLateClose || !!this.forceReason.trim();
}
submitClose(): void {
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,
this.isLateClose ? 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 {
-69
View File
@@ -19,13 +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 { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
import { VoucherViewComponent } from './voucher-view/voucher-view.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';
@@ -85,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: () =>
@@ -143,40 +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' }
}
]
},
{
path: 'cashier',
data: { title: 'Cashier', breadcrumb: 'Cashier' },
@@ -221,26 +172,6 @@ const routes: Routes = [
component: CashHistoryComponent,
data: { title: 'Cash History', breadcrumb: 'Cash History' }
},
{
path: 'day-book',
component: DayBookComponent,
data: { title: 'Day Book', breadcrumb: 'Day Book' }
},
{
path: 'voucher-exceptions',
component: VoucherExceptionsComponent,
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
},
{
path: 'voucher',
component: VoucherViewComponent,
data: { title: 'Voucher', breadcrumb: 'Voucher' }
},
{
path: 'voucher-report',
component: VoucherReportComponent,
data: { title: 'Voucher Report', breadcrumb: 'Voucher Report' }
},
{
path: 'closing-report',
component: ClosingCashReportComponent,
-1
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;
+2 -275
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,227 +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 });
}
// ── Voucher physical verification & attachments ──
// The voucher number always travels in the body or a query parameter, never as a path segment:
// numbers are formatted CR/001/2108/0001 and Spring Security's strict firewall rejects the
// encoded slashes with a 400 before the request reaches the resource.
/** Records the outcome of checking a voucher against the paper slip. */
verifyVoucher(
voucherNo: string,
status: 'VERIFIED' | 'EXCEPTION',
slipAmount?: number,
remarks?: string
): Observable<any> {
return this.http.post('/vouchers/verify', {
voucherNo,
status,
slipAmount: slipAmount != null ? String(slipAmount) : null,
remarks: remarks ?? null,
locale: 'en'
});
}
uploadVoucherAttachment(
voucherNo: string,
fileName: string,
contentType: string,
fileData: string,
description?: string
): Observable<any> {
return this.http.post('/vouchers/attachments', {
voucherNo,
fileName,
contentType,
fileData,
description: description ?? null
});
}
listVoucherAttachments(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/attachments', { params });
}
deleteVoucherAttachment(id: number): Observable<any> {
return this.http.delete(`/vouchers/attachments/${id}`);
}
/** Fetched as a blob through HttpClient so the interceptor's auth header travels with it. */
downloadVoucherAttachment(id: number): Observable<Blob> {
return this.http.get(`/vouchers/attachments/${id}/download`, { responseType: 'blob' });
}
/**
* Flagged vouchers awaiting (or carrying) a conclusion. Not scoped to a date — an exception
* raised on Friday still needs answering on Monday.
*/
voucherExceptions(status: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN'): Observable<any> {
const params = new HttpParams().set('status', status);
return this.http.get('/vouchers/exceptions', { 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): 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);
}
return this.http.get('/vouchers/report', { params });
}
/**
* One voucher and the debit and credit sides it is made of — what a printed voucher shows.
*
* The voucher number travels as a query parameter, not a path segment: numbers are formatted
* CR/2708/0001 and the server rejects the encoded slashes in a path.
*/
voucherDetail(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/detail', { params });
}
/** Closes an exception. Manager / head cashier only, and a supporting document must exist. */
resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable<any> {
return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes });
}
/**
* 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);
@@ -411,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}`);
}
/** Preview of what the member would receive on closure. The backend dry-runs the very same
* interest posting that closing the account will perform, then rolls it back — so the figure
* matches the real payout to the paisa. Nothing is persisted. */
preview(savingsAccountId: number): Observable<any> {
return this.http.get(`/clerk/closure-requests/preview/${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 });
}
@@ -227,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>
@@ -84,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) => {
@@ -105,19 +106,6 @@ export class ClosingCashReportComponent implements OnInit {
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,393 +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">CASHIER · DAY BOOK</div>
<h1 class="pg-title">Day Book</h1>
<p class="pg-sub">
Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections and bank
transfers, with credit and debit totals.
</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>
}
<!-- Exceptions surface at the top of the day, not only as a badge buried in their own row: a
flag raised for investigation is useless if someone has to scroll to find it. -->
@if (!loading && exceptionRows.length > 0) {
<div class="db-exceptions">
<div class="db-exceptions__head">
<mat-icon>warning</mat-icon>
<strong>
{{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation
</strong>
@if (resolvedExceptionCount > 0) {
<span class="db-muted">({{ resolvedExceptionCount }} already closed)</span>
}
<button type="button" class="pg-btn pg-btn--warn" (click)="toggleExceptionsOnly()">
{{ exceptionsOnly ? 'Show all vouchers' : 'Show only these' }}
</button>
</div>
@for (e of exceptionRows; track e.voucherNo) {
<div class="db-exceptions__item">
<span class="pg-chip">{{ e.voucherNo }}</span>
<span class="db-exceptions__amt">
System ₹{{ e.amount | number: '1.2-2' }}
@if (e.slipAmount != null && e.slipAmount !== e.amount) {
· Slip ₹{{ e.slipAmount | number: '1.2-2' }}
<strong class="db-exceptions__diff"
>(diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }})</strong
>
}
</span>
<span class="db-exceptions__why">{{ e.verifyRemarks }}</span>
<span class="db-muted">— {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }}</span>
</div>
}
</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>
<div class="pg-card">
<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>Slip Check</th>
<th>Time</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of filteredRows; track r.voucherNo ?? r.submittedAt) {
<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>
@if (r.verifyStatus === 'VERIFIED') {
<span class="db-badge db-badge--ok" [title]="'Verified by ' + (r.verifiedByName ?? '')"
>✓ MATCHED</span
>
} @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) {
<!-- Closed: the flag is history now, so it reads as settled rather than pending. -->
<span
class="db-badge db-badge--ok"
[title]="resolutionLabel(r.resolutionStatus) + ' — ' + (r.resolutionNotes ?? '')"
>
✓ RESOLVED
</span>
} @else if (r.verifyStatus === 'EXCEPTION') {
<span
class="db-badge db-badge--rejected"
[title]="'Slip ₹' + r.slipAmount + ' vs system ₹' + r.amount + ' — ' + (r.verifyRemarks ?? '')"
>
⚠ EXCEPTION
</span>
} @else {
<span class="db-muted">Not checked</span>
}
</td>
<td>{{ timeOf(r.submittedAt) }}</td>
<td class="db-actions">
<button type="button" class="db-icon-btn" (click)="openVerify(r)" title="Check against the paper slip">
<mat-icon>fact_check</mat-icon>
</button>
<button type="button" class="db-icon-btn" (click)="openAttach(r)" title="Attach or view scanned slip">
<mat-icon>attach_file</mat-icon>
@if (r.attachmentCount > 0) {
<span class="db-icon-btn__count">{{ r.attachmentCount }}</span>
}
</button>
</td>
</tr>
<!-- A flagged voucher spells out its reason in the table itself. A tooltip is not
enough: it cannot be printed, exported, or read without a mouse. -->
@if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) {
<tr class="db-exc-row" [class.db-exc-row--resolved]="r.resolutionStatus">
<td colspan="13">
<mat-icon>{{ r.resolutionStatus ? 'task_alt' : 'warning' }}</mat-icon>
<strong>Exception</strong>
@if (r.slipAmount != null && r.slipAmount !== r.amount) {
— slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference
₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }})
}
— {{ r.verifyRemarks }}
<span class="db-muted">· raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }}</span>
<!-- Both halves of the record: what the cashier found, and how it was settled. -->
@if (r.resolutionStatus) {
<div class="db-exc-row__resolution">
<strong>Closed — {{ resolutionLabel(r.resolutionStatus) }}:</strong>
{{ r.resolutionNotes }}
<span class="db-muted">· by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }}</span>
</div>
}
</td>
</tr>
}
<!-- Verification panel, opened inline under its own voucher so the two amounts sit
next to the row they describe. -->
@if (verifyRow === r) {
<tr class="db-panel-row">
<td colspan="13">
<div class="db-panel">
<div class="db-panel__head">
<strong>Slip check — {{ r.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeVerify()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="db-panel__body">
<div class="db-cmp">
<span class="db-cmp__label">System recorded</span>
<span class="db-cmp__value">₹{{ r.amount | number: '1.2-2' }}</span>
</div>
<label class="pg-field">
<span>Amount written on slip</span>
<input type="number" step="0.01" [(ngModel)]="verifySlipAmount" />
</label>
@if (verifySlipBlank) {
<div class="db-cmp">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">—</span>
</div>
} @else if (verifyMismatch) {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">₹{{ verifyDifference | number: '1.2-2' }}</span>
</div>
} @else {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">Match</span>
<span class="db-cmp__value">Amounts agree</span>
</div>
}
</div>
<label class="pg-field db-panel__remarks">
<span>{{ verifyMismatch ? 'What did you find? (required)' : 'Remarks (optional)' }}</span>
<input
type="text"
[(ngModel)]="verifyRemarks"
placeholder="e.g. slip written as 500, system posted 400 — teller keyed it wrong"
/>
</label>
@if (verifyMismatch) {
<p class="db-panel__warn">
Saving this marks the voucher as an <strong>exception</strong> for investigation — it does not
change the posted amount.
</p>
}
<div class="db-panel__actions">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="saveVerify()"
[disabled]="verifySaving || verifySlipBlank"
>
{{ verifyMismatch ? 'Mark Exception' : 'Mark Verified' }}
</button>
<!-- Raising an exception on an already-matched voucher stays available: a
slip cleared in haste, or a difference noticed later, has to be
recordable without erasing the earlier verdict. -->
@if (!verifyMismatch) {
<button
type="button"
class="pg-btn pg-btn--warn"
(click)="raiseException()"
[disabled]="verifySaving"
>
Raise Exception
</button>
}
<button type="button" class="pg-btn" (click)="closeVerify()">Cancel</button>
</div>
</div>
</td>
</tr>
}
<!-- Attachment panel for this voucher. -->
@if (attachRow === r) {
<tr class="db-panel-row">
<td colspan="13">
<div class="db-panel">
<div class="db-panel__head">
<strong>Attachments — {{ r.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeAttach()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<label class="db-upload">
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
<span class="db-muted">Scanned slip, cheque image or ID proof · max 10 MB</span>
</label>
@if (attachUploading) {
<p class="db-muted">Uploading…</p>
}
@if (attachLoading) {
<p class="db-muted">Loading…</p>
}
@if (!attachLoading && attachments.length === 0) {
<p class="db-muted">Nothing attached to this voucher yet.</p>
}
@for (a of attachments; track a.id) {
<div class="db-file">
<mat-icon>description</mat-icon>
<span class="db-file__name">{{ a.fileName }}</span>
<span class="db-muted">{{ a.fileSize }} bytes · {{ a.uploadedByName }}</span>
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
<mat-icon>download</mat-icon>
</button>
<button type="button" class="db-icon-btn" (click)="deleteAttachment(a)" title="Delete">
<mat-icon>delete</mat-icon>
</button>
</div>
}
</div>
</td>
</tr>
}
}
</tbody>
<tfoot>
<tr>
<td colspan="5">Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)</td>
<td class="num">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</td>
<td colspan="7"></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,393 +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;
}
/* 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;
&--ok {
background: #dcfce7;
color: #15803d;
}
&--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;
}
}
/* The reason spelled out under its own flagged row. */
.db-exc-row td {
background: #fef2f2 !important;
border-bottom: 1px solid #fecaca !important;
color: #7f1d1d;
font-size: 12px;
padding: 8px 14px !important;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
vertical-align: -3px;
margin-right: 4px;
}
}
/* A closed exception stays on the row as history, but drops the alarm colouring — it is no longer
something anyone has to act on. */
.db-exc-row--resolved td {
background: #f0fdf4 !important;
border-bottom: 1px solid #bbf7d0 !important;
color: #14532d;
}
.db-exc-row__resolution {
margin-top: 4px;
padding-top: 4px;
border-top: 1px dotted #bbf7d0;
}
.db-actions {
white-space: nowrap;
}
/* 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;
}
}
}
.db-upload {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.db-file {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
margin-bottom: 6px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #64748b;
}
&__name {
font-weight: 700;
color: #0d1b2a;
}
}
.pg-table tfoot td {
background: #f8fafc;
font-weight: 800;
font-size: 12px;
color: #334155;
border-top: 1px solid #e2e8f0;
}
@@ -1,523 +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 DayBookRow {
voucherNo: string | null;
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;
/** null until someone has physically checked the slip. */
verifyStatus: 'VERIFIED' | 'EXCEPTION' | null;
slipAmount: number | null;
verifyRemarks: string | null;
verifiedByName: string | null;
verifiedAt: string | null;
/** Set once a clerk or manager has closed the exception; null while it is still open. */
resolutionStatus: string | null;
resolutionNotes: string | null;
resolvedByName: string | null;
resolvedAt: string | null;
attachmentCount: number;
}
/**
* 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);
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();
}
/** Show only the flagged vouchers — set from the exception banner. */
exceptionsOnly = false;
/**
* Vouchers still awaiting a conclusion. A closed exception drops out of the banner — it stays
* visible on its own row as history, but it is no longer something anyone has to act on.
*/
get exceptionRows(): DayBookRow[] {
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus);
}
/** Closed exceptions, kept for the record rather than for action. */
get resolvedExceptionCount(): number {
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length;
}
/** Human label for a resolution code. */
resolutionLabel(status: string | null): string {
switch (status) {
case 'RESOLVED_SYSTEM_ERROR':
return 'System entry was wrong';
case 'RESOLVED_SLIP_ERROR':
return 'Slip was written wrong';
case 'RESOLVED_NO_ACTION':
return 'No action needed';
default:
return status ?? '';
}
}
/** Vouchers nobody has checked against a slip yet. */
get uncheckedCount(): number {
return this.rows.filter((r) => !r.verifyStatus).length;
}
toggleExceptionsOnly(): void {
this.exceptionsOnly = !this.exceptionsOnly;
}
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
get filteredRows(): DayBookRow[] {
const base = this.exceptionsOnly ? this.exceptionRows : 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';
}
statusClass(status: string): string {
switch ((status ?? '').toUpperCase()) {
case 'APPROVED':
case 'COMPLETED':
case 'WITHDRAWN':
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) : '—';
}
// ── Physical slip verification ────────────────────────────────────────────
/** Row whose verification panel is open, or null when none is. */
verifyRow: DayBookRow | null = null;
verifySlipAmount: number | null = null;
verifyRemarks = '';
verifySaving = false;
openVerify(r: DayBookRow): void {
this.verifyRow = r;
// Pre-filled with the system's own figure: the common case is that the two agree, and when
// they don't the cashier only has to change the digits that actually differ.
this.verifySlipAmount = Number(r.amount ?? 0);
this.verifyRemarks = r.verifyRemarks ?? '';
this.attachRow = null;
}
closeVerify(): void {
this.verifyRow = null;
this.verifySlipAmount = null;
this.verifyRemarks = '';
}
/** True when the amount typed off the slip differs from what the system recorded. */
get verifyMismatch(): boolean {
if (!this.verifyRow || this.verifySlipAmount == null) return false;
return Number(this.verifySlipAmount) !== Number(this.verifyRow.amount ?? 0);
}
/**
* True while the slip box is empty. Clearing the field must not read as "amounts agree" — an
* unanswered question is not a match, so the panel shows neither verdict until a figure is typed.
*/
get verifySlipBlank(): boolean {
return this.verifySlipAmount == null || String(this.verifySlipAmount).trim() === '';
}
get verifyDifference(): number {
if (!this.verifyRow || this.verifySlipAmount == null) return 0;
return Number(this.verifySlipAmount) - Number(this.verifyRow.amount ?? 0);
}
/**
* Saves the verdict. The status is derived from the figures rather than picked by the user —
* if the slip and the system disagree it is an exception, whatever anyone intended to click.
*/
saveVerify(): void {
const row = this.verifyRow;
if (!row?.voucherNo) return;
const isException = this.verifyMismatch;
if (isException && !this.verifyRemarks.trim()) {
this.errorMsg = 'The amounts differ — add a remark saying what you found before saving.';
return;
}
this.verifySaving = true;
this.errorMsg = '';
this.service
.verifyVoucher(
row.voucherNo,
isException ? 'EXCEPTION' : 'VERIFIED',
isException ? Number(this.verifySlipAmount) : undefined,
this.verifyRemarks.trim() || undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not save the verification.';
return of(null);
})
)
.subscribe((res: any) => {
this.verifySaving = false;
if (res) {
this.closeVerify();
this.load();
}
});
}
/**
* Flags a voucher whose amounts agree — the problem is something other than the figure: a
* missing signature, a wrong account, a slip the member disputes. Deliberately not a "clear the
* check" action: erasing a verdict would erase the evidence that anything was ever wrong, which
* is exactly what an investigation needs.
*/
raiseException(): void {
const row = this.verifyRow;
if (!row?.voucherNo) return;
if (!this.verifyRemarks.trim()) {
this.errorMsg = 'Say what the problem is before raising an exception.';
return;
}
this.verifySaving = true;
this.errorMsg = '';
this.service
// The slip amount is sent as-is: the figures match, so it doubles as the record of what
// both the slip and the system said at the time the exception was raised.
.verifyVoucher(
row.voucherNo,
'EXCEPTION',
Number(this.verifySlipAmount ?? row.amount ?? 0),
this.verifyRemarks.trim()
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not raise the exception.';
return of(null);
})
)
.subscribe((res: any) => {
this.verifySaving = false;
if (res) {
this.closeVerify();
this.load();
}
});
}
// ── Attachments ───────────────────────────────────────────────────────────
attachRow: DayBookRow | null = null;
attachments: any[] = [];
attachLoading = false;
attachUploading = false;
openAttach(r: DayBookRow): void {
this.attachRow = r;
this.verifyRow = null;
this.attachments = [];
if (!r.voucherNo) return;
this.attachLoading = true;
this.service
.listVoucherAttachments(r.voucherNo)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.attachLoading = false;
this.attachments = res?.attachments ?? [];
});
}
closeAttach(): void {
this.attachRow = null;
this.attachments = [];
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const row = this.attachRow;
if (!file || !row?.voucherNo) return;
// 10 MB, matching the backend's own ceiling — rejecting here saves uploading the whole file
// only to have it refused.
if (file.size > 10 * 1024 * 1024) {
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.attachUploading = true;
this.errorMsg = '';
this.service
.uploadVoucherAttachment(
row.voucherNo!,
file.name,
file.type || 'application/octet-stream',
String(reader.result)
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.attachUploading = false;
input.value = '';
if (res) {
this.openAttach(row);
this.load();
}
});
};
reader.readAsDataURL(file);
}
/**
* Fetched through HttpClient rather than window.open: the download endpoint needs the auth
* header the interceptor adds, which a bare browser navigation would not carry. The blob is
* then handed to the browser as a normal save.
*/
downloadAttachment(a: any): void {
this.service
.downloadVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob) {
this.errorMsg = 'Could not download that file.';
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = a.fileName ?? 'attachment';
link.click();
URL.revokeObjectURL(url);
});
}
deleteAttachment(a: any): void {
const row = this.attachRow;
this.service
.deleteVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe(() => {
if (row) this.openAttach(row);
this.load();
});
}
exportCsv(): void {
const header = [
'Voucher No',
'Side',
'Type',
'Account',
'Party',
'Amount',
'Payment Type',
'Status',
'Handled By',
'Approved By',
'Slip Check',
'Slip Amount',
'Remarks',
'Checked By',
'Resolution',
'Resolution Notes',
'Closed By',
'Attachments',
'Time'
];
const lines = this.filteredRows.map((r) =>
[
r.voucherNo ?? '',
r.side,
r.kind,
r.accountNo ?? '',
r.party ?? '',
String(r.amount ?? 0),
r.paymentTypeName ?? '',
r.status,
r.handledBy ?? '',
r.approvedBy ?? '',
r.verifyStatus ?? 'NOT CHECKED',
r.slipAmount != null ? String(r.slipAmount) : '',
r.verifyRemarks ?? '',
r.verifiedByName ?? '',
r.resolutionStatus ? this.resolutionLabel(r.resolutionStatus) : '',
r.resolutionNotes ?? '',
r.resolvedByName ?? '',
String(r.attachmentCount ?? 0),
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);
}
}
@@ -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">
@@ -142,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>
}
@@ -156,7 +160,6 @@
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
@@ -202,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'"
>
@@ -236,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>
@@ -255,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]) {
@@ -267,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
@@ -286,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
@@ -311,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()"
@@ -436,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) {
@@ -871,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>
@@ -932,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>
@@ -940,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>
@@ -956,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 -->
@@ -1061,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>
@@ -1069,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>
@@ -1405,9 +1327,12 @@
<div class="ws-f ws-f--type">
<label class="ws-f__lbl">TYPE</label>
<select class="ws-f__ctl" [(ngModel)]="row.typ">
@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">
@@ -1426,6 +1351,7 @@
[(ngModel)]="row.paymentTypeId"
[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>
}
@@ -1439,7 +1365,6 @@
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
@@ -1481,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'"
>
@@ -1510,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>
@@ -1531,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]) {
@@ -1545,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
@@ -1560,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="—" />
@@ -1575,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)"
/>
@@ -1746,19 +1660,6 @@
My Disbursements
</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
</button>
}
</div>
<div class="ws-footer__actions">
<button
@@ -1780,81 +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">Calculating interest to date…</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>
<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 calculated to {{ closurePreview.asOfDate }} using the same calculation the closure itself
performs. 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;
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,183 +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">CASHIER · EXCEPTIONS</div>
<h1 class="pg-title">Voucher Exceptions</h1>
<p class="pg-sub">
Vouchers flagged at the counter — where the slip and the system disagree, or something else about the entry is
wrong. A clerk or manager reviews each one with the cashier, records what was found, and attaches the supporting
document.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Show</span>
<select [(ngModel)]="filter" (change)="load()">
<option value="OPEN">Open</option>
<option value="RESOLVED">Resolved</option>
<option value="ALL">All</option>
</select>
</label>
<button type="button" class="pg-btn pg-btn--primary" (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 (!canResolve && loaded) {
<div class="ve-note">
<mat-icon>info</mat-icon>
You can see these exceptions but not close them — a Clerk, Head Cashier or Bank Manager records the conclusion, so
that whoever raised the flag is not the one who rules on it.
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && exceptions.length === 0) {
<div class="pg-empty">
<mat-icon>check_circle</mat-icon>
<p>{{ filter === 'OPEN' ? 'No open exceptions. Everything checked so far agrees.' : 'Nothing to show.' }}</p>
</div>
}
@for (e of exceptions; track e.voucherNo) {
<div class="ve-card" [class.ve-card--resolved]="e.resolutionStatus">
<div class="ve-card__head">
<span class="pg-chip">{{ e.voucherNo }}</span>
@if (e.resolutionStatus) {
<span class="db-badge db-badge--ok">RESOLVED</span>
} @else {
<span class="db-badge db-badge--rejected">⚠ OPEN</span>
}
<span class="db-muted">
{{ e.accountNo }} · {{ e.party }} · raised by {{ e.raisedByName }} on {{ stamp(e.raisedAt) }}
</span>
@if (!e.resolutionStatus && canResolve) {
<button type="button" class="pg-btn pg-btn--primary ve-card__act" (click)="openResolve(e)">
Review &amp; Close
</button>
}
</div>
<div class="ve-card__amts">
<div class="db-cmp">
<span class="db-cmp__label">System recorded</span>
<span class="db-cmp__value">₹{{ e.systemAmount | number: '1.2-2' }}</span>
</div>
@if (!amountsAgree(e)) {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Written on slip</span>
<span class="db-cmp__value">₹{{ e.slipAmount | number: '1.2-2' }}</span>
</div>
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">₹{{ difference(e) | number: '1.2-2' }}</span>
</div>
} @else {
<div class="db-cmp">
<span class="db-cmp__label">Amounts</span>
<span class="db-cmp__value">Agree — flagged for another reason</span>
</div>
}
</div>
<p class="ve-card__why"><strong>Cashier reported:</strong> {{ e.remarks }}</p>
@if (e.resolutionStatus) {
<div class="ve-card__resolution">
<strong>{{ resolutionLabel(e.resolutionStatus) }}</strong> — {{ e.resolutionNotes }}
<span class="db-muted">· closed by {{ e.resolvedByName }} on {{ stamp(e.resolvedAt) }}</span>
</div>
}
<!-- Resolution panel, opened under the exception it closes. -->
@if (resolveRow === e) {
<div class="db-panel ve-panel">
<div class="db-panel__head">
<strong>Close exception — {{ e.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeResolve()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<label class="pg-field ve-panel__field">
<span>What was wrong?</span>
<select [(ngModel)]="resolutionStatus">
<option value="">— Select —</option>
@for (o of resolutionOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</label>
<label class="pg-field ve-panel__field">
<span>What was found, and what was done about it?</span>
<input
type="text"
[(ngModel)]="resolutionNotes"
placeholder="e.g. teller keyed 700 instead of 500 — reversing entry passed, member informed"
/>
</label>
<div class="ve-panel__docs">
<strong>Supporting document</strong>
<p class="db-muted">
An exception cannot be closed on someone's word alone — attach the corrected slip, a signed note, or
whatever the decision rests on.
</p>
<label class="db-upload">
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
<span class="db-muted">Max 10 MB</span>
</label>
@if (attachUploading) {
<p class="db-muted">Uploading…</p>
}
@if (attachLoading) {
<p class="db-muted">Loading…</p>
}
@if (!attachLoading && attachments.length === 0) {
<p class="ve-panel__warn">No document attached yet — required before closing.</p>
}
@for (a of attachments; track a.id) {
<div class="db-file">
<mat-icon>description</mat-icon>
<span class="db-file__name">{{ a.fileName }}</span>
<span class="db-muted">{{ a.uploadedByName }}</span>
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
<mat-icon>download</mat-icon>
</button>
</div>
}
</div>
<div class="db-panel__actions">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="saveResolution()"
[disabled]="!canSubmitResolution"
>
Close Exception
</button>
<button type="button" class="pg-btn" (click)="closeResolve()">Cancel</button>
</div>
</div>
}
</div>
}
</div>
@@ -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/.
*/
/* Page chrome comes from cashier-shared.scss, and the comparison tiles, panel and file rows are
shared with the day book. Only the exception card itself is defined here. */
.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;
}
}
/* One card per flagged voucher. Open ones carry a red edge; closed ones go green, so a queue
reads at a glance without opening anything. */
.ve-card {
padding: 14px 16px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #dc2626;
border-radius: 8px;
&--resolved {
border-left-color: #16a34a;
background: #fafffb;
}
&__head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
/* Pushed right so the identity of the voucher reads first. */
&__act {
margin-left: auto;
height: 32px;
font-size: 12px;
}
&__amts {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
&__why {
margin: 0;
padding: 8px 12px;
background: #f8fafc;
border-radius: 6px;
font-size: 12px;
color: #0d1b2a;
}
&__resolution {
margin-top: 10px;
padding: 8px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
font-size: 12px;
color: #14532d;
}
}
.ve-panel {
margin-top: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
&__field {
display: block;
margin-bottom: 12px;
max-width: 640px;
}
&__docs {
padding: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
p {
margin: 4px 0 10px;
}
}
&__warn {
padding: 6px 10px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 4px;
color: #92400e;
}
}
@@ -1,248 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
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 VoucherException {
voucherNo: string;
systemAmount: number | null;
slipAmount: number | null;
remarks: string | null;
raisedByName: string | null;
raisedAt: string | null;
accountNo: string | null;
party: string | null;
raisedAgainst: string | null;
attachmentCount: number;
resolutionStatus: string | null;
resolutionNotes: string | null;
resolvedByName: string | null;
resolvedAt: string | null;
}
/**
* Voucher exceptions raised at the counter, and the conclusion recorded on each.
*
* The cashier who spots a difference cannot close it — this screen is where a clerk, head cashier
* or manager sits down with them, records what was actually wrong, and attaches the paper that
* supports the decision.
*/
@Component({
selector: 'mifosx-voucher-exceptions',
templateUrl: './voucher-exceptions.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./voucher-exceptions.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class VoucherExceptionsComponent implements OnInit {
private service = inject(CashierService);
filter: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN';
exceptions: VoucherException[] = [];
/** Set by the backend from the caller's own roles — the cashier who raised a flag cannot close it. */
canResolve = false;
loading = false;
loaded = false;
errorMsg = '';
// Resolution panel
resolveRow: VoucherException | null = null;
resolutionStatus = '';
resolutionNotes = '';
resolveSaving = false;
// Attachments for the voucher being resolved
attachments: any[] = [];
attachLoading = false;
attachUploading = false;
readonly resolutionOptions = [
{ value: 'RESOLVED_SYSTEM_ERROR', label: 'System entry was wrong' },
{ value: 'RESOLVED_SLIP_ERROR', label: 'Slip was written wrong' },
{ value: 'RESOLVED_NO_ACTION', label: 'No action needed' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.service
.voucherExceptions(this.filter)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load voucher exceptions.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.exceptions = res?.exceptions ?? [];
this.canResolve = !!res?.canResolve;
});
}
difference(e: VoucherException): number {
if (e.slipAmount == null) return 0;
return Number(e.slipAmount) - Number(e.systemAmount ?? 0);
}
/** True when the flag was about something other than the amount. */
amountsAgree(e: VoucherException): boolean {
return e.slipAmount == null || Number(e.slipAmount) === Number(e.systemAmount ?? 0);
}
openResolve(e: VoucherException): void {
this.resolveRow = e;
this.resolutionStatus = '';
this.resolutionNotes = '';
this.loadAttachments(e);
}
closeResolve(): void {
this.resolveRow = null;
this.attachments = [];
}
private loadAttachments(e: VoucherException): void {
this.attachments = [];
this.attachLoading = true;
this.service
.listVoucherAttachments(e.voucherNo)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.attachLoading = false;
this.attachments = res?.attachments ?? [];
});
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const row = this.resolveRow;
if (!file || !row) return;
// 10 MB, matching the backend — refusing here saves uploading the whole file to be rejected.
if (file.size > 10 * 1024 * 1024) {
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.attachUploading = true;
this.errorMsg = '';
this.service
.uploadVoucherAttachment(
row.voucherNo,
file.name,
file.type || 'application/octet-stream',
String(reader.result),
'Supporting document for exception resolution'
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.attachUploading = false;
input.value = '';
if (res) {
this.loadAttachments(row);
this.load();
}
});
};
reader.readAsDataURL(file);
}
downloadAttachment(a: any): void {
this.service
.downloadVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob) {
this.errorMsg = 'Could not download that file.';
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = a.fileName ?? 'attachment';
link.click();
URL.revokeObjectURL(url);
});
}
/** Mirrors the backend's own rule, so the button explains itself before it is pressed. */
get canSubmitResolution(): boolean {
return (
!!this.resolutionStatus && !!this.resolutionNotes.trim() && this.attachments.length > 0 && !this.resolveSaving
);
}
saveResolution(): void {
const row = this.resolveRow;
if (!row) return;
this.resolveSaving = true;
this.errorMsg = '';
this.service
.resolveVoucherException(row.voucherNo, this.resolutionStatus, this.resolutionNotes.trim())
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the exception.';
return of(null);
})
)
.subscribe((res: any) => {
this.resolveSaving = false;
if (res) {
this.closeResolve();
this.load();
}
});
}
resolutionLabel(status: string | null): string {
if (!status) return '';
return this.resolutionOptions.find((o) => o.value === status)?.label ?? status;
}
/** Date and time — an exception queue spans days, so the day matters here. */
stamp(iso: string | null): string {
if (!iso) return '—';
const [
d,
t
] = iso.split('T');
return t ? `${d} ${t.slice(0, 5)}` : d;
}
}
@@ -1,183 +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">Voucher Report</h1>
<p class="pg-sub">
Every voucher raised over a day or a month — the number, what was debited, what was credited, and who raised it.
</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 && 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' }}
}
· {{ report.count }} voucher{{ report.count === 1 ? '' : 's' }}
· ₹{{ report.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>
}
</div>
@if (report.count === 0) {
<div class="pg-empty">No vouchers 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">Vouchers</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>
<th>Time</th>
</tr>
</thead>
<tbody>
@for (row of report.vouchers; track row.voucherNo) {
<tr>
<td>
<a class="vr-link" [routerLink]="['/cashier/voucher']" [queryParams]="{ voucherNo: row.voucherNo }">
{{ row.voucherNo }}
</a>
@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>
<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>
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
@@ -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/.
*/
/* 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-link {
color: #1d4ed8;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
.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;
}
}
/* Printing a report should put the register on paper — the filters are how you reached it. */
@media print {
.vr-no-print {
display: none !important;
}
.vr-link {
color: inherit;
text-decoration: none;
}
}
@@ -1,159 +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 { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
/** 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;
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;
}
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;
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,
RouterLink,
MatIconModule
]
})
export class VoucherReportComponent implements OnInit {
private service = inject(CashierService);
/** 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 = '';
report: VoucherReport | 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
.voucherReport(from, to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined)
.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')}`;
}
/** Totals per user, for the summary a manager reads before the rows. */
get byUser(): { username: string; count: number; total: number }[] {
const rows = this.report?.vouchers ?? [];
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.report?.vouchers ?? [];
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();
}
}
@@ -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/.
-->
<div class="pg">
<header class="pg-head vv-no-print">
<div>
<div class="pg-eyebrow">CASHIER · VOUCHER</div>
<h1 class="pg-title">Voucher</h1>
<p class="pg-sub">
Look up a voucher by its number to see the entry behind it — which accounts were debited, which were credited,
and for how much.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Voucher No</span>
<input
type="text"
[(ngModel)]="voucherNo"
(keyup.enter)="search()"
placeholder="CR/2708/0001"
autocomplete="off"
/>
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="search()" [disabled]="loading">
<mat-icon>search</mat-icon> Find
</button>
@if (voucher) {
<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 vv-no-print">{{ error }}</div>
}
@if (loading) {
<div class="pg-empty vv-no-print">Loading…</div>
}
@if (voucher && !loading) {
<div class="pg-card vv-slip">
<div class="vv-slip-head">
<div>
<div class="vv-no">{{ voucher.voucherNo }}</div>
<div class="vv-meta">
{{ voucher.voucherType || '—' }}
@if (voucher.branchName) {
· {{ voucher.branchName }}
}
</div>
</div>
<div class="vv-slip-head-right">
<div class="vv-amount">₹{{ voucher.amount | number: '1.2-2' }}</div>
<div class="vv-meta">
@if (voucher.createdAt) {
{{ voucher.createdAt | date: 'dd MMM yyyy, HH:mm' }}
}
@if (voucher.createdBy) {
· {{ voucher.createdBy }}
}
</div>
</div>
</div>
@if (voucher.verificationStatus) {
<div class="vv-status" [class.vv-status--exception]="voucher.verificationStatus === 'EXCEPTION'">
<mat-icon>{{ voucher.verificationStatus === 'EXCEPTION' ? 'error_outline' : 'verified' }}</mat-icon>
{{ voucher.verificationStatus === 'EXCEPTION' ? 'Exception raised against this voucher' : 'Verified against the slip' }}
</div>
}
<!-- A voucher raised before its sides were recorded: the number is valid, the posting behind it
simply was never captured, so it is shown as an older record rather than as an error. -->
@if (hasNoLines) {
<div class="pg-empty">
No debit or credit detail was recorded for this voucher. It was raised before the entry sides were kept.
</div>
} @else {
<table class="pg-table vv-lines">
<thead>
<tr>
<th>Particulars</th>
<th>Account</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
</tr>
</thead>
<tbody>
@for (line of debitLines; track line.lineNo) {
<tr>
<td>
<div class="vv-party">{{ line.partyName || '—' }}</div>
@if (line.description) {
<div class="vv-desc">{{ line.description }}</div>
}
</td>
<td>{{ line.accountNo || '—' }}</td>
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
<td class="num">—</td>
</tr>
}
@for (line of creditLines; track line.lineNo) {
<tr>
<td class="vv-indent">
<div class="vv-party">{{ line.partyName || '—' }}</div>
@if (line.description) {
<div class="vv-desc">{{ line.description }}</div>
}
</td>
<td>{{ line.accountNo || '—' }}</td>
<td class="num">—</td>
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2">Total</td>
<td class="num">{{ voucher.totalDebit | number: '1.2-2' }}</td>
<td class="num">{{ voucher.totalCredit | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
<!-- Stated rather than assumed: a voucher whose sides were written before the balance check
existed, or repaired by hand, should read as unbalanced rather than pass as a valid slip. -->
@if (!voucher.balanced) {
<div class="pg-alert pg-alert--error">
This voucher does not balance — debits and credits differ. Refer it to accounts before relying on it.
</div>
}
}
</div>
}
</div>
@@ -1,109 +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 and the table come from cashier-shared.scss. Only the slip itself is defined here. */
.vv-slip {
max-width: 860px;
}
.vv-slip-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid #e5e7eb;
}
.vv-slip-head-right {
text-align: right;
}
.vv-no {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.4px;
color: #111827;
}
.vv-amount {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.vv-meta {
margin-top: 2px;
font-size: 12px;
color: #6b7280;
}
.vv-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 14px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 8px;
color: #065f46;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
/* An exception is a warning about this voucher, not a state of it — it has to stop the eye. */
&.vv-status--exception {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
}
.vv-lines {
/* Credits sit under their debits the way they do on a written slip, so the indent carries the
relationship that the DR/CR columns alone would leave to the reader to work out. */
.vv-indent {
padding-left: 28px;
}
.vv-party {
color: #111827;
}
.vv-desc {
margin-top: 2px;
font-size: 11px;
color: #6b7280;
}
tfoot td {
font-weight: 600;
border-top: 2px solid #e5e7eb;
}
}
/* Printing a voucher should put the slip on paper and nothing else — the search box and buttons are
how you reached it, not part of it. */
@media print {
.vv-no-print {
display: none !important;
}
.vv-slip {
max-width: none;
border: none;
box-shadow: none;
}
}
@@ -1,143 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
/** One side of a voucher: a debit or a credit against a single account. */
interface VoucherLine {
lineNo: number;
entryType: 'DR' | 'CR';
glAccountId: number | null;
savingsAccountId: number | null;
loanAccountId: number | null;
partyName: string | null;
accountNo: string | null;
amount: number;
description: string | null;
}
interface Voucher {
voucherNo: string;
voucherType: string | null;
referenceType: string | null;
referenceId: number | null;
amount: number | null;
branchOfficeId: number | null;
branchName: string | null;
createdBy: string | null;
createdAt: string | null;
verificationStatus: string | null;
lines: VoucherLine[];
totalDebit: number;
totalCredit: number;
balanced: boolean;
}
/**
* One voucher, as a slip: which accounts were debited, which credited, and for how much.
*
* Looked up by voucher number rather than listed, because that is how a voucher is reached — from a
* paper slip in someone's hand, or from a number quoted on a statement.
*/
@Component({
selector: 'mifosx-voucher-view',
templateUrl: './voucher-view.component.html',
styleUrls: [
'../cashier-shared.scss',
'./voucher-view.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class VoucherViewComponent {
private service = inject(CashierService);
private route = inject(ActivatedRoute);
private router = inject(Router);
voucherNo = '';
voucher: Voucher | null = null;
loading = false;
/** Shown in place of the slip — a bad number and a server error read differently to the user. */
error: string | null = null;
constructor() {
// A voucher number in the URL means someone arrived from a link or a statement rather than by
// typing, so the slip is fetched straight away instead of showing them an empty search box.
const fromUrl = this.route.snapshot.queryParamMap.get('voucherNo');
if (fromUrl) {
this.voucherNo = fromUrl;
this.search();
}
}
search(): void {
const trimmed = (this.voucherNo || '').trim();
if (!trimmed) {
this.error = 'Enter a voucher number.';
this.voucher = null;
return;
}
this.loading = true;
this.error = null;
// Kept in the URL so the slip can be linked to and survives a refresh.
this.router.navigate([], {
relativeTo: this.route,
queryParams: { voucherNo: trimmed },
replaceUrl: true
});
this.service
.voucherDetail(trimmed)
.pipe(
catchError((err: any) => {
this.error =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
`Voucher ${trimmed} could not be loaded.`;
return of(null);
})
)
.subscribe((res: Voucher | null) => {
this.loading = false;
this.voucher = res;
});
}
/** Debits first, then credits — the order a slip is read in. */
get debitLines(): VoucherLine[] {
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'DR');
}
get creditLines(): VoucherLine[] {
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'CR');
}
/**
* A voucher raised before its sides were recorded. The voucher is real and its number valid — only
* the posting behind it was never captured — so this reads as an older record, not a broken one.
*/
get hasNoLines(): boolean {
return !!this.voucher && this.voucher.lines.length === 0;
}
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 } });
}
@@ -39,5 +39,4 @@ export class DividendDashboardService {
const params = status && status !== 'ALL' ? { status } : {};
return this.http.get('/clerk/dividend-dashboard/locks', { params });
}
}
@@ -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,235 +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,73 +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
@@ -163,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';
}
@@ -205,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);
}
}
}
@@ -170,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>
@@ -187,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',
@@ -263,6 +263,7 @@
<mat-form-field appearance="outline" class="name-field last-name">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
<<<<<<< Updated upstream
<input
matInput
required
@@ -271,6 +272,9 @@
(blur)="capitalizeControl('lastname')"
style="text-transform: capitalize"
/>
=======
<input matInput required formControlName="lastname" autocomplete="off" (input)="formatName('lastname')" />
>>>>>>> Stashed changes
@if (createClientForm.controls.lastname.hasError('required')) {
<mat-error>Last name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
@@ -394,7 +398,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(
@@ -2599,6 +2590,28 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
dormantFlag: 'ACTIVE'
};
}
/**
* Automatically capitalizes the first letter of each word and lowercases the rest.
* e.g., "sri rAm" -> "Sri Ram"
*/
formatName(controlName: string): void {
const control = this.createClientForm.get(controlName);
if (!control || typeof control.value !== 'string') return;
const val = control.value;
const formatted = val
.split(' ')
.map((word: string) => {
if (!word) return '';
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
})
.join(' ');
if (formatted !== val) {
control.setValue(formatted, { emitEvent: false });
}
}
}
/**
@@ -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>
@@ -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 {
@@ -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>
+1 -1
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';
/**
@@ -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 });
}
}
+15 -168
View File
@@ -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,47 +173,6 @@
Today's Activity
</a>
}
<!-- Outside the clerkAccountLoaded block on purpose: an open exception has to be
reachable whether or not the clerk currently has an account loaded. -->
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/cashier/voucher-exceptions')"
[routerLink]="['/cashier/voucher-exceptions']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Exceptions
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.split('?')[0] === '/cashier/voucher'"
[routerLink]="['/cashier/voucher']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1z" />
<line x1="8" y1="9" x2="16" y2="9" />
<line x1="8" y1="13" x2="14" y2="13" />
</svg>
Voucher
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/cashier/voucher-report')"
[routerLink]="['/cashier/voucher-report']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
Voucher Report
</a>
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav>
} @else {
@@ -286,30 +234,6 @@
>
<fa-icon icon="history"></fa-icon> Cash History
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/day-book']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="book"></fa-icon> Day Book
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher-exceptions']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exclamation-triangle"></fa-icon> Exceptions
</a>
<a class="cashier-nav__item" [routerLink]="['/cashier/voucher']" routerLinkActive="cashier-nav__item--active">
<fa-icon icon="receipt"></fa-icon> Voucher
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="list"></fa-icon> Voucher Report
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/closing-report']"
@@ -506,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
@@ -657,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>
@@ -748,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>
@@ -1071,50 +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>
</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>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Voucher Lookup
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-report']">
<fa-icon icon="list" class="menu-item-icon"></fa-icon> Voucher Report
</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]="['/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> My Disbursement Requests
</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>
</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
@@ -1149,18 +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/voucher-exceptions']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Voucher Lookup
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-report']">
<fa-icon icon="list" class="menu-item-icon"></fa-icon> Voucher Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
@@ -1182,14 +1048,16 @@
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/recurring-deposits']">
<fa-icon icon="sync" class="menu-item-icon"></fa-icon> Recurring
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS</button>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS
</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Agent</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Agent Commission
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-collection']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Today Collection
</button>
<button mat-menu-item tabindex="0">
@@ -1204,8 +1072,8 @@
</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 mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-reports']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Reports
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
@@ -1763,7 +1631,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">
@@ -1805,17 +1673,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>
@@ -2058,16 +1915,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">
@@ -12,7 +12,9 @@
<fa-icon icon="university" class="cca-header__icon"></fa-icon>
<div>
<h2 class="cca-header__title">Create Current Account</h2>
<div class="cca-header__sub">Pick a product, confirm currency and review before submit.</div>
<div class="cca-header__sub">
Pick a product, confirm currency, set digital channels and review before submit.
</div>
</div>
</div>
@@ -76,7 +78,34 @@
</div>
</mat-tab>
<!-- Tab 3: Preview -->
<!-- Tab 3: Digital -->
<mat-tab [disabled]="!currentAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="mobile-alt" class="cca-tab__icon"></fa-icon>
Digital
</ng-template>
<div class="cca-tab-body">
@if (currentAccountProductTemplate) {
<mifosx-current-account-digital-step
[currentAccountProductTemplate]="currentAccountProductTemplate"
></mifosx-current-account-digital-step>
}
<div class="cca-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cca-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<mat-tab [disabled]="!currentAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cca-tab__icon"></fa-icon>
@@ -57,8 +57,8 @@ export class CreateCurrentAccountComponent {
currentAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Preview. */
private readonly totalTabs = 3;
/** Total wizard tabs — Product, Currency, Digital, Preview. */
private readonly totalTabs = 4;
@ViewChild(CurrentAccountDetailsStepComponent, { static: true })
currentAccountDetailsStep: CurrentAccountDetailsStepComponent;
@@ -30,6 +30,18 @@
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-fill">
<mat-label>Opening Balance</mat-label>
<input matInput type="number" [min]="minRequiredOpeningBalance ?? 0" formControlName="openingBalance" />
<span matPrefix>₹&nbsp;</span>
@if (minRequiredOpeningBalance) {
<mat-hint>Minimum required: ₹{{ minRequiredOpeningBalance }}</mat-hint>
}
@if (activateCurrentAccountForm.controls.openingBalance.hasError('min')) {
<mat-error>Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }}</mat-error>
}
</mat-form-field>
</div>
</mat-card-content>
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { Component, OnInit, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
@@ -36,6 +36,8 @@ export class ActivateCurrentAccountComponent implements OnInit {
private router = inject(Router);
private settingsService = inject(SettingsService);
@Input() minRequiredOpeningBalance: number | null = null;
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
@@ -69,10 +71,18 @@ export class ActivateCurrentAccountComponent implements OnInit {
* Creates the activate current account form.
*/
createActivateCurrentAccountForm() {
const minBalance = this.minRequiredOpeningBalance ?? 0;
this.activateCurrentAccountForm = this.formBuilder.group({
activatedOnDate: [
'',
Validators.required
],
openingBalance: [
minBalance,
[
Validators.required,
Validators.min(minBalance)
]
]
});
}
@@ -10,7 +10,6 @@
<mat-card>
<form [formGroup]="approveCurrentAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
<div class="layout-column">
<mat-form-field (click)="approvedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
@@ -81,9 +81,8 @@ export class ApproveCurrentAccountComponent implements OnInit {
}
/**
* Submits the form, approves the current account, then immediately activates it on the same
* date -- Approve and Activate are still two separate Fineract commands underneath (each shows
* up in the audit trail), but the officer only sees and clicks through one step.
* Submits the form and approves the current account,
* if successful redirects to the current account.
*/
submit() {
const approveCurrentAccountFormData = this.approveCurrentAccountForm.value;
@@ -99,16 +98,7 @@ export class ApproveCurrentAccountComponent implements OnInit {
locale
};
this.currentAccountsService.executeCurrentAccountCommand(this.accountId, 'approve', data).subscribe(() => {
const activationData = {
activatedOnDate: approveCurrentAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.currentAccountsService
.executeCurrentAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../transactions'], { relativeTo: this.route });
});
this.router.navigate(['../../transactions'], { relativeTo: this.route });
});
}
}
@@ -13,7 +13,9 @@
<mifosx-reject-current-account></mifosx-reject-current-account>
}
@if (actions['Activate']) {
<mifosx-activate-current-account></mifosx-activate-current-account>
<mifosx-activate-current-account
[minRequiredOpeningBalance]="accountData?.minRequiredOpeningBalance"
></mifosx-activate-current-account>
}
@if (actions['Undo Approval']) {
<mifosx-undo-approval-current-account></mifosx-undo-approval-current-account>
@@ -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') || '';
}
@@ -15,6 +15,7 @@
<h3 class="dig-header__title">Digital &amp; Account Features</h3>
<p class="dig-header__sub">Toggle the facilities available for this account (as per RBI guidelines)</p>
</div>
<span class="dig-header__badge">FROM PRODUCT</span>
</div>
<div class="dig-grid">
@@ -52,11 +52,12 @@ export class CurrentAccountDigitalStepComponent {
}
}
// Note: a new account always starts with every digital facility off, even if the product has
// its own defaults configured -- those are enabled deliberately, one at a time, after the
// account is created and active, not inherited automatically at opening.
@Input()
currentAccountProductTemplate: any;
set currentAccountProductTemplate(value: any) {
if (!this.hasExistingAccount && value?.accountFacilityConfig) {
this.facilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...value.accountFacilityConfig };
}
}
get currentAccountDigital() {
return { accountFacilityConfig: { ...this.facilityConfig } };
@@ -136,11 +136,12 @@ export class CurrentAccountPreviewStepComponent implements OnChanges {
return this.currentAccountSettingsForm?.getRawValue?.() || this.currentAccount || {};
}
/** Digital facility config as actually captured by a digital step in this wizard -- no fallback
* to the product's own defaults, since account creation no longer has a digital step at all and
* must not preview facilities the officer never actually chose. */
get digitalFacilityConfig(): any {
return this.currentAccountDigital?.accountFacilityConfig ?? null;
return (
this.currentAccountDigital?.accountFacilityConfig ??
this.currentAccountProductTemplate?.accountFacilityConfig ??
null
);
}
get hasDigitalSection(): boolean {
@@ -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();
@@ -0,0 +1,241 @@
<!--
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="dte-container">
<div class="dte-header">
<button mat-stroked-button [routerLink]="['../']" class="dte-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="dte-header__title">Daily Transaction Detail</h2>
</div>
<div class="dte-card">
<form [formGroup]="entryForm">
<div class="dte-info-panels">
<div class="dte-info-panel">
<div class="dte-info-panel__title">Agent Information</div>
<dl>
<dt>Name</dt>
<dd>{{ agentInfo.name || '—' }}</dd>
<dt>Address</dt>
<dd>{{ agentInfo.address || '—' }}</dd>
<dt>City</dt>
<dd>{{ agentInfo.city || '—' }}</dd>
<dt>A/C</dt>
<dd>{{ agentInfo.accountNo || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor1 || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor2 || '—' }}</dd>
<dt>Curr Bal</dt>
<dd>{{ agentInfo.currentBalance | number: '1.2-2' }}</dd>
</dl>
</div>
<div class="dte-info-panel">
<div class="dte-info-panel__title">Party Information</div>
<p class="dte-info-panel__hint">Populated once a party is selected in a collection row below.</p>
</div>
</div>
<div class="dte-controls">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Agent</mat-label>
<input matInput required formControlName="agentId" />
</mat-form-field>
<button mat-stroked-button type="button" (click)="loadAgent()"><i class="fas fa-search"></i> Load</button>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Date</mat-label>
<input matInput required [matDatepicker]="txnDatePicker" formControlName="transactionDate" />
<mat-datepicker-toggle matIconSuffix [for]="txnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #txnDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Tran. Type</mat-label>
<mat-select required formControlName="transactionType">
@for (type of transactionTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (isPartyTran) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-list"></i>
<h3>Today's Collections</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="addCollectionRow()">
<i class="fas fa-plus"></i> Add Row
</button>
</div>
@if (collections.length === 0) {
<p class="dte-empty">No collection rows added yet.</p>
}
@for (row of collections.controls; track row; let i = $index) {
<div class="dte-collection-row" [formGroup]="$any(row)">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Party Code</mat-label>
<input matInput required formControlName="partyCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>TRTP</mat-label>
<mat-select required formControlName="transactionMode">
@for (mode of transactionModes; track mode) {
<mat-option [value]="mode">{{ mode }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>AC Type</mat-label>
<input matInput formControlName="accountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>A/C No.</mat-label>
<input matInput required formControlName="accountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput required type="number" formControlName="amount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Cheque No</mat-label>
<input matInput formControlName="chequeNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Token No</mat-label>
<input matInput formControlName="tokenNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="description" />
</mat-form-field>
<button
mat-icon-button
type="button"
class="dte-remove-row"
(click)="removeCollectionRow(i)"
matTooltip="Remove row"
>
<i class="fas fa-trash"></i>
</button>
</div>
}
</div>
}
@if (isAgentTran) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-hand-holding-usd"></i>
<h3>Agent Settlement</h3>
</div>
<p class="dte-info-panel__hint">
Cash the agent hands over to the branch, deducted from the agent's collected balance shown above.
</p>
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput required type="number" formControlName="settlementAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>TRTP</mat-label>
<mat-select required formControlName="settlementMode">
@for (mode of transactionModes; track mode) {
<mat-option [value]="mode">{{ mode }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>AC Type</mat-label>
<input matInput formControlName="settlementAccountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>A/C No.</mat-label>
<input matInput formControlName="settlementAccountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="settlementDescription" />
</mat-form-field>
</div>
</div>
}
@if (isWithdrawDeposite) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-exchange-alt"></i>
<h3>Withdraw Deposite</h3>
</div>
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<input matInput formControlName="creditorPartyCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Name</mat-label>
<input matInput formControlName="creditorPartyName" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<input matInput formControlName="debitPartyCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Name</mat-label>
<input matInput formControlName="debitPartyName" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput type="number" formControlName="withdrawAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="withdrawDescription" />
</mat-form-field>
</div>
</div>
}
@if (!isPartyTran && !isWithdrawDeposite && !isAgentTran) {
<div class="dte-section">
<p class="dte-empty">
"{{ entryForm.get('transactionType').value }}" entry screen — same shape as Party Tran, to be filled in once
the backend distinguishes these transaction types.
</p>
</div>
}
<div class="dte-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
<button mat-stroked-button type="button" (click)="viewLedger()">View Ledger</button>
<button mat-stroked-button type="button" (click)="signature()">Signature</button>
<button mat-raised-button color="primary" type="button" [disabled]="entryForm.invalid" (click)="save()">
Save
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,187 @@
/**
* 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/.
*/
$primary: #0f766e;
.dte-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px;
}
.dte-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.dte-back {
color: $primary;
}
.dte-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px 24px 24px;
}
.dte-info-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.dte-info-panel {
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 8px;
padding: 12px 16px;
background: rgb(15 118 110 / 4%);
&__title {
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $primary;
margin-bottom: 8px;
}
&__hint {
margin: 0;
color: rgb(0 0 0 / 50%);
font-size: 13px;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
margin: 0;
}
dt {
font-weight: 500;
color: rgb(0 0 0 / 60%);
font-size: 13px;
}
dd {
margin: 0;
font-size: 13px;
}
}
.dte-controls {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.dte-field {
min-width: 180px;
&--wide {
grid-column: span 2;
}
}
.dte-section {
padding: 16px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
}
}
.dte-add-row {
color: $primary;
}
.dte-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0;
}
.dte-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.dte-collection-row {
display: grid;
grid-template-columns: repeat(7, 1fr) auto;
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
}
.dte-collection-row .dte-field--wide {
grid-column: span 2;
}
.dte-remove-row {
color: #b91c1c;
margin-top: 8px;
}
.dte-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
flex-wrap: wrap;
}
@media (width <= 1000px) {
.dte-info-panels {
grid-template-columns: 1fr;
}
.dte-grid {
grid-template-columns: repeat(2, 1fr);
}
.dte-collection-row {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -0,0 +1,290 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/**
* Daily Transaction Entry component.
*
* The screen a collection agent uses at the end of a day's round: pick a
* transaction type, then batch-enter that day's collections against
* assigned parties (Party Tran), settle collected cash to the branch
* (Agent Tran), or record a withdrawal/transfer between accounts (Withdraw
* Deposite). Modelled directly on the legacy Daily Transaction Detail
* screen this replaces.
*
* Backed by the DDS Party Transaction API (`/dds/partytransactions`) for
* Party Tran, Agent Tran and Withdraw Deposite - the three types this
* screen actually collects fields for; Party Payment / OD Entry / OD
* Payment remain placeholders until the backend distinguishes them too.
* "Retrieve" (a ready-made list of today's assigned parties) isn't wired -
* that needs a dedicated per-agent Party lookup this MVP doesn't have yet.
*/
@Component({
selector: 'mifosx-daily-transaction-entry',
templateUrl: './daily-transaction-entry.component.html',
styleUrls: ['./daily-transaction-entry.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class DailyTransactionEntryComponent {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private static readonly TRANSACTION_TYPE_CODES: Record<string, string> = {
'Party Tran': 'PARTY_TRAN',
'Agent Tran': 'AGENT_TRAN',
'Withdraw Deposite': 'WITHDRAW_DEPOSITE'
};
transactionTypes = [
'Party Tran',
'Agent Tran',
'Party Payment',
'OD Entry',
'OD Payment',
'Withdraw Deposite'
];
transactionModes = [
'Cash',
'Transfer',
'Clearing'
];
entryForm: UntypedFormGroup = this.formBuilder.group({
agentId: [
'',
Validators.required
],
transactionType: [
'Party Tran',
Validators.required
],
transactionDate: [
new Date(),
Validators.required
],
// Withdraw Deposite fields
creditorPartyCode: [''],
creditorPartyName: [
{
value: '',
disabled: true
}
],
creditorPartyBalance: [
{
value: 0,
disabled: true
}
],
debitPartyCode: [''],
debitPartyName: [
{
value: '',
disabled: true
}
],
debitPartyBalance: [
{
value: 0,
disabled: true
}
],
withdrawAmount: [0],
withdrawDescription: [''],
// Agent Tran fields
settlementAmount: [0],
settlementMode: ['Cash'],
settlementAccountType: [''],
settlementAccountNo: [''],
settlementDescription: [''],
collections: this.formBuilder.array([])
});
agentInfo = {
name: '',
address: '',
city: '',
accountNo: '',
guarantor1: '',
guarantor2: '',
currentBalance: 0
};
get isPartyTran(): boolean {
return this.entryForm.get('transactionType').value === 'Party Tran';
}
get isWithdrawDeposite(): boolean {
return this.entryForm.get('transactionType').value === 'Withdraw Deposite';
}
get isAgentTran(): boolean {
return this.entryForm.get('transactionType').value === 'Agent Tran';
}
get collections(): UntypedFormArray {
return this.entryForm.get('collections') as UntypedFormArray;
}
private buildCollectionRow(): UntypedFormGroup {
return this.formBuilder.group({
partyCode: [
'',
Validators.required
],
transactionMode: [
'Cash',
Validators.required
],
accountType: [''],
accountNo: [
'',
Validators.required
],
amount: [
0,
Validators.required
],
chequeNo: [''],
tokenNo: [''],
description: [''],
partyName: [
{
value: '',
disabled: true
}
],
balanceAfterTransaction: [
{
value: 0,
disabled: true
}
]
});
}
addCollectionRow(): void {
this.collections.push(this.buildCollectionRow());
}
removeCollectionRow(index: number): void {
this.collections.removeAt(index);
}
loadAgent(): void {
const agentId = this.entryForm.get('agentId')?.value;
if (!agentId) {
return;
}
this.organizationService.getAgent(agentId).subscribe((agent: any) => {
const guarantors = agent.guarantors || [];
this.agentInfo = {
name: agent.agentNameId || '',
address: agent.agentAddress1 || '',
city: agent.agentCity || '',
accountNo: agent.agentSavingAccountNo || '',
guarantor1: guarantors[0]?.guarantorName || '',
guarantor2: guarantors[1]?.guarantorName || '',
currentBalance: agent.agentCurrentBalance || 0
};
});
}
retrieve(): void {
// No "my assigned parties" read API exists yet — this will pre-fill
// the collections grid with today's assigned parties once available.
}
viewLedger(): void {
// No ledger view exists yet for this agent.
}
signature(): void {
// No signature capture/verification flow exists yet.
}
cancel(): void {
this.entryForm.reset({
transactionType: 'Party Tran',
transactionDate: new Date()
});
this.collections.clear();
}
exit(): void {
this.router.navigate(['/accounts/savings']);
}
save(): void {
const raw = this.entryForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const transactionType = DailyTransactionEntryComponent.TRANSACTION_TYPE_CODES[raw.transactionType];
const payload: any = {
agentId: Number(raw.agentId),
transactionType,
transactionDate: this.dateUtils.formatDate(raw.transactionDate, dateFormat),
dateFormat,
locale
};
if (this.isPartyTran) {
payload.collections = (raw.collections || []).map((row: any) => ({
partyCode: row.partyCode,
transactionMode: row.transactionMode,
accountType: row.accountType || undefined,
accountNo: row.accountNo,
amount: row.amount,
chequeNo: row.chequeNo || undefined,
tokenNo: row.tokenNo || undefined,
description: row.description || undefined
}));
} else if (this.isWithdrawDeposite) {
payload.creditorPartyCode = raw.creditorPartyCode;
payload.debitPartyCode = raw.debitPartyCode;
payload.withdrawAmount = raw.withdrawAmount;
payload.withdrawDescription = raw.withdrawDescription || undefined;
} else if (this.isAgentTran) {
payload.amount = raw.settlementAmount;
payload.transactionMode = raw.settlementMode;
payload.accountType = raw.settlementAccountType || undefined;
payload.accountNo = raw.settlementAccountNo || undefined;
payload.description = raw.settlementDescription || undefined;
}
this.organizationService.createPartyTransaction(payload).subscribe(() => {
this.collections.clear();
this.entryForm.patchValue({
creditorPartyCode: '',
debitPartyCode: '',
withdrawAmount: 0,
withdrawDescription: '',
settlementAmount: 0,
settlementAccountType: '',
settlementAccountNo: '',
settlementDescription: ''
});
this.loadAgent();
});
}
}
@@ -0,0 +1,33 @@
<!--
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="dr-container">
<div class="dr-header">
<button mat-stroked-button [routerLink]="['../']" class="dr-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="dr-header__title">DDS Reports</h2>
</div>
@for (group of reportGroups; track group.title) {
<div class="dr-group">
<div class="dr-group__title">{{ group.title }}</div>
<div class="dr-grid">
@for (report of group.reports; track report) {
<a class="dr-card" [routerLink]="['view', report]">
<div class="dr-card__icon">
<i class="fas fa-file-alt"></i>
</div>
<div class="dr-card__title">{{ report }}</div>
<i class="fas fa-chevron-right dr-card__arrow"></i>
</a>
}
</div>
</div>
}
</div>
@@ -0,0 +1,97 @@
/**
* 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/.
*/
$primary: #0f766e;
.dr-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px;
}
.dr-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.dr-back {
color: $primary;
}
.dr-group {
margin-bottom: 24px;
&__title {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $primary;
font-weight: 700;
margin-bottom: 10px;
}
}
.dr-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.dr-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--md-sys-color-surface, #fff);
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 8px;
padding: 12px 14px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
&__icon {
color: $primary;
font-size: 16px;
flex: none;
}
&__title {
flex: 1;
font-size: 13px;
font-weight: 500;
}
&__arrow {
color: rgb(0 0 0 / 30%);
font-size: 12px;
}
}
@media (width <= 900px) {
.dr-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width <= 600px) {
.dr-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,73 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* DDS Reports hub component.
*
* Lists every DDS report from the legacy Reports menu, grouped the same
* way (As-On-Date, Interest, Daily/Balance/Master, Transaction). Each
* links to the shared report viewer, parameterised by report name.
*
* UI only for now: no report actually queries the backend yet — see
* ReportViewerComponent.
*/
@Component({
selector: 'mifosx-dds-reports',
templateUrl: './dds-reports.component.html',
styleUrls: ['./dds-reports.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class DdsReportsComponent {
reportGroups = [
{
title: 'As On Date',
reports: [
'Supplementary-DDS (As On)',
'Agent Check List',
'Party Check List-As On',
'Party Balance',
'Agent Wise Balance',
'Agent Wise Monthly Collection',
'Commission Register',
'Collection Summary Agent Wise',
'Close Party Ac Register',
'Agent Partywise Daily Collection',
'Daily Collection Balance',
'Interest Certificate',
'Mature Date Wise Report'
]
},
{
title: 'Interest',
reports: [
'Party Int. Register',
'Commission Register'
]
},
{
title: 'Daily / Balance / Master',
reports: [
'Daily Collection Report',
'Balance Summary',
'Master Data Export'
]
},
{
title: 'Transaction',
reports: ['Transaction Register']
},
{
title: 'Statements',
reports: ['Agent Commission Statement']
}
];
}
@@ -0,0 +1,94 @@
<!--
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="rv-container">
<div class="rv-header">
<button mat-stroked-button [routerLink]="['../../']" class="rv-back">
<i class="fas fa-arrow-left"></i> DDS Reports
</button>
<h2 class="rv-header__title">{{ reportName }}</h2>
</div>
<div class="rv-card">
<form [formGroup]="filterForm">
<div class="rv-grid">
<mat-form-field appearance="outline" class="rv-field">
<mat-label>From Date</mat-label>
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>To Date</mat-label>
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent From</mat-label>
<input matInput type="number" formControlName="agentFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent To</mat-label>
<input matInput type="number" formControlName="agentTo" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party From</mat-label>
<input matInput type="number" formControlName="partyFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party To</mat-label>
<input matInput type="number" formControlName="partyTo" />
</mat-form-field>
</div>
<div class="rv-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-raised-button color="primary" type="button" [disabled]="filterForm.invalid" (click)="runReport()">
<i class="fas fa-play" style="margin-right: 6px"></i> Run Report
</button>
</div>
@if (hasRun) {
<div class="rv-results">
@if (!available) {
<p class="rv-empty">{{ unavailableMessage }}</p>
} @else if (rows.length === 0) {
<p class="rv-empty">No rows matched these filters.</p>
} @else {
<div class="rv-table-scroll">
<table class="rv-table">
<thead>
<tr>
@for (column of columns; track column) {
<th>{{ column }}</th>
}
</tr>
</thead>
<tbody>
@for (row of rows; track row) {
<tr>
@for (column of columns; track column) {
<td>{{ row[column] }}</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
</form>
</div>
</div>
@@ -0,0 +1,103 @@
/**
* 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/.
*/
$primary: #0f766e;
.rv-container {
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px;
}
.rv-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.rv-back {
color: $primary;
}
.rv-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px 24px 24px;
}
.rv-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.rv-field {
width: 100%;
}
.rv-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.rv-results {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.rv-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0;
text-align: center;
padding: 24px 0;
}
.rv-table-scroll {
overflow-x: auto;
}
.rv-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
th {
text-align: left;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
padding: 8px 10px;
border-bottom: 2px solid rgb(0 0 0 / 10%);
}
td {
padding: 8px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
}
}
@media (width <= 900px) {
.rv-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -0,0 +1,120 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { Dates } from 'app/core/utils/dates';
/**
* Generic DDS report viewer.
*
* One shared screen for all 13+ DDS reports — the report name comes from
* the route and is mapped to a backend report key. Every report in the
* legacy system shared the same basic filter shape (date, agent range,
* party range), so this avoids building a bespoke screen per report - the
* backend returns a generic {columns, rows} shape this renders as one
* table regardless of which report it is.
*
* One legacy report (Close Party Ac Register) has no corresponding data in
* the backend yet - Party accounts have no closed/open status at all. Run
* Report still calls through, and the backend's `available: false` +
* message is shown instead of a table.
*/
@Component({
selector: 'mifosx-dds-report-viewer',
templateUrl: './report-viewer.component.html',
styleUrls: ['./report-viewer.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class ReportViewerComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private formBuilder = inject(UntypedFormBuilder);
private organizationService = inject(OrganizationService);
private dateUtils = inject(Dates);
private static readonly REPORT_KEYS: Record<string, string> = {
'Supplementary-DDS (As On)': 'SUPPLEMENTARY_DDS',
'Agent Check List': 'AGENT_CHECK_LIST',
'Party Check List-As On': 'PARTY_CHECK_LIST',
'Party Balance': 'PARTY_BALANCE',
'Agent Wise Balance': 'AGENT_WISE_BALANCE',
'Agent Wise Monthly Collection': 'AGENT_WISE_MONTHLY_COLLECTION',
'Commission Register': 'COMMISSION_REGISTER',
'Collection Summary Agent Wise': 'COLLECTION_SUMMARY_AGENT_WISE',
'Close Party Ac Register': 'CLOSE_PARTY_AC_REGISTER',
'Agent Partywise Daily Collection': 'AGENT_PARTYWISE_DAILY_COLLECTION',
'Daily Collection Balance': 'DAILY_COLLECTION_REPORT',
'Interest Certificate': 'INTEREST_CERTIFICATE',
'Mature Date Wise Report': 'MATURE_DATE_WISE',
'Party Int. Register': 'PARTY_INTEREST_REGISTER',
'Daily Collection Report': 'DAILY_COLLECTION_REPORT',
'Balance Summary': 'BALANCE_SUMMARY',
'Master Data Export': 'MASTER_DATA_EXPORT',
'Transaction Register': 'TRANSACTION_REGISTER',
'Agent Commission Statement': 'AGENT_COMMISSION_STATEMENT'
};
reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report';
hasRun = false;
available = true;
unavailableMessage = '';
columns: string[] = [];
rows: Array<Record<string, any>> = [];
filterForm: UntypedFormGroup = this.formBuilder.group({
fromDate: [
'',
Validators.required
],
toDate: [
'',
Validators.required
],
agentFrom: [1],
agentTo: [99999],
partyFrom: [1],
partyTo: [99999999]
});
runReport(): void {
const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName];
if (!reportKey) {
return;
}
const raw = this.filterForm.getRawValue();
this.organizationService
.runDdsReport(reportKey, {
fromDate: this.dateUtils.getDate(raw.fromDate),
toDate: this.dateUtils.getDate(raw.toDate),
agentFrom: raw.agentFrom,
agentTo: raw.agentTo,
partyFrom: raw.partyFrom,
partyTo: raw.partyTo
})
.subscribe((result: any) => {
this.available = result.available;
this.unavailableMessage = result.message || '';
this.columns = result.columns || [];
this.rows = result.rows || [];
this.hasRun = true;
});
}
exit(): void {
this.router.navigate(['../../'], { relativeTo: this.route });
}
}

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