= {};
+
+ submitting = false;
+ errorMsg = '';
+ successMsg = '';
+ result: any = null;
+
+ /** Only asked for when the counted total disagrees with an already-posted GL balance — mirrors
+ * the same "explain the gap" gate Open Business Day applies. */
+ differenceReason = '';
+
+ get allDenoms(): Denom[] {
+ return [
+ ...this.notes,
+ ...this.coins
+ ];
+ }
+
+ ngOnInit(): void {
+ this.maxDate = this.settingsService.businessDate;
+ this.businessDate = this.maxDate;
+
+ this.route.data.subscribe((data: { offices: any }) => {
+ this.officeData = data.offices || [];
+ });
+
+ this.service.getActiveDenominations().subscribe({
+ next: (list) => {
+ const rows: Denom[] = (list || []).map((x: any) => ({
+ id: x.id,
+ value: x.denomination,
+ kind: x.kind,
+ label: '₹' + x.denomination
+ }));
+ this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
+ this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
+ },
+ error: () => {
+ this.notes = [];
+ this.coins = [];
+ }
+ });
+ }
+
+ onOfficeChange(): void {
+ this.glStatus = null;
+ this.glError = '';
+ this.result = null;
+ this.successMsg = '';
+ this.errorMsg = '';
+ this.todayAlreadyOpen = false;
+ this.todayOpenInfo = null;
+ if (!this.officeId) return;
+
+ this.glLoading = true;
+ this.service
+ .cashGlStatus(this.officeId)
+ .pipe(catchError(() => of(null)))
+ .subscribe((res: any) => {
+ this.glLoading = false;
+ this.glStatus = res;
+ if (res && res.configured === false) {
+ this.glError = res.message || 'No cash GL account is configured for this tenant yet.';
+ }
+ });
+
+ this.service
+ .branchDayRegister(this.officeId)
+ .pipe(catchError(() => of(null)))
+ .subscribe((res: any) => {
+ const openDay = res?.openDay;
+ this.todayAlreadyOpen = !!openDay && openDay.businessDate === res?.today;
+ this.todayOpenInfo = this.todayAlreadyOpen ? openDay : null;
+ });
+ }
+
+ countFor(d: Denom): number {
+ return this.counts[d.id] || 0;
+ }
+
+ subtotalFor(d: Denom): number {
+ return this.countFor(d) * d.value;
+ }
+
+ onCountChange(d: Denom, raw: any): void {
+ this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
+ }
+
+ bumpCount(d: Denom, delta: number): void {
+ this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
+ }
+
+ /** Widens the count box as the typed count grows more digits, so a large count (hundreds,
+ * thousands) is never clipped or hard to read inside a field sized for one or two digits. */
+ countInputWidthCh(d: Denom): number {
+ return Math.max(3, String(this.countFor(d)).length + 2);
+ }
+
+ get countedTotal(): number {
+ return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0);
+ }
+
+ /** Whether this office's GL already carries a balance the count has to match, vs. an empty
+ * account the count itself will define. */
+ get glHasBalance(): boolean {
+ return !!this.glStatus?.hasBalance;
+ }
+
+ get glTarget(): number {
+ return Number(this.glStatus?.currentBalance ?? 0);
+ }
+
+ get isReconciled(): boolean {
+ if (!this.glStatus || this.glStatus.configured === false) return false;
+ if (this.countedTotal <= 0) return false;
+ return !this.glHasBalance || this.countedTotal === this.glTarget;
+ }
+
+ get needsDifferenceReason(): boolean {
+ return (
+ this.glHasBalance && this.countedTotal > 0 && this.countedTotal !== this.glTarget && !this.differenceReason.trim()
+ );
+ }
+
+ get canSubmit(): boolean {
+ return (
+ !!this.officeId &&
+ !!this.businessDate &&
+ !this.glError &&
+ this.countedTotal > 0 &&
+ !this.needsDifferenceReason &&
+ !this.submitting
+ );
+ }
+
+ private denominationPayload() {
+ return this.allDenoms
+ .filter((d) => this.countFor(d) > 0)
+ .map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }));
+ }
+
+ /** Strict yyyy-MM-dd, independent of the viewer's locale/date-format setting — see the comment on
+ * `businessDate` for why this can't go through dateUtils.formatDate(). */
+ private toIsoDate(d: Date): string {
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+ }
+
+ submit(): void {
+ if (!this.canSubmit || !this.officeId) return;
+ this.submitting = true;
+ this.errorMsg = '';
+ this.successMsg = '';
+ this.service
+ .migrateCashDenomination(
+ this.officeId,
+ this.toIsoDate(this.businessDate),
+ this.denominationPayload(),
+ this.differenceReason.trim() || undefined
+ )
+ .pipe(
+ catchError((err: any) => {
+ this.errorMsg =
+ err?.error?.errors?.[0]?.defaultUserMessage ??
+ err?.error?.message ??
+ 'Could not migrate cash denomination.';
+ return of(null);
+ })
+ )
+ .subscribe((res: any) => {
+ this.submitting = false;
+ if (res) {
+ this.result = res;
+ this.successMsg = res.glOpeningBalancePosted
+ ? `Cash GL opening balance defined at ₹${this.countedTotal.toLocaleString('en-IN')} and business day ${res.businessDate} opened.`
+ : `Business day ${res.businessDate} opened — count matched the existing GL balance.`;
+ }
+ });
+ }
+
+ goToBranchDay(): void {
+ this.router.navigate(['/branch-ops/day-register']);
+ }
+}
diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html
index 339dfc4bb..ac06c467a 100644
--- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html
+++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html
@@ -6,48 +6,67 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts
index 39c8e1694..cf8330511 100644
--- a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts
+++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts
@@ -14,6 +14,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
import { merge } from 'rxjs';
import { tap, startWith, map, distinctUntilChanged, debounceTime } from 'rxjs/operators';
import { AccountingService } from '../accounting.service';
+import { DISABLED_ACCOUNTING_FEATURES } from '../disabled-features';
import { SettingsService } from 'app/settings/settings.service';
import { JournalEntriesDataSource } from './journal-entry.datasource';
import { Dates } from 'app/core/utils/dates';
@@ -90,6 +91,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
maxDate = new Date();
+ /** Accounting features switched off for this installation — see disabled-features.ts. */
+ disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
// ── Filter controls ───────────────────────────────────────────────────────
officeName = new UntypedFormControl();
diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts
index 59385a350..047fed18c 100644
--- a/src/app/aml/aml.service.ts
+++ b/src/app/aml/aml.service.ts
@@ -34,6 +34,17 @@ export class AmlService {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
}
+ /**
+ * Decides named rules within one alert, leaving its other rules open.
+ *
+ *
+ * The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules
+ * leaves the alert open on the sixth.
+ */
+ reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable {
+ return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes });
+ }
+
// ═══ Recurring-alert review queue ═══
/**
@@ -122,6 +133,15 @@ export class AmlService {
return this.http.get('/aml/ctr-reports');
}
+ /**
+ * The reportable lines behind the generated CTRs, each resolved to the account, customer, branch
+ * and date a FIU-IND filing names. Omit `reportId` for every generated month.
+ */
+ getCtrReportLines(reportId?: number): Observable {
+ const params = reportId ? new HttpParams().set('reportId', String(reportId)) : undefined;
+ return this.http.get('/aml/ctr-reports/lines', { params });
+ }
+
/** month format: yyyy-MM */
generateCtrReport(month: string): Observable {
return this.http.post('/aml/ctr-reports/generate', { month });
@@ -132,4 +152,38 @@ export class AmlService {
getRiskCategoryHistory(clientId: number): Observable {
return this.http.get(`/clients/${clientId}/risk-category-history`);
}
+
+ // ═══ Risk-category changes (maker-checker) ═══
+ //
+ // The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so
+ // it moves only once a checker agrees rather than on one officer's say-so.
+
+ /** @param requestedCategory 1 Low, 2 Medium, 3 High */
+ requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable {
+ return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason });
+ }
+
+ getRiskChangeRequests(status?: string): Observable {
+ const params = status ? new HttpParams().set('status', status) : undefined;
+ return this.http.get('/aml/risk-change-requests', { params });
+ }
+
+ approveRiskChange(requestId: number): Observable {
+ return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {});
+ }
+
+ rejectRiskChange(requestId: number, rejectionReason: string): Observable {
+ return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason });
+ }
+
+ /**
+ * The latest behaviour window for each customer, as the nightly job computed it.
+ *
+ * One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report
+ * prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of
+ * transactions that happened to trip a rule.
+ */
+ getBehaviourWindows(): Observable {
+ return this.http.get('/risk-review/behaviour-windows');
+ }
}
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index c2317b327..4cfde7def 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -13,6 +13,7 @@ import { Routes, RouterModule } from '@angular/router';
// Not Found Component
import { NotFoundComponent } from './not-found/not-found.component';
import { CallbackComponent } from './zitadel/callback/callback.component';
+import { PlatformMenuAccessComponent } from './platform-admin/menu-access/menu-access.component';
/**
* Fallback to this route when no prior route is matched.
@@ -22,6 +23,12 @@ const routes: Routes = [
path: 'callback',
component: CallbackComponent
},
+ {
+ // Platform-operator only — outside the normal login/tenant flow on purpose, see
+ // PlatformMenuAccessComponent's own doc comment for why that is safe.
+ path: 'platform-admin/menu-access',
+ component: PlatformMenuAccessComponent
+ },
{
path: '**',
component: NotFoundComponent
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 0dac0a1d2..4136ae258 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -62,6 +62,7 @@ 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';
@@ -174,6 +175,7 @@ export function HttpLoaderFactory(http: HttpClient) {
CfcfrmsModule,
BankCalendarModule,
DeafModule,
+ SavingsDailyInterestModule,
DicgcModule,
FiuModule,
GlReportsModule,
diff --git a/src/app/board-resolution/bor-report.component.html b/src/app/board-resolution/bor-report.component.html
new file mode 100644
index 000000000..ec7b8bf32
--- /dev/null
+++ b/src/app/board-resolution/bor-report.component.html
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
Board of Resolution Register — Share Capital
+
+ Every share account with the board resolution that authorised the allotment. Accounts with no resolution on
+ record sort to the top.
+
+
+
+
+
+ {{ exporting ? 'Exporting…' : 'Export' }}
+
+
+
+ @if (loading) {
+
Loading the register…
+ } @else if (loadError) {
+
{{ loadError }}
+ }
+
+ @if (!loading && !loadError) {
+
+ @if (missingResolutionCount > 0) {
+
+
+ {{ missingResolutionCount }} share account{{ missingResolutionCount === 1 ? '' : 's' }} carr{{
+ missingResolutionCount === 1 ? 'ies' : 'y'
+ }}
+ no resolution number. Share capital allotted without a board resolution cannot be evidenced to the Registrar.
+
+ }
+
+
+
+
{{ filtered.length | number }}
+
Share Accounts
+
+
+
{{ distinctResolutions | number }}
+
Resolutions
+
+
+
{{ totalShares | number }}
+
Shares Allotted
+
+
+
₹{{ totalValue | number: '1.0-0' }}
+
Share Value
+
+
+
+
+
+ Resolution No.
+
+
+
+ Branch
+
+ All branches
+ @for (b of branches; track b) {
+ {{ b }}
+ }
+
+
+
+ Status
+
+ Any status
+ @for (s of statuses; track s) {
+ {{ statusText(s) }}
+ }
+
+
+
+ Search
+
+
+
+
+ No resolution only
+
+
Reset
+
+
+
+
+
+
+ Resolution No.
+ Resolution Date
+ Share A/c No.
+ CIF No.
+ Member Name
+ Branch
+ Product
+ Shares
+ Share Value
+ Tranches
+ Status
+ Activated On
+
+
+
+ @for (r of filtered; track r.shareAccountId) {
+
+
+ @if (r.resolutionNumber) {
+ {{ r.resolutionNumber }}
+ } @else {
+ Not recorded
+ }
+
+ {{ r.resolutionDate || '—' }}
+ {{ r.accountNo }}
+ {{ r.cifNumber || '—' }}
+ {{ r.clientName }}
+ {{ r.officeName || '—' }}
+ {{ r.productName || '—' }}
+ {{ r.approvedShares | number }}
+ {{ r.shareValue | number: '1.2-2' }}
+ {{ r.trancheCount }}
+ {{ statusText(r.statusEnum) }}
+ {{ r.activatedDate || '—' }}
+
+ } @empty {
+
+ No share account matches the filters.
+
+ }
+
+
+
+ }
+
diff --git a/src/app/board-resolution/bor-report.component.scss b/src/app/board-resolution/bor-report.component.scss
new file mode 100644
index 000000000..a21ca4608
--- /dev/null
+++ b/src/app/board-resolution/bor-report.component.scss
@@ -0,0 +1,313 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+.bor-root {
+ padding: 20px 24px 40px;
+ background: #f8fafc;
+ min-height: 100%;
+}
+
+.bor-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 18px;
+}
+
+.bor-head-left {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.bor-head-ic {
+ font-size: 26px;
+ color: #0f4f3c;
+ margin-top: 2px;
+}
+
+.bor-head-t {
+ margin: 0 0 3px;
+ font-size: 20px;
+ font-weight: 700;
+ color: #1e293b;
+}
+
+.bor-head-s {
+ margin: 0;
+ max-width: 780px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: #64748b;
+}
+
+.bor-note {
+ display: flex;
+ align-items: flex-start;
+ gap: 9px;
+ padding: 11px 14px;
+ margin-bottom: 14px;
+ border: 1px solid #bfdbfe;
+ border-radius: 9px;
+ background: #eff6ff;
+ color: #1e3a8a;
+ font-size: 12.5px;
+ line-height: 1.55;
+
+ fa-icon {
+ flex: 0 0 auto;
+ margin-top: 1px;
+ }
+
+ /* An allotment with no resolution behind it is the finding, not a detail. */
+ &--warn {
+ border-color: #fde68a;
+ background: #fffbeb;
+ color: #92400e;
+
+ fa-icon {
+ color: #d97706;
+ }
+ }
+}
+
+.bor-kpis {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.bor-kpi {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-left: 4px solid #0f4f3c;
+ border-radius: 11px;
+ padding: 12px 16px;
+
+ &--blue {
+ border-left-color: #2563eb;
+ }
+
+ &--green {
+ border-left-color: #15803d;
+ }
+
+ &--amber {
+ border-left-color: #d97706;
+ }
+}
+
+.bor-kpi-n {
+ font-size: 22px;
+ font-weight: 700;
+ color: #1e293b;
+ line-height: 1.2;
+}
+
+.bor-kpi-l {
+ font-size: 12px;
+ color: #64748b;
+ margin-top: 2px;
+}
+
+.bor-filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 12px;
+ padding: 14px 16px;
+ margin-bottom: 14px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 11px;
+}
+
+.bor-filter {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ label {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #64748b;
+ }
+
+ &--grow {
+ flex: 1 1 240px;
+ }
+}
+
+.bor-inp {
+ height: 34px;
+ min-width: 170px;
+ padding: 0 10px;
+ border: 1px solid #cbd5e1;
+ border-radius: 7px;
+ font: inherit;
+ font-size: 13px;
+ color: #1e293b;
+ background: #fff;
+
+ &:focus {
+ outline: 2px solid #2563eb;
+ outline-offset: -1px;
+ }
+}
+
+.bor-check {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 34px;
+ font-size: 12.5px;
+ color: #334155;
+ cursor: pointer;
+}
+
+.bor-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ height: 34px;
+ padding: 0 14px;
+ border: 1px solid #cbd5e1;
+ border-radius: 7px;
+ background: #fff;
+ font: inherit;
+ font-size: 12.5px;
+ font-weight: 600;
+ color: #334155;
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ background: #f1f5f9;
+ }
+
+ &:disabled {
+ opacity: 0.55;
+ cursor: default;
+ }
+
+ &--xls {
+ border-color: #15803d;
+ background: #16a34a;
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ background: #15803d;
+ }
+ }
+}
+
+.bor-table-wrap {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 11px;
+ overflow-x: auto;
+}
+
+.bor-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12.5px;
+
+ th {
+ text-align: left;
+ padding: 10px;
+ background: #f1f5f9;
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #475569;
+ white-space: nowrap;
+ border-bottom: 1px solid #e2e8f0;
+ }
+
+ td {
+ padding: 11px 10px;
+ border-bottom: 1px solid #f1f5f9;
+ vertical-align: middle;
+ color: #334155;
+ }
+
+ .num {
+ text-align: right;
+ font-family: 'DM Mono', monospace;
+ }
+
+ .mono {
+ font-family: 'DM Mono', monospace;
+ }
+
+ .strong {
+ font-weight: 700;
+ }
+
+ tbody tr:hover {
+ background: #f8fafc;
+ }
+}
+
+/* The row an auditor is looking for, marked in the table as well as counted above it. */
+.bor-row--missing {
+ background: #fffbeb;
+}
+
+.bor-res {
+ color: #0f4f3c;
+ font-family: 'DM Mono', monospace;
+}
+
+.bor-missing {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: #fef3c7;
+ color: #92400e;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.bor-status {
+ display: inline-block;
+ padding: 2px 9px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ background: #f1f5f9;
+ color: #475569;
+
+ &--active {
+ background: #dcfce7;
+ color: #15803d;
+ }
+
+ &--submitted,
+ &--approved {
+ background: #fef3c7;
+ color: #b45309;
+ }
+
+ &--rejected,
+ &--closed {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+}
+
+.bor-empty {
+ padding: 30px;
+ text-align: center;
+ color: #94a3b8;
+}
diff --git a/src/app/board-resolution/bor-report.component.ts b/src/app/board-resolution/bor-report.component.ts
new file mode 100644
index 000000000..776a4ad40
--- /dev/null
+++ b/src/app/board-resolution/bor-report.component.ts
@@ -0,0 +1,221 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, OnInit, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import { exportWorkbook } from '../cashier/cashier-export';
+import { BorRegisterRow, BorReportService } from './bor-report.service';
+
+/** m_share_account.status_enum, as the register labels it. */
+const STATUS_LABEL: Record = {
+ 100: 'Submitted',
+ 200: 'Approved',
+ 300: 'Active',
+ 400: 'Rejected',
+ 500: 'Closed',
+ 600: 'Inactive'
+};
+
+/**
+ * The Board of Resolution register for share capital.
+ *
+ * A co-operative allots shares only under a board resolution, and this is the list the Registrar or
+ * an auditor asks for: every member's holding against the resolution that authorised it. The
+ * account with no resolution recorded is the point of the register, so it sorts to the top and is
+ * counted on its own rather than being left to be noticed.
+ */
+@Component({
+ selector: 'mifosx-bor-report',
+ templateUrl: './bor-report.component.html',
+ styleUrls: ['./bor-report.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FaIconComponent,
+ FormsModule
+ ]
+})
+export class BorReportComponent implements OnInit {
+ private borService = inject(BorReportService);
+
+ readonly statusLabel = STATUS_LABEL;
+
+ rows: BorRegisterRow[] = [];
+ loading = false;
+ loadError = '';
+
+ /** '' = every status. */
+ filterStatus: number | '' = '';
+ filterBranch = '';
+ filterResolution = '';
+ search = '';
+ /** Only the accounts with no resolution on file — the ones that need chasing. */
+ missingOnly = false;
+
+ exporting = false;
+
+ ngOnInit(): void {
+ this.load();
+ }
+
+ load(): void {
+ this.loading = true;
+ this.loadError = '';
+ this.borService.getRegister().subscribe({
+ next: (rows) => {
+ this.loading = false;
+ this.rows = rows;
+ },
+ error: (err: any) => {
+ this.loading = false;
+ this.rows = [];
+ this.loadError = err?.error?.defaultUserMessage || 'Could not load the BOR register.';
+ }
+ });
+ }
+
+ /** Branches present in the data, so the filter offers only what can actually be selected. */
+ get branches(): string[] {
+ return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
+ }
+
+ get statuses(): number[] {
+ return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b);
+ }
+
+ get filtered(): BorRegisterRow[] {
+ const needle = this.search.trim().toLowerCase();
+ const resolutionNeedle = this.filterResolution.trim().toLowerCase();
+ return this.rows.filter((r) => {
+ if (this.filterStatus !== '' && r.statusEnum !== this.filterStatus) {
+ return false;
+ }
+ if (this.filterBranch && r.officeName !== this.filterBranch) {
+ return false;
+ }
+ if (this.missingOnly && r.resolutionNumber) {
+ return false;
+ }
+ if (resolutionNeedle && !r.resolutionNumber.toLowerCase().includes(resolutionNeedle)) {
+ return false;
+ }
+ if (
+ needle &&
+ !(
+ r.clientName.toLowerCase().includes(needle) ||
+ r.accountNo.toLowerCase().includes(needle) ||
+ r.cifNumber.toLowerCase().includes(needle)
+ )
+ ) {
+ return false;
+ }
+ return true;
+ });
+ }
+
+ get totalShares(): number {
+ return this.filtered.reduce((s, r) => s + r.approvedShares, 0);
+ }
+
+ get totalValue(): number {
+ return this.filtered.reduce((s, r) => s + r.shareValue, 0);
+ }
+
+ /** How many resolutions the filtered holdings were authorised under. */
+ get distinctResolutions(): number {
+ return new Set(this.filtered.map((r) => r.resolutionNumber).filter(Boolean)).size;
+ }
+
+ get missingResolutionCount(): number {
+ return this.rows.filter((r) => !r.resolutionNumber).length;
+ }
+
+ statusText(statusEnum: number): string {
+ return STATUS_LABEL[statusEnum] || '—';
+ }
+
+ statusClass(statusEnum: number): string {
+ return 'bor-status bor-status--' + (STATUS_LABEL[statusEnum] || 'unknown').toLowerCase();
+ }
+
+ resetFilters(): void {
+ this.filterStatus = '';
+ this.filterBranch = '';
+ this.filterResolution = '';
+ this.search = '';
+ this.missingOnly = false;
+ }
+
+ async exportExcel(): Promise {
+ this.exporting = true;
+ try {
+ const rows = this.filtered;
+ await exportWorkbook(
+ [
+ {
+ sheetName: 'BOR Register',
+ title: 'Board of Resolution Register — Share Capital',
+ subtitle: `${rows.length} share account(s) · ${this.distinctResolutions} resolution(s) · ${this.totalShares} shares`,
+ headers: [
+ 'Resolution No.',
+ 'Resolution Date',
+ 'Share A/c No.',
+ 'CIF No.',
+ 'Member Name',
+ 'Branch',
+ 'Product',
+ 'Shares',
+ 'Unit Price',
+ 'Share Value',
+ 'Tranches',
+ 'Status',
+ 'Activated On'
+ ],
+ widths: [
+ 20,
+ 16,
+ 18,
+ 16,
+ 30,
+ 18,
+ 18,
+ 10,
+ 12,
+ 14,
+ 10,
+ 12,
+ 14
+ ],
+ rows: rows.map((r) => [
+ // Named rather than left blank, because an empty cell in a filing reads as an
+ // oversight in the export rather than a gap in the register.
+ r.resolutionNumber || 'NOT RECORDED',
+ r.resolutionDate || '—',
+ r.accountNo,
+ r.cifNumber,
+ r.clientName,
+ r.officeName,
+ r.productName,
+ r.approvedShares,
+ r.unitPrice,
+ r.shareValue,
+ r.trancheCount,
+ this.statusText(r.statusEnum),
+ r.activatedDate || '—'
+ ])
+ }
+ ],
+ 'BOR-Register'
+ );
+ } finally {
+ this.exporting = false;
+ }
+ }
+}
diff --git a/src/app/board-resolution/bor-report.service.ts b/src/app/board-resolution/bor-report.service.ts
new file mode 100644
index 000000000..b75370e8c
--- /dev/null
+++ b/src/app/board-resolution/bor-report.service.ts
@@ -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/.
+ */
+
+/** Angular Imports */
+import { Injectable, inject } from '@angular/core';
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Observable, map } from 'rxjs';
+
+/** Custom Helpers */
+import { toIsoDate } from '../core/utils/api-date';
+
+/** One share account and the board resolution that authorised it. */
+export interface BorRegisterRow {
+ shareAccountId: number;
+ accountNo: string;
+ clientId: number;
+ cifNumber: string;
+ clientName: string;
+ officeId: number | null;
+ officeName: string;
+ productName: string;
+ currencyCode: string;
+ statusEnum: number;
+ approvedShares: number;
+ pendingShares: number;
+ unitPrice: number;
+ shareValue: number;
+ resolutionNumber: string;
+ /** ISO date, or '' where no meeting date was ever recorded against the allotment. */
+ resolutionDate: string;
+ /** How many purchase tranches make up the holding. */
+ trancheCount: number;
+ submittedDate: string;
+ approvedDate: string;
+ activatedDate: string;
+ closedDate: string;
+}
+
+/**
+ * The BOR register.
+ *
+ * One call returns every account with its resolution. The share allotment report next door fetches
+ * each account separately and stops at two hundred, which is a limit on a register rather than on a
+ * page of one.
+ */
+@Injectable({ providedIn: 'root' })
+export class BorReportService {
+ private http = inject(HttpClient);
+
+ getRegister(status?: number, officeId?: number): Observable {
+ let params = new HttpParams();
+ if (status != null) {
+ params = params.set('status', String(status));
+ }
+ if (officeId != null) {
+ params = params.set('officeId', String(officeId));
+ }
+ return this.http
+ .get('/shareaccounts/bor-register', { params })
+ .pipe(map((rows) => (rows || []).map((r) => this.toRow(r))));
+ }
+
+ /** Dates arrive as [y, M, d] arrays; flattened to ISO so they sort and filter as strings. */
+ private toRow(r: any): BorRegisterRow {
+ return {
+ shareAccountId: r.shareAccountId,
+ accountNo: r.accountNo || '',
+ clientId: r.clientId,
+ cifNumber: r.cifNumber || '',
+ clientName: r.clientName || '',
+ officeId: r.officeId ?? null,
+ officeName: r.officeName || '',
+ productName: r.productName || '',
+ currencyCode: r.currencyCode || 'INR',
+ statusEnum: Number(r.statusEnum) || 0,
+ approvedShares: Number(r.approvedShares) || 0,
+ pendingShares: Number(r.pendingShares) || 0,
+ unitPrice: Number(r.unitPrice) || 0,
+ shareValue: Number(r.shareValue) || 0,
+ resolutionNumber: (r.resolutionNumber || '').trim(),
+ resolutionDate: toIsoDate(r.resolutionDate),
+ trancheCount: Number(r.trancheCount) || 0,
+ submittedDate: toIsoDate(r.submittedDate),
+ approvedDate: toIsoDate(r.approvedDate),
+ activatedDate: toIsoDate(r.activatedDate),
+ closedDate: toIsoDate(r.closedDate)
+ };
+ }
+}
diff --git a/src/app/cashier/account-register/account-register.component.html b/src/app/cashier/account-register/account-register.component.html
new file mode 100644
index 000000000..d1a802313
--- /dev/null
+++ b/src/app/cashier/account-register/account-register.component.html
@@ -0,0 +1,169 @@
+
+
+
+
+
+
REPORTS · ACCOUNT REGISTER
+
Account Register
+
+ 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.
+
+
+
+
+ chevron_left
+
+
+ As on date
+
+
+
+ chevron_right
+
+ Today
+
+ refresh Reload
+
+
+
+
+
+
Account Register as on {{ date }} · {{ section }}
+
+
+
+ Section
+
+ @for (s of sections; track s.value) {
+ {{ s.label }}
+ }
+
+
+
+ Movement
+
+
+ Only accounts opened / closed on this date
+
+
+
+ Search
+
+
+
+ print Print
+
+
+ download CSV
+
+
+
+ @if (errorMsg) {
+
{{ errorMsg }}
+ }
+
+ @if (loading) {
+
Loading…
+ }
+
+ @if (!loading && loaded) {
+
+
+
+ Accounts opened on {{ date }}
+ {{ openedCount }}
+
+
+ Accounts closed on {{ date }}
+ {{ closedCount }}
+
+
+ Balance on {{ date }}
+ ₹{{ shownBalanceOnDate | number: '1.2-2' }}
+
+
+ Current balance
+ ₹{{ shownCurrentBalance | number: '1.2-2' }}
+
+
+ }
+
+ @if (!loading && loaded && filteredRows.length === 0) {
+
+
account_balance_wallet
+
+ @if (movementOnly) {
+ No account was opened or closed on {{ date }}.
+ } @else {
+ No accounts existed on {{ date }}.
+ }
+
+
+ }
+
+ @if (filteredRows.length > 0) {
+
+
+
+
+ Account No
+ Section
+ Client
+ Product
+ Status
+ Opened
+ Closed
+ Balance on {{ date }}
+ Current Balance
+
+
+
+ @for (r of filteredRows; track r.section + r.accountId) {
+
+
+ {{ r.accountNo }}
+ @if (r.openedOnDate) {
+ OPENED
+ }
+ @if (r.closedOnDate) {
+ CLOSED
+ }
+
+ {{ r.section }}
+ {{ r.clientName ?? '—' }}
+ {{ r.productName ?? '—' }}
+
+ {{ r.status }}
+
+ {{ r.openedOn ?? '—' }}
+ {{ r.closedOn ?? '—' }}
+ ₹{{ r.balanceOnDate | number: '1.2-2' }}
+
+ ₹{{ r.currentBalance | number: '1.2-2' }}
+ @if (r.section === 'SHARE' && r.sharesHeld) {
+ · {{ r.sharesHeld }} sh
+ }
+
+
+ }
+
+
+
+
+ {{ filteredRows.length }} account(s) · {{ openedCount }} opened · {{ closedCount }} closed
+
+ ₹{{ shownBalanceOnDate | number: '1.2-2' }}
+ ₹{{ shownCurrentBalance | number: '1.2-2' }}
+
+
+
+
+ }
+
diff --git a/src/app/cashier/account-register/account-register.component.scss b/src/app/cashier/account-register/account-register.component.scss
new file mode 100644
index 000000000..702002e4e
--- /dev/null
+++ b/src/app/cashier/account-register/account-register.component.scss
@@ -0,0 +1,144 @@
+/**
+ * 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;
+ }
+}
diff --git a/src/app/cashier/account-register/account-register.component.ts b/src/app/cashier/account-register/account-register.component.ts
new file mode 100644
index 000000000..683841a11
--- /dev/null
+++ b/src/app/cashier/account-register/account-register.component.ts
@@ -0,0 +1,221 @@
+/**
+ * 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();
+ }
+}
diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html
new file mode 100644
index 000000000..65d27eb6e
--- /dev/null
+++ b/src/app/cashier/branch-day/branch-day.component.html
@@ -0,0 +1,743 @@
+
+
+
+
+
+
BRANCH OPERATIONS · {{ missedOnly ? 'MISSED CLOSE' : 'DAY REGISTER' }}
+
{{ missedOnly ? 'Missed Close Register' : 'Day Open / Day Close' }}
+
+ @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.
+ }
+
+
+
+ @if (canOpenToday && !missedOnly) {
+
+ lock_open Open Day
+
+ }
+
+ refresh Reload
+
+
+
+
+ @if (errorMsg) {
+
{{ errorMsg }}
+ }
+ @if (successMsg) {
+
{{ successMsg }}
+ }
+
+ @if (!canOperate && loaded) {
+
+ info
+ 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.
+
+ }
+
+ @if (firstOpenNeedsCashMigration && !missedOnly) {
+
+
lock
+
+ This branch has never opened a business day, and its Cash at Vault GL account has no opening balance yet. Count
+ the vault and open the first business day together from
+ Migrate Cash Denomination — Open Day here is
+ locked until that is done, so today's cash tracking never starts out of step with the ledger.
+
+
+ }
+
+
+ @if (missed.length > 0) {
+
+
+ warning
+ {{ missed.length }} business day{{ missed.length === 1 ? '' : 's' }} never closed
+
+ @for (m of missed; track m.id) {
+
+ {{ m.businessDate }}
+ open {{ m.daysOpen }} day{{ m.daysOpen === 1 ? '' : 's' }}
+
+ opened {{ timeOnly(m.openedAt) }} by {{ m.openedByName }} · opening ₹{{ m.openingCash | number: '1.2-2' }}
+
+ @if (canOperate) {
+
+ Close this day
+
+ }
+
+ }
+
+ }
+
+
+ @if (missedOnly && loaded && missed.length === 0) {
+
+
check_circle
+
No missed closes. Every business day opened for this branch has been closed.
+
+ }
+
+
+ @if (!missedOnly && openDay) {
+
+
+ DAY OPEN
+ {{ openDay.businessDate }}
+
+ opened {{ timeOnly(openDay.openedAt) }} by {{ openDay.openedByName }} · opening cash ₹{{
+ openDay.openingCash | number: '1.2-2'
+ }}
+
+
+ @if (canOperate) {
+
+ lock Close Day
+
+ }
+
+ } @else if (loaded && canOperate) {
+
+
+ NO DAY OPEN
+ Open the business day before any cash transaction is accepted.
+
+
+ }
+
+
+ @if (showOpenForm) {
+
+
+ Open business day
+
+ close
+
+
+
+
+ Business date
+
+
+
+ Opening cash (leave blank to carry forward)
+ 0"
+ placeholder="Carried from last close"
+ />
+
+
+
+ @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.
+ }
+
+
+ @if (hasAutoFilledOpenCounts && openLiveDifference !== null) {
+ @if (openLiveDifference === 0) {
+
+ vs. yesterday's close
+ Tallied
+
+ } @else {
+
+ vs. yesterday's close
+
+ {{ openLiveDifference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(openLiveDifference) | number: '1.2-2' }}
+
+
+ }
+ }
+
+
+ @if (openDenomTotal > 0) {
+
+ Denomination total: ₹{{ openDenomTotal | number: '1.2-2' }}
+ @if (hasAutoFilledOpenCounts) {
+
+ Reset to yesterday's count
+
+ }
+
+ }
+
+ @if (openLiveDifference !== null && openLiveDifference !== 0) {
+
+ Why does this differ from yesterday's closing count? (required)
+
+
+ }
+
+
+
+ Open Day
+
+ Cancel
+
+
+ }
+
+
+ @if (closeTarget) {
+
+
+ Close business day — {{ closeTarget.businessDate }}
+
+ close
+
+
+
+ @if (needsForceReason) {
+
+ This day is being closed after its own date. It will be recorded as a
+ force close , with the reason below kept permanently against it.
+
+ }
+
+ @if (previewLoading) {
+
Working out the day's figure…
+ }
+
+
+ @if (preview) {
+
+
+ Opening cash (carried forward)
+ ₹{{ preview.openingCash | number: '1.2-2' }}
+
+
+ + Cash deposits taken (approved)
+ ₹{{ preview.deposits | number: '1.2-2' }}
+
+
+ − Cash paid out (approved withdrawals)
+ ₹{{ preview.withdrawals | number: '1.2-2' }}
+
+
+ Cash the branch should be holding
+ ₹{{ preview.expectedCash | number: '1.2-2' }}
+
+ @if (preview.vaultIn > 0 || preview.settledOut > 0) {
+
+ 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.
+
+ }
+
+ }
+
+
+ @if (eodLoading) {
+
Fetching vault and cashier positions…
+ }
+
+
+ @if (branchTotalSummary; as bt) {
+
+
+ account_balance_wallet
+ Branch Total (Vault + All Cashiers)
+
+
+ Vault ₹{{ bt.vaultTotal | number: '1.2-2' }}
+ + Cashiers ₹{{ bt.cashierTotal | number: '1.2-2' }}
+ = ₹{{ bt.grandTotal | number: '1.2-2' }}
+ vs. book ₹{{ bt.expected | number: '1.2-2' }}
+
+ @if (bt.difference === 0) {
+
Branch Tallied
+ } @else {
+
+ {{ bt.difference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(bt.difference) | number: '1.2-2' }}
+
+ }
+
+ }
+
+ @for (t of eodTellers; track t.tellerId) {
+
+
+ account_balance
+ {{ t.tellerName }}
+
+ vault holds
+ {{ t.vault?.hasBreakdown ? '₹' + (t.vault.totalAmount | number: '1.2-2') : 'no breakdown recorded' }}
+
+
+
+ @if (t.vault?.hasBreakdown && t.vault.denominations.length > 0) {
+
+ @for (d of t.vault.denominations; track d.denominationId) {
+ ₹{{ d.denominationValue }} × {{ d.count }}
+ }
+
+ }
+
+ @if (t.cashiers.length === 0) {
+
No cashiers allocated under this vault.
+ }
+ @for (c of t.cashiers; track c.cashierId) {
+
+
+ {{ c.cashierName }}
+
+ morning ₹{{ c.morningTotal | number: '1.2-2' }} → current ₹{{ c.currentTotal | number: '1.2-2' }} ·
+ {{ c.txnCount ?? '—' }} txn{{ c.txnCount === 1 ? '' : 's' }} today
+
+
+
+ @if (c.reconciled === true) {
+ Reconciled
+ } @else if (c.reconciled === false) {
+ Not Reconciled
+ }
+ @if (c.alreadySettled === true) {
+ Settled to Vault
+ } @else if (c.alreadySettled === false) {
+ Not Settled
+ }
+
+ @if (c.denominations.length > 0) {
+
+ @for (d of c.denominations; track d.denominationId) {
+ 0"
+ [class.bd-eod__chip--down]="d.delta < 0"
+ >
+ ₹{{ d.denominationValue }}: {{ d.morningCount }} → {{ d.count }}
+ @if (d.delta !== 0) {
+ ({{ d.delta > 0 ? '+' : '' }}{{ d.delta }})
+ }
+
+ }
+
+ } @else {
+
No balance.
+ }
+
+ }
+
+ }
+
+
+
+ Physical cash counted (or count denominations below)
+ 0"
+ placeholder="0.00"
+ />
+
+ @if (preview && liveDifference !== null) {
+ @if (liveDifference === 0) {
+
+ Difference
+ Tallied
+
+ } @else {
+
+ Difference
+
+ {{ liveDifference > 0 ? 'Excess' : 'Short' }} ₹{{
+ liveDifference < 0 ? -liveDifference : (liveDifference | number: '1.2-2')
+ }}
+
+
+ }
+ }
+ @if (preview && liveDifference !== 0) {
+
Use expected figure
+ }
+
+
+
+ @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.
+ }
+
+
+
+ @if (closeDenomTotal > 0) {
+
+ Denomination total: ₹{{ closeDenomTotal | number: '1.2-2' }}
+ @if (hasAutoFilledCounts) {
+
+ Reset to auto-filled count
+
+ }
+
+ }
+
+
+ @if (isLateClose || hasUnreconciledOrUnsettledCashiers) {
+
+
+ @if (isLateClose) {
+ Why was this day left open? (required)
+ } @else {
+ Why close over a cashier who has not reconciled? (required)
+ }
+
+
+
+ }
+
+
+ Remarks (optional)
+
+
+
+
+ The figure above is stored against the record, so a difference stays readable later even if a transaction is
+ reversed afterwards.
+
+
+ @if (hasUnreconciledOrUnsettledCashiers) {
+
+
+
{{ blocksSameDayClose ? 'block' : 'warning' }}
+
+ @if (blocksSameDayClose) {
+
Cannot close today — cashiers still holding cash have not been reconciled or settled.
+ } @else {
+
This day's cash was never reconciled or settled.
+ }
+
+ @for (detail of unreconciledOrUnsettledCashierDetails; track detail.name) {
+
+ {{ detail.name }} —
+ @if (detail.notReconciled && detail.notSettled) {
+ Not Reconciled, Not Settled
+ } @else if (detail.notReconciled) {
+ Not Reconciled
+ } @else {
+ Not Settled
+ }
+
+ }
+
+ @if (blocksSameDayClose) {
+
Each cashier must count their drawer, reconcile it, and settle the cash back to the vault. Then this
+ day can be closed.
+ } @else {
+
The cash cannot be counted for a date that has passed. Record why above — their names are kept
+ against the record.
+ }
+
+
+ }
+
+
+
+ {{ needsForceReason ? 'Force Close Day' : 'Close Day' }}
+
+ Cancel
+
+
+ }
+
+ @if (loading) {
+
Loading…
+ }
+
+ @if (!loading && loaded && days.length === 0 && !missedOnly) {
+
+
event_note
+
No business day has been opened for this branch yet.
+
+ }
+
+
+ @if (days.length > 0 && !missedOnly) {
+
+
+
+
+ Business Date
+ Status
+ Opened By
+ Opening ₹
+ Closed By
+ Closing ₹
+ Book ₹
+ Difference
+ Note
+
+
+
+ @for (d of days; track d.id) {
+
+
+ {{ d.businessDate }}
+
+
+ @if (d.status === 'OPEN') {
+ OPEN
+ } @else if (d.forceClosed) {
+ FORCE CLOSED
+ } @else {
+ CLOSED
+ }
+
+
+ {{ d.openedByName ?? '—' }}
+ · {{ timeOnly(d.openedAt) }}
+
+ ₹{{ d.openingCash | number: '1.2-2' }}
+
+ {{ d.closedByName ?? '—' }}
+ @if (d.closedAt) {
+ · {{ timeOnly(d.closedAt) }}
+ }
+
+ {{ hasValue(d.closingCash) ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}
+ {{ hasValue(d.bookCash) ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}
+
+ @if (!hasValue(d.cashDifference)) {
+ —
+ } @else if (d.cashDifference === 0) {
+ Tallied
+ } @else {
+ {{ differenceLabel(d.cashDifference) }}
+ }
+
+
+ @if (d.forceCloseReason) {
+ Left open: {{ d.forceCloseReason }}
+ }
+ @if (d.remarks) {
+ {{ d.remarks }}
+ }
+
+
+ }
+
+
+
+ }
+
diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss
new file mode 100644
index 000000000..c97aebcc9
--- /dev/null
+++ b/src/app/cashier/branch-day/branch-day.component.scss
@@ -0,0 +1,374 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Page chrome comes from cashier-shared.scss; the badges, panel and comparison tiles are shared
+ with the day book. Only what is specific to the day register lives here. */
+
+/* Read-only notice for a cashier. Defined here rather than borrowed from the exceptions screen so
+ this component does not depend on another component's stylesheet. */
+.ve-note {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ margin-bottom: 14px;
+ background: #eff6ff;
+ border: 1px solid #bfdbfe;
+ border-radius: 8px;
+ color: #1e40af;
+ font-size: 12px;
+
+ mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ }
+}
+
+/* Blocks Open Day until the branch's first-ever open has gone through Migrate Cash Denomination —
+ see BranchDayComponent.checkFirstOpenCashMigration(). Same shape as .ve-note, in the error palette
+ the rest of this screen already uses for a missed close (.bd-missed). */
+.bd-locked {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 10px 14px;
+ margin-bottom: 14px;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ border-radius: 8px;
+ color: #b91c1c;
+ font-size: 12px;
+ line-height: 1.5;
+
+ mat-icon {
+ flex: none;
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ }
+
+ a {
+ color: inherit;
+ font-weight: 600;
+ text-decoration: underline;
+ }
+}
+
+.bd-success {
+ padding: 10px 14px;
+ margin-bottom: 14px;
+ background: #f0fdf4;
+ border: 1px solid #bbf7d0;
+ border-radius: 8px;
+ color: #14532d;
+ font-size: 13px;
+}
+
+/* One row per never-closed day, inside the shared red exception panel. */
+.bd-missed {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ padding: 7px 0;
+ font-size: 12px;
+ border-top: 1px dotted #fecaca;
+
+ &__age {
+ color: #b91c1c;
+ }
+
+ /* Pushed right so the date and age read before the action. */
+ &__act {
+ margin-left: auto;
+ height: 28px;
+ font-size: 11px;
+ }
+}
+
+/* The branch's current day — the one thing someone opening this screen is looking for. */
+.bd-current {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ padding: 14px 16px;
+ margin-bottom: 14px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-left: 4px solid #f59e0b;
+ border-radius: 8px;
+
+ /* No day open at all: nothing is running, so the amber "in progress" edge would mislead. */
+ &--shut {
+ border-left-color: #94a3b8;
+ background: #f8fafc;
+ }
+
+ &__main {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ }
+
+ &__date {
+ font-size: 15px;
+ color: #0d1b2a;
+ }
+
+ .pg-btn {
+ margin-left: auto;
+ }
+}
+
+.bd-panel {
+ margin-bottom: 14px;
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+
+ /* A late close is a different act from an ordinary one, and looks like it. */
+ &--late {
+ border-left: 4px solid #dc2626;
+ }
+
+ &__wide {
+ display: block;
+ margin-top: 12px;
+ max-width: 640px;
+ }
+}
+
+/* The closing figure's arithmetic, laid out as a running sum so each line can be checked against
+ the screen it came from. */
+.bd-calc {
+ max-width: 520px;
+ margin-bottom: 14px;
+ padding: 10px 14px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 12px;
+
+ &__row {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 5px 0;
+ color: #475569;
+
+ &--in .bd-calc__amt {
+ color: #15803d;
+ }
+
+ &--out .bd-calc__amt {
+ color: #b91c1c;
+ }
+
+ &--total {
+ margin-top: 4px;
+ padding-top: 8px;
+ border-top: 2px solid #e2e8f0;
+ font-weight: 800;
+ font-size: 14px;
+ color: #0d1b2a;
+ }
+ }
+
+ &__amt {
+ font-variant-numeric: tabular-nums;
+ font-weight: 700;
+ white-space: nowrap;
+ }
+
+ &__memo {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px dashed #e2e8f0;
+ font-size: 11px;
+ color: #94a3b8;
+ }
+}
+
+.bd-hint {
+ margin: 10px 0 0;
+ font-size: 11px;
+ color: #94a3b8;
+}
+
+.bd-note {
+ max-width: 280px;
+ font-size: 11px;
+ line-height: 1.4;
+}
+
+/* The denomination grid inside the open/close panels — same table chrome as pg-card elsewhere,
+ just tightened to sit inside the panel instead of spanning the page. */
+.bd-denom {
+ margin: 4px 0 0;
+ max-width: 520px;
+
+ /* Flags a row where the hand-count no longer matches what the vault/cashier positions reported. */
+ &__auto {
+ display: block;
+ margin-top: 2px;
+ font-size: 10px;
+ white-space: nowrap;
+ }
+
+ &__reset {
+ margin-left: 10px;
+ height: 24px;
+ padding: 0 10px;
+ font-size: 11px;
+ }
+}
+
+/* Auto-fetched EOD summary: what the vault and every cashier under it currently hold. */
+.bd-eod {
+ margin: 0 0 12px;
+ padding: 10px 14px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 12px;
+
+ &__head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+
+ mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ color: #0ea5e9;
+ }
+ }
+
+ &__denoms {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin: 4px 0 8px;
+ }
+
+ &__chip {
+ padding: 2px 8px;
+ background: #f1f5f9;
+ border-radius: 999px;
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ color: #334155;
+
+ &--up {
+ background: #dcfce7;
+ color: #15803d;
+ }
+
+ &--down {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+ }
+
+ &__cashier {
+ padding: 6px 0;
+ border-top: 1px dashed #e2e8f0;
+
+ &-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ font-weight: 600;
+ color: #0d1b2a;
+ margin-bottom: 2px;
+ }
+ }
+
+ &__empty {
+ font-size: 11px;
+ }
+
+ &__status {
+ display: flex;
+ gap: 6px;
+ margin: 2px 0 6px;
+ }
+
+ &--total {
+ border-left: 4px solid #0ea5e9;
+ background: #f0f9ff;
+ }
+
+ &__totals {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 10px;
+ margin-bottom: 6px;
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+
+ &-sum {
+ font-weight: 800;
+ font-size: 15px;
+ color: #0d1b2a;
+ }
+ }
+}
+
+/* Alert shown when cashiers holding cash have not been reconciled or settled — blocks day close. */
+.bd-reconcile-block {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ margin: 14px 0;
+ padding: 12px 16px;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ border-left: 4px solid #dc2626;
+ border-radius: 8px;
+ color: #991b1b;
+ font-size: 12px;
+
+ mat-icon {
+ font-size: 20px;
+ width: 20px;
+ height: 20px;
+ color: #dc2626;
+ flex-shrink: 0;
+ margin-top: 1px;
+ }
+
+ &__list {
+ margin: 6px 0 6px 16px;
+ padding: 0;
+ list-style: disc;
+ font-size: 12px;
+ color: #b91c1c;
+ line-height: 1.6;
+ }
+}
+
+/* Amber, not red: an unreconciled cashier is now a reason to record why the day is being closed
+ rather than a refusal to close it at all. Red said "you cannot", which was true when the button
+ was disabled and misleading once a force close became possible. */
+.bd-reconcile-block--warn {
+ background: #fffbeb;
+ border-color: #fde68a;
+ border-left-color: #d97706;
+ color: #92400e;
+
+ mat-icon {
+ color: #d97706;
+ }
+}
diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts
new file mode 100644
index 000000000..da476f658
--- /dev/null
+++ b/src/app/cashier/branch-day/branch-day.component.ts
@@ -0,0 +1,717 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { MatIconModule } from '@angular/material/icon';
+import { ActivatedRoute, RouterLink } from '@angular/router';
+import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
+import { CashierService } from '../cashier.service';
+
+interface Denom {
+ id: number;
+ value: number;
+ kind: 'note' | 'coin';
+ label: string;
+}
+
+interface BranchDay {
+ id: number;
+ officeId: number;
+ officeName: string | null;
+ businessDate: string;
+ openedByName: string | null;
+ openedAt: string | null;
+ openingCash: number | null;
+ closedByName: string | null;
+ closedAt: string | null;
+ closingCash: number | null;
+ bookCash: number | null;
+ cashDifference: number | null;
+ forceClosed: boolean;
+ forceCloseReason: string | null;
+ remarks: string | null;
+ status: 'OPEN' | 'CLOSED';
+ /** Only present on the missed-close list. */
+ daysOpen?: number;
+}
+
+/**
+ * Branch day register — opening and closing the branch's business day, and the list of days that
+ * were opened and never closed.
+ *
+ * A day left open is not a tidiness problem: its closing balance never carries forward, a
+ * back-dated entry can still land in it, and any cash difference on that date goes unnoticed.
+ */
+@Component({
+ selector: 'mifosx-branch-day',
+ templateUrl: './branch-day.component.html',
+ styleUrls: [
+ '../cashier-shared.scss',
+ '../day-book/day-book.component.scss',
+ './branch-day.component.scss'
+ ],
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatIconModule,
+ RouterLink
+ ]
+})
+export class BranchDayComponent implements OnInit {
+ private service = inject(CashierService);
+ private route = inject(ActivatedRoute);
+
+ /**
+ * Set from the route: the missed-close register is the same data as the day register, shown with
+ * only the never-closed days. Two menu entries used to point at one screen, so choosing "Missed
+ * Close Register" looked like nothing had happened whenever the branch was up to date.
+ */
+ missedOnly = false;
+
+ days: BranchDay[] = [];
+ missed: BranchDay[] = [];
+ openDay: any = null;
+ today = '';
+ /** Set by the backend from the caller's roles — a cashier may read the register but not act on it. */
+ canOperate = false;
+ loading = false;
+ loaded = false;
+ errorMsg = '';
+ successMsg = '';
+ /** True only for a branch that has never opened a business day and whose Cash at Vault / Cash at
+ * Teller GL account has never had anything posted to it — Open Day here would let day-to-day cash
+ * tracking begin while the ledger silently stays wherever it already was. The backend enforces
+ * this independently too (see BranchDayRegisterApiResource#requireCashMigrationBeforeFirstEverOpen);
+ * this flag only steers the user to the right screen before they hit that rejection. */
+ firstOpenNeedsCashMigration = false;
+
+ // Open-day form
+ showOpenForm = false;
+ openDate = '';
+ openingCash: number | null = null;
+ /** Required only when the counted/typed opening figure differs from yesterday's own closing
+ * figure — the backend rejects the open without it in that case. */
+ openDifferenceReason = '';
+ opening = false;
+
+ // Close-day form. The date is fixed by which day is being closed, never typed.
+ closeTarget: BranchDay | null = null;
+ closingCash: number | null = null;
+ closeRemarks = '';
+ forceReason = '';
+ closing = false;
+
+ // Denomination breakdown, shared by both the open and close forms — a physical note/coin count
+ // is the true source of the lump-sum figure, not a typed-in guess. Counted separately per form
+ // since opening and closing are two independent physical counts.
+ notes: Denom[] = [];
+ coins: Denom[] = [];
+ openCounts: Record = {};
+ closeCounts: Record = {};
+ /** 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 = {};
+ /** 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 = {};
+
+ 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, d: Denom): number {
+ return counts[d.id] || 0;
+ }
+
+ subtotalFor(counts: Record, d: Denom): number {
+ return this.countFor(counts, d) * d.value;
+ }
+
+ onCountChange(counts: Record, d: Denom, raw: any): void {
+ counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
+ }
+
+ bumpCount(counts: Record, d: Denom, delta: number): void {
+ counts[d.id] = Math.max(0, (counts[d.id] || 0) + delta);
+ }
+
+ totalOf(counts: Record): 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) {
+ 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.checkFirstOpenCashMigration();
+ });
+
+ this.service
+ .branchDayMissed()
+ .pipe(catchError(() => of(null)))
+ .subscribe((res: any) => {
+ this.missed = res?.missed ?? [];
+ });
+ }
+
+ /**
+ * Only relevant the moment this branch has never opened a business day before — every later
+ * open/close on this screen is untouched, so there is nothing to re-check once history exists.
+ */
+ private checkFirstOpenCashMigration(): void {
+ if (this.days.length > 0 || this.openDay) {
+ this.firstOpenNeedsCashMigration = false;
+ return;
+ }
+ this.service.cashGlStatus().subscribe({
+ next: (status: any) => {
+ // configured === false means this tenant has no Cash at Vault / Cash at Teller account
+ // mapped at all — nothing for this check to reconcile against, so it does not block.
+ this.firstOpenNeedsCashMigration = status?.configured !== false && !status?.hasBalance;
+ },
+ error: () => {
+ this.firstOpenNeedsCashMigration = false;
+ }
+ });
+ }
+
+ /** True when the branch has no open day at all, so today can be opened. */
+ get canOpenToday(): boolean {
+ return this.canOperate && !this.openDay && !this.firstOpenNeedsCashMigration;
+ }
+
+ /**
+ * A still-open day's closing/book figures are dropped from the API response entirely (the
+ * backend's Gson instance omits a null map value rather than sending it as JSON null), so the
+ * field arrives as undefined, not null. Checking only `!== null` against that — which a stricter
+ * pass of this file made the mistake of doing — leaves undefined on the "has a value" branch,
+ * which then renders `'₹' + null` (the number pipe's own null output, string-concatenated) as the
+ * literal text "₹null". This checks for either, the way an open day actually shows up.
+ */
+ hasValue(value: number | null | undefined): boolean {
+ return value !== null && value !== undefined;
+ }
+
+ startOpen(): void {
+ this.showOpenForm = true;
+ this.openDate = this.today;
+ // Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure
+ // over it is how a carried balance drifts.
+ this.openingCash = null;
+ this.openCounts = {};
+ this.openCountsAuto = {};
+ this.openDifferenceReason = '';
+ this.successMsg = '';
+ this.errorMsg = '';
+ this.loadCarriedForwardDenominations();
+ }
+
+ cancelOpen(): void {
+ this.showOpenForm = false;
+ this.openCountsAuto = {};
+ }
+
+ /**
+ * Pulls yesterday's own closing note/coin count so the open form shows what is about to be
+ * carried forward instead of a blank table the caller has to trust blindly. Only the most
+ * recently closed day is used — the same day the backend itself falls back to when the caller
+ * submits no denominations and no lump sum.
+ */
+ private loadCarriedForwardDenominations(): void {
+ const lastClosed = this.days.find((d) => d.status === 'CLOSED' && d.closingCash != null);
+ if (!lastClosed) return;
+ this.service
+ .branchDayDenominations(lastClosed.officeId, lastClosed.businessDate)
+ .pipe(catchError(() => of(null)))
+ .subscribe((res: any) => {
+ const closing = res?.closing || [];
+ if (!closing.length) return;
+ const totals: Record = {};
+ 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 = {};
+ 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();
+ (morning?.denominations || []).forEach((d: any) => morningMap.set(d.denominationId, d.morningCount ?? 0));
+ return {
+ cashierId: cashier.id,
+ cashierName: cashier.staffName,
+ morningTotal: Number(morning?.totalMorning ?? 0),
+ currentTotal: Number(position?.totalAmount ?? 0),
+ reconciled: reconciliation?.reconciled ?? null,
+ alreadySettled: settlement?.alreadySettled ?? null,
+ txnCount: glEntries?.totalFilteredRecords ?? null,
+ denominations: ((position?.denominations || []) as any[]).map((d: any) => ({
+ ...d,
+ morningCount: morningMap.get(d.denominationId) ?? 0,
+ delta: d.count - (morningMap.get(d.denominationId) ?? 0)
+ }))
+ };
+ })
+ );
+ }
+
+ /** The counted figure the form would submit right now — the denomination total when any note/coin
+ * has been counted, otherwise the plain typed-in amount. */
+ get effectiveClosingFigure(): number | null {
+ return this.closeDenomTotal > 0 ? this.closeDenomTotal : this.closingCash;
+ }
+
+ /** Difference the form would record right now, so it is visible before saving. */
+ get liveDifference(): number | null {
+ if (!this.preview || this.effectiveClosingFigure == null) return null;
+ return Number(this.effectiveClosingFigure) - Number(this.preview.expectedCash ?? 0);
+ }
+
+ /** Puts the expected figure back after an edit. */
+ useExpected(): void {
+ if (this.preview?.expectedCash != null) {
+ this.closingCash = Number(this.preview.expectedCash);
+ }
+ }
+
+ cancelClose(): void {
+ this.closeTarget = null;
+ this.eodTellers = [];
+ this.closeCountsAuto = {};
+ }
+
+ /** A date earlier than today is a late close, and has to say why it was left open. */
+ get isLateClose(): boolean {
+ return !!this.closeTarget && this.closeTarget.businessDate < this.today;
+ }
+
+ /**
+ * True when any cashier who was given cash (currentTotal > 0) is still NOT reconciled
+ * or NOT settled — meaning outstanding cash hasn't been accounted for yet.
+ */
+ get hasUnreconciledOrUnsettledCashiers(): boolean {
+ for (const t of this.eodTellers) {
+ for (const c of t.cashiers) {
+ if (c.currentTotal > 0 && (c.reconciled === false || c.alreadySettled === false)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /** Names of cashiers blocking the close, for the error message. */
+ get unreconciledOrUnsettledCashierDetails(): { name: string; notReconciled: boolean; notSettled: boolean }[] {
+ const result: { name: string; notReconciled: boolean; notSettled: boolean }[] = [];
+ for (const t of this.eodTellers) {
+ for (const c of t.cashiers) {
+ if (c.currentTotal > 0 && (c.reconciled === false || c.alreadySettled === false)) {
+ result.push({
+ name: c.cashierName,
+ notReconciled: c.reconciled === false,
+ notSettled: c.alreadySettled === false
+ });
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Whether a reason has to be given before the day can close.
+ *
+ * Only for a late close. Cash still sitting with a cashier is not something a reason excuses —
+ * see canSubmitClose.
+ */
+ get needsForceReason(): boolean {
+ return this.isLateClose;
+ }
+
+ get canSubmitClose(): boolean {
+ const hasFigure = this.closingCash != null || this.closeDenomTotal > 0;
+ if (!this.closeTarget || !hasFigure || this.closing) return false;
+ // Block while cashier/vault data is still loading — the reconciliation check can't run yet.
+ if (this.eodLoading || this.previewLoading) return false;
+ // An unreconciled cashier is a reason-required condition, not an absolute block: the backend
+ // accepts the close once a reason is recorded (and appends the cashiers' names to it), and
+ // refusing outright left a day that could never be closed at all — a cashier who reconciled on
+ // the wrong date, went on leave, or left mid-shift would strand that day and every day after it.
+ if (this.isLateClose || this.hasUnreconciledOrUnsettledCashiers) {
+ return !!this.forceReason.trim();
+ }
+ return true;
+ }
+
+ submitClose(): void {
+ if (this.eodLoading || this.previewLoading) {
+ this.errorMsg = 'Still loading the cashier and vault positions — try again in a moment.';
+ return;
+ }
+ // Safety net mirroring canSubmitClose: never send a close that the backend will refuse for a
+ // missing override reason.
+ if ((this.isLateClose || this.hasUnreconciledOrUnsettledCashiers) && !this.forceReason.trim()) {
+ this.errorMsg = 'Record why this day is being closed before submitting.';
+ return;
+ }
+ const day = this.closeTarget;
+ const denominations = this.denominationPayload(this.closeCounts);
+ const figure = denominations.length ? this.closeDenomTotal : this.closingCash;
+ if (!day || figure == null) return;
+
+ this.closing = true;
+ this.errorMsg = '';
+ this.service
+ .closeBranchDay(
+ day.businessDate,
+ Number(figure),
+ this.closeRemarks.trim() || undefined,
+ // Sent whenever one was typed, not only on a late close: it is also the override the backend
+ // needs for a same-day close over an unreconciled cashier, and gating it on isLateClose
+ // dropped it on exactly the request that could not succeed without it.
+ this.forceReason.trim() || undefined,
+ undefined,
+ denominations
+ )
+ .pipe(
+ catchError((err: any) => {
+ this.errorMsg =
+ err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the day.';
+ return of(null);
+ })
+ )
+ .subscribe((res: any) => {
+ this.closing = false;
+ if (res) {
+ this.closeTarget = null;
+ const diff = Number(res.cashDifference ?? 0);
+ this.successMsg =
+ diff === 0
+ ? `Business day ${res.businessDate} closed — cash tallied.`
+ : `Business day ${res.businessDate} closed with a difference of ₹${diff.toFixed(2)}.`;
+ this.load();
+ }
+ });
+ }
+
+ absValue(n: number): number {
+ return Math.abs(n);
+ }
+
+ /** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */
+ differenceLabel(d: number | null): string {
+ if (d == null) return '—';
+ const n = Number(d);
+ if (n === 0) return 'Tallied';
+ return n > 0 ? `Excess ₹${Math.abs(n).toFixed(2)}` : `Short ₹${Math.abs(n).toFixed(2)}`;
+ }
+
+ stamp(iso: string | null): string {
+ if (!iso) return '—';
+ const [
+ d,
+ t
+ ] = iso.split('T');
+ return t ? `${d} ${t.slice(0, 5)}` : d;
+ }
+
+ timeOnly(iso: string | null): string {
+ if (!iso) return '—';
+ const t = iso.split('T')[1];
+ return t ? t.slice(0, 5) : '—';
+ }
+}
diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.html b/src/app/cashier/cash-transfer/cash-transfer.component.html
index 82cb5f963..4c09120a9 100644
--- a/src/app/cashier/cash-transfer/cash-transfer.component.html
+++ b/src/app/cashier/cash-transfer/cash-transfer.component.html
@@ -50,7 +50,7 @@
Date
-
+
@@ -59,11 +59,28 @@
+
+ @if (fromTellerId && !loadingVault) {
+ @if (vaultHasBreakdown) {
+
+ account_balance
+ Vault available: ₹{{ vaultTotalAvailable | number: '1.2-2' }}
+
+ } @else {
+
+ warning
+ No denomination breakdown recorded for this vault — per-note availability can't be checked, only the total
+ opening balance.
+
+ }
+ }
+
Denomination
+ Available
Count
Amount
@@ -71,14 +88,17 @@
@if (notes.length) {
- Bank Notes
+ Bank Notes
}
@for (d of notes; track d.id) {
-
+
{{ d.label }}
+
+ {{ vaultHasBreakdown ? availableFor(d) : '—' }}
+
−
@@ -91,14 +111,17 @@
}
@if (coins.length) {
- Coins
+ Coins
}
@for (d of coins; track d.id) {
-
+
{{ d.label }}
+
+ {{ vaultHasBreakdown ? availableFor(d) : '—' }}
+
−
@@ -111,13 +134,24 @@
}
@if (denoms.length === 0) {
- No active denominations. Add some in Cash Denomination.
+ No active denominations. Add some in Cash Denomination.
}
+ @if (anyDenomExceedsAvailable) {
+
+ One or more notes/coins above are counted higher than the vault currently holds — reduce them before recording the
+ transfer.
+
+ } @else if (exceedsVaultTotal) {
+
+ Total exceeds what the vault has available (₹{{ vaultTotalAvailable | number: '1.2-2' }}).
+
+ }
+
Total to transfer:
₹{{ total | number: '1.2-2' }}
diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.scss b/src/app/cashier/cash-transfer/cash-transfer.component.scss
new file mode 100644
index 000000000..78dfe9ead
--- /dev/null
+++ b/src/app/cashier/cash-transfer/cash-transfer.component.scss
@@ -0,0 +1,51 @@
+/**
+ * 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;
+}
diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.ts b/src/app/cashier/cash-transfer/cash-transfer.component.ts
index 84f6ad450..fa5de03f9 100644
--- a/src/app/cashier/cash-transfer/cash-transfer.component.ts
+++ b/src/app/cashier/cash-transfer/cash-transfer.component.ts
@@ -29,7 +29,10 @@ interface Denom {
@Component({
selector: 'mifosx-cash-transfer',
templateUrl: './cash-transfer.component.html',
- styleUrls: ['../cashier-shared.scss'],
+ styleUrls: [
+ '../cashier-shared.scss',
+ './cash-transfer.component.scss'
+ ],
standalone: true,
imports: [
CommonModule,
@@ -70,6 +73,14 @@ 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 = {};
+ 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')}`;
@@ -101,7 +112,8 @@ export class CashTransferComponent implements OnInit {
});
}
- /** When a From-teller is chosen, load ONLY that teller's cashiers into the To dropdown. */
+ /** 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. */
onTellerSelect(tellerId: number): void {
this.fromTellerId = tellerId;
const teller = this.tellers.find((t) => t.id === tellerId);
@@ -109,6 +121,7 @@ export class CashTransferComponent implements OnInit {
this.toParty = '';
this.toCashierId = null;
this.cashiers = [];
+ this.loadVaultAvailability();
if (!tellerId) {
return;
}
@@ -125,6 +138,45 @@ 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 = {};
+ ((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;
@@ -152,8 +204,24 @@ 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;
+ return (
+ this.total > 0 &&
+ !!this.fromTellerId &&
+ !!this.toCashierId &&
+ !this.anyDenomExceedsAvailable &&
+ !this.exceedsVaultTotal
+ );
}
clear(): void {
diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts
index f5efc11bd..045252339 100644
--- a/src/app/cashier/cashier-routing.module.ts
+++ b/src/app/cashier/cashier-routing.module.ts
@@ -19,9 +19,13 @@ import { InterCashierTransferComponent } from './inter-cashier-transfer/inter-ca
import { CashierPositionComponent } from './cashier-position/cashier-position.component';
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { CashHistoryComponent } from './cash-history/cash-history.component';
+import { DayBookComponent } from './day-book/day-book.component';
+import { VoucherReportComponent } from './voucher-report/voucher-report.component';
+import { BranchDayComponent } from './branch-day/branch-day.component';
+import { VaultPositionComponent } from './vault-position/vault-position.component';
+import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
-import { ShareCollectionComponent } from './share-collection/share-collection.component';
const routes: Routes = [
Route.withShell([
@@ -60,7 +64,7 @@ const routes: Routes = [
import('../clerk/my-disbursement-requests/my-disbursement-requests.component').then(
(m) => m.MyDisbursementRequestsComponent
),
- data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
+ data: { title: 'Disbursements & Transfer', breadcrumb: 'Disbursements & Transfer' }
},
{
path: 'share-withdrawal-request',
@@ -78,6 +82,14 @@ 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: () =>
@@ -104,6 +116,59 @@ const routes: Routes = [
import('../clerk/photo-signature/photo-signature.component').then((m) => m.PhotoSignatureComponent),
data: { title: 'Photo & Signature', breadcrumb: 'Photo & Signature' }
},
+ // The same register the cashier screens use, without their cash-only filter: a clerk raises
+ // book entries — a closure settled to savings, a share redemption, an interest posting the
+ // system made on their approval — and cash-only would hide exactly those.
+ {
+ path: 'received-report',
+ component: VoucherReportComponent,
+ data: {
+ title: 'Received Vouchers',
+ breadcrumb: 'Received Vouchers',
+ side: 'CR',
+ cashOnly: false,
+ cashiersOnly: true
+ }
+ },
+ {
+ path: 'payment-report',
+ component: VoucherReportComponent,
+ data: {
+ title: 'Payment Vouchers',
+ breadcrumb: 'Payment Vouchers',
+ side: 'DR',
+ cashOnly: false,
+ cashiersOnly: true
+ }
+ },
+ // Both sides in one list, scoped to whoever is signed in — the "what did I do today"
+ // question the two registers above cannot answer, because an approval raises no voucher.
+ {
+ path: 'my-work',
+ loadComponent: () => import('../cashier/my-work/my-work.component').then((m) => m.MyWorkComponent),
+ data: { title: 'My Vouchers', breadcrumb: 'My Vouchers' }
+ },
+ {
+ path: 'end-of-day',
+ loadComponent: () =>
+ import('../cashier/end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent),
+ data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' }
+ },
+ // The system's own postings — interest, charges, escheats, journals. Both sides in one
+ // list: a night's run credits and debits, and splitting it across two screens would hide
+ // half of it from whichever one you opened.
+ {
+ path: 'system-vouchers',
+ component: VoucherReportComponent,
+ data: {
+ title: 'System Vouchers',
+ breadcrumb: 'System Vouchers',
+ side: 'ALL',
+ cashOnly: false,
+ cashiersOnly: false,
+ systemOnly: true
+ }
+ },
{
path: 'standing-instructions',
loadComponent: () =>
@@ -128,6 +193,57 @@ const routes: Routes = [
}
]
},
+ {
+ // Branch-level operations, kept out of the cashier tree on purpose: opening and closing the
+ // day governs the whole branch, and is not something a cashier does.
+ path: 'branch-ops',
+ data: { title: 'Branch Operations', breadcrumb: 'Branch Operations' },
+ children: [
+ {
+ path: 'day-register',
+ component: BranchDayComponent,
+ data: { title: 'Day Open / Day Close', breadcrumb: 'Day Register' }
+ },
+ {
+ // Same screen, narrowed to the days that were never closed. A separate route rather than
+ // a second link to the register, so choosing it visibly does something.
+ path: 'missed-close',
+ component: BranchDayComponent,
+ data: { title: 'Missed Close Register', breadcrumb: 'Missed Close', missedOnly: true }
+ },
+ {
+ // Lives here alongside the day register because both answer "what did this branch look
+ // like on that date"; it is linked from the Reports menu, where people go to find it.
+ path: 'account-register',
+ component: AccountRegisterComponent,
+ data: { title: 'Account Register', breadcrumb: 'Account Register' }
+ },
+ {
+ // Same branch-level scope as the day register: this is the vault's own stock, not any
+ // one cashier's float, so it sits with the other branch-wide screens.
+ path: 'vault-position',
+ component: VaultPositionComponent,
+ data: { title: 'Vault Position', breadcrumb: 'Vault Position' }
+ },
+ {
+ // The same day book the clerk and cashier read, reached from where a principal officer
+ // signing the day off actually looks. It carries the Officer Verification Sheet — each
+ // person's own credit, debit and net — which is the branch-level reading of the day, not
+ // a counter one.
+ path: 'day-book',
+ component: DayBookComponent,
+ data: { title: 'Day Book & Verification', breadcrumb: 'Day Book' }
+ },
+ // The one sheet the day is signed off on: the transaction summary, the drawer counted
+ // against the books, and the notes that say why it cannot be closed yet.
+ {
+ path: 'end-of-day',
+ loadComponent: () =>
+ import('./end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent),
+ data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' }
+ }
+ ]
+ },
{
path: 'cashier',
data: { title: 'Cashier', breadcrumb: 'Cashier' },
@@ -172,21 +288,62 @@ const routes: Routes = [
component: CashHistoryComponent,
data: { title: 'Cash History', breadcrumb: 'Cash History' }
},
+ {
+ path: 'day-book',
+ component: DayBookComponent,
+ data: { title: 'Day Book', breadcrumb: 'Day Book' }
+ },
+ // The register is read one side at a time: Received is the CR side, Payment the DR side.
+ // Same component both times — the side it shows comes from `data.side`.
+ //
+ // `cashOnly` is what makes these the cashier's own registers rather than the whole voucher
+ // book: a cashier deals in notes and coins, so only vouchers that actually moved cash
+ // belong here, and the two reports then reconcile against the drawer and the closing
+ // report. A back-office screen wanting every voucher points at the same component without
+ // the flag.
+ {
+ path: 'received-report',
+ component: VoucherReportComponent,
+ data: {
+ title: 'Received Report',
+ breadcrumb: 'Received Report',
+ side: 'CR',
+ cashOnly: true,
+ cashiersOnly: true
+ }
+ },
+ {
+ path: 'payment-report',
+ component: VoucherReportComponent,
+ data: {
+ title: 'Payment Report',
+ breadcrumb: 'Payment Report',
+ side: 'DR',
+ cashOnly: true,
+ cashiersOnly: true
+ }
+ },
+ {
+ path: 'my-work',
+ loadComponent: () => import('./my-work/my-work.component').then((m) => m.MyWorkComponent),
+ data: { title: 'My Vouchers', breadcrumb: 'My Vouchers' }
+ },
{
path: 'closing-report',
component: ClosingCashReportComponent,
data: { title: 'Closing Cash Report', breadcrumb: 'Closing Cash Report' }
},
+ {
+ path: 'end-of-day',
+ loadComponent: () =>
+ import('./end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent),
+ data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' }
+ },
{
path: 'excess-report',
component: ExcessTransactionReportComponent,
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
},
- {
- path: 'share-collection',
- component: ShareCollectionComponent,
- data: { title: 'Share Collection', breadcrumb: 'Share Collection' }
- },
{
path: 'transaction-entry',
canActivate: [CashierOnlyGuard],
diff --git a/src/app/cashier/cashier-shared.scss b/src/app/cashier/cashier-shared.scss
index 6985025e4..58ac3ab4b 100644
--- a/src/app/cashier/cashier-shared.scss
+++ b/src/app/cashier/cashier-shared.scss
@@ -206,6 +206,7 @@
border-radius: 6px;
font-size: 12px;
font-weight: 700;
+ white-space: nowrap;
background: linear-gradient(135deg, #ecfeff, #cffafe);
color: #155e75;
border: 1px solid #a5f3fc;
@@ -311,6 +312,47 @@
font-variant-numeric: tabular-nums;
color: #0f172a;
}
+
+ // Names the part of a total that has no row in the table below, so the figures do not read as
+ // failing to add up.
+ &__note {
+ margin-top: 2px;
+ font-size: 10px;
+ line-height: 1.4;
+ color: #64748b;
+ }
+
+ // The drawer strip sits under the counter totals and is the figure a physical count is checked
+ // against, so it is marked out as the one to read rather than a second row of the same thing.
+ &--balance {
+ margin-top: 10px;
+ border-color: #cbd5e1;
+ box-shadow: 0 1px 3px rgb(15 23 42 / 8%);
+ }
+}
+
+.closing-note {
+ margin: 8px 0 0;
+ font-size: 11px;
+ line-height: 1.5;
+ color: #64748b;
+}
+
+// Says what a row without a denomination is, next to its chip.
+.closing-row__hint {
+ margin-left: 6px;
+ font-size: 11px;
+ color: #64748b;
+}
+
+// A totals row, marked off from the rows it sums. Defined here rather than only in the day book's
+// own stylesheet, which the other cashier screens do not load.
+.pg-table tfoot td {
+ background: #f8fafc;
+ font-weight: 800;
+ font-size: 12px;
+ color: #334155;
+ border-top: 1px solid #e2e8f0;
}
.pg-step {
@@ -672,3 +714,15 @@
}
}
}
+
+/* Names a figure strip before the reader meets it. A run of tiles ending in a total reads as
+ arithmetic to somebody who already knows the screen and as five unrelated numbers to somebody
+ who does not. */
+.closing-lede {
+ margin: 0 0 -4px;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #475569;
+}
diff --git a/src/app/cashier/cashier.module.ts b/src/app/cashier/cashier.module.ts
index 2cf93e092..ec6dcfeeb 100644
--- a/src/app/cashier/cashier.module.ts
+++ b/src/app/cashier/cashier.module.ts
@@ -20,7 +20,6 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
import { CashHistoryComponent } from './cash-history/cash-history.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
-import { ShareCollectionComponent } from './share-collection/share-collection.component';
@NgModule({
imports: [
@@ -35,8 +34,7 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co
ReconciliationComponent,
CashHistoryComponent,
ClosingCashReportComponent,
- ExcessTransactionReportComponent,
- ShareCollectionComponent
+ ExcessTransactionReportComponent
]
})
export class CashierModule {}
diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts
index 77f3af681..3078701f8 100644
--- a/src/app/cashier/cashier.service.ts
+++ b/src/app/cashier/cashier.service.ts
@@ -37,8 +37,10 @@ export class CashierService {
}
// ── Tellers / Cashiers ──
- getTellers(): Observable {
- return this.http.get('/tellers');
+ getTellers(officeId?: number): Observable {
+ let params = new HttpParams();
+ if (officeId) params = params.set('officeId', String(officeId));
+ return this.http.get('/tellers', { params });
}
getAllCashiers(): Observable {
return this.http.get('/cashiers');
@@ -113,6 +115,192 @@ 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 {
+ let params = new HttpParams().set('date', date);
+ if (side) params = params.set('side', side);
+ if (status) params = params.set('status', status);
+ return this.http.get('/cashreports/day-book', { params });
+ }
+
+ /**
+ * The vouchers raised over a period, one row each, with what was debited and what credited.
+ *
+ * Who appears is decided by the server from the caller's own roles — a cashier sees only their
+ * own work whatever they ask for — so `username` is a filter for those allowed to use it, not a
+ * way to choose whose report this is.
+ */
+ voucherReport(
+ from: string,
+ to: string,
+ username?: string,
+ voucherType?: string,
+ cashOnly?: boolean,
+ cashiersOnly?: boolean,
+ systemOnly?: boolean
+ ): Observable {
+ let params = new HttpParams().set('from', from).set('to', to);
+ if (username) {
+ params = params.set('username', username);
+ }
+ if (voucherType) {
+ params = params.set('voucherType', voucherType);
+ }
+ // Only sent when asked for: the server treats a missing flag as the full register, so the
+ // cashier screens opt in to cash and the back-office ones keep seeing everything.
+ if (cashOnly) {
+ params = params.set('cashOnly', 'true');
+ }
+ // Opt-in the same way: the counter registers ask for the drawer-holders only, the back-office
+ // ones keep seeing whoever raised a voucher.
+ if (cashiersOnly) {
+ params = params.set('cashiersOnly', 'true');
+ }
+ // The postings nobody filed a request for: interest a nightly run credited, a charge taken from
+ // a balance, an escheat, a journal keyed to the ledger. Nowhere else shows them — My Vouchers is
+ // the reader's own work and a scheduled job is nobody's.
+ if (systemOnly) {
+ params = params.set('systemOnly', 'true');
+ }
+ return this.http.get('/vouchers/report', { params });
+ }
+
+ /**
+ * The signed-in user's own day: every voucher they raised and every one they verified, receipts
+ * and payments in one list. Always scoped to the caller by the server — there is no username
+ * parameter, because "my work" for somebody else is the register's job, not this screen's.
+ */
+ myVoucherWork(from: string, to: string): Observable {
+ const params = new HttpParams().set('from', from).set('to', to);
+ return this.http.get('/vouchers/my-work', { params });
+ }
+
+ /** One voucher and every debit/credit line it is made of — the full slip behind a summarised
+ * row (a "+2 more" badge in a register only ever shows the first side of each). */
+ voucherDetail(voucherNo: string): Observable {
+ const params = new HttpParams().set('voucherNo', voucherNo);
+ return this.http.get('/vouchers/detail', { params });
+ }
+
+ /**
+ * Account register for one date: what opened and closed that day, each account's balance as it
+ * stood then, and what it holds now.
+ */
+ accountRegister(date: string, section?: string): Observable {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
@@ -176,16 +364,77 @@ export class CashierService {
return this.http.get('/paymenttypes');
}
- // ── Share Collection ──
- getShareAccountForCollection(accountId: string): Observable {
- return this.http.get(`/accounts/share/${accountId}`);
+ /** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
+ getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable {
+ // 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 });
}
- collectSharePayment(data: any): Observable {
- return this.http.post('/cashier/sharecollection', data);
+ /** GL accounts selectable when searching for a voucher, for the picker on the Apply Collection screen. */
+ getShareAdvanceGlAccounts(): Observable {
+ return this.http.get('/shareadvancecollection/glaccounts');
}
- getShareCollectionStatus(shareAccountId: number): Observable {
- return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
+ /** Share value + any outstanding entry fee for one share account, shown before a voucher search. */
+ getShareAdvanceRequirement(shareAccountId: number): Observable {
+ return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
+ }
+
+ /**
+ * Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
+ * ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
+ * deposits and receipts linked to another member — which attach by voucher instead.
+ *
+ * One request rather than two: both lists come from the same table under the same filters, and
+ * splitting them across endpoints only meant the same rows were fetched twice and, where they
+ * overlapped, drawn twice on the same screen.
+ */
+ getShareCollectionReceipts(clientId?: number): Observable {
+ const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined;
+ return this.http.get('/shareadvancecollection/unattributed', { params });
+ }
+
+ /** Moves a receipt out of Sundry into share capital for an activated account. */
+ applyShareAdvance(collectionId: number, data: any): Observable {
+ return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
+ }
+
+ /** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
+ * which is unrelated screen state that can name a different account than this receipt sits in. */
+ getShareAdvanceGlEntries(collectionId: number): Observable {
+ return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
+ }
+
+ /** Every additional-share tranche awaiting approval, across every share account. */
+ getShareAdditionRequests(): Observable {
+ 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 {
+ return this.http.get(`/shareadditionrequests/${transactionId}`);
+ }
+
+ /**
+ * Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
+ * "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
+ * without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
+ * options should still be usable for every other field.
+ */
+ getEntryTypeOptions(): Observable {
+ return this.http.get('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
}
}
diff --git a/src/app/cashier/clerk-closure-request.service.ts b/src/app/cashier/clerk-closure-request.service.ts
new file mode 100644
index 000000000..2798e013c
--- /dev/null
+++ b/src/app/cashier/clerk-closure-request.service.ts
@@ -0,0 +1,80 @@
+/**
+ * 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 {
+ return this.http.post('/clerk/closure-requests', data);
+ }
+
+ getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable {
+ 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 {
+ 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 {
+ return this.http.get(`/clerk/closure-requests/${id}`);
+ }
+
+ /** Posts every rupee of interest the member has earned up to today straight onto the account —
+ * the same posting close() itself performs — and returns the resulting real balance. Not a dry
+ * run: ticking "Close this account" is meant to credit the interest immediately, not estimate it. */
+ postClosureInterest(savingsAccountId: number): Observable {
+ return this.http.post(`/clerk/closure-requests/post-interest/${savingsAccountId}`, {});
+ }
+
+ approve(id: number): Observable {
+ return this.http.post(`/clerk/closure-requests/${id}/approve`, {});
+ }
+
+ reject(id: number, reason: string): Observable {
+ 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 {
+ 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 {
+ return this.http.post(`/clerk/closure-requests/${id}/disburse`, {
+ denominationData,
+ transactionAmount: transactionAmount != null ? String(transactionAmount) : null
+ });
+ }
+}
diff --git a/src/app/cashier/clerk-share-withdrawal.service.ts b/src/app/cashier/clerk-share-withdrawal.service.ts
index fde90ad02..ff93d4294 100644
--- a/src/app/cashier/clerk-share-withdrawal.service.ts
+++ b/src/app/cashier/clerk-share-withdrawal.service.ts
@@ -28,10 +28,18 @@ export class ClerkShareWithdrawalService {
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
}
- getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable {
+ getPendingRequests(
+ status = 'PENDING',
+ offset = 0,
+ limit = 20,
+ fromDate?: string,
+ toDate?: string,
+ search?: string
+ ): Observable {
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 });
}
diff --git a/src/app/cashier/clerk-withdrawal.service.ts b/src/app/cashier/clerk-withdrawal.service.ts
index c137da4b4..391b4f5d7 100644
--- a/src/app/cashier/clerk-withdrawal.service.ts
+++ b/src/app/cashier/clerk-withdrawal.service.ts
@@ -32,10 +32,18 @@ export class ClerkWithdrawalService {
return this.http.get('/clerk/withdrawal-requests/my', { params });
}
- getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable {
+ getPendingRequests(
+ status = 'PENDING',
+ offset = 0,
+ limit = 20,
+ fromDate?: string,
+ toDate?: string,
+ search?: string
+ ): Observable {
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 });
}
diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.html b/src/app/cashier/closing-cash-report/closing-cash-report.component.html
index 1ae44ef03..c964fd540 100644
--- a/src/app/cashier/closing-cash-report/closing-cash-report.component.html
+++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.html
@@ -9,9 +9,8 @@
- @if (!showGl && rows.length > 0) {
-
-
+
+ @if (!showGl && scopedToCashier && hasTodayLegs) {
+
+
Your cash for the day, step by step
+
- Total Deposits (IN)
- ₹{{ depositTotal | number: '1.2-2' }}
+ Cash you got this morning
+ ₹{{ todayVaultIn | number: '1.2-2' }}
+
+
+
+
+ Cash customers gave you
+ ₹{{ depositTotalWithDd | number: '1.2-2' }}
+ @if (ddCashTotal > 0) {
+
+
+ ₹{{ ddCashTotal | number: '1.2-2' }} of this was for {{ ddCashCount }} draft{{
+ ddCashCount === 1 ? '' : 's'
+ }}, notes not listed
+
+ }
−
- Total Withdrawals (OUT)
+ Cash you gave customers
₹{{ withdrawalTotal | number: '1.2-2' }}
+
−
+
+ Cash already returned
+ ₹{{ todaySettledOut | number: '1.2-2' }}
+
=
+
= 0"
- [class.closing-summary__item--net-neg]="netTotal < 0"
+ [class.closing-summary__item--net-pos]="expectedInDrawer >= 0"
+ [class.closing-summary__item--net-neg]="expectedInDrawer < 0"
>
- Net Closing Cash
- ₹{{ netTotal | number: '1.2-2' }}
+ Cash left with you now
+ ₹{{ expectedInDrawer | number: '1.2-2' }}
+
+ Count your cash now — it should come to ₹{{ expectedInDrawer | number: '1.2-2' }}. Hand that amount back and
+ your day is done.
+
+
+ @if ((todayInterCashierIn ?? 0) > 0 || (todayInterCashierOut ?? 0) > 0) {
+
+ This already counts cash swapped with another cashier — ₹{{ todayInterCashierIn | number: '1.2-2' }} taken
+ from them, ₹{{ todayInterCashierOut | number: '1.2-2' }} given to them.
+
+ }
}
@if (!showGl) {
- @if (noteRows.length > 0) {
+ @if (noteRows.length > 0 || ddCashTotal > 0) {
Notes
- Denomination
- Deposit Pcs
- Deposit Amt
- Withdrawal Pcs
- Withdrawal Amt
- Net Pcs
- Net Amount
+ Note
+
+ Came in
+ Went out
+ Left with you
+ Worth
@@ -73,15 +120,16 @@
₹{{ r.denominationValue }}
- {{ r.depositCount }}
- ₹{{ r.depositAmount | number: '1.2-2' }}
- {{ r.withdrawalCount }}
- ₹{{ r.withdrawalAmount | number: '1.2-2' }}
+
+ {{ r.vaultInCount + r.depositCount || '—' }}
+ {{ r.withdrawalCount + r.settledOutCount || '—' }}
0 ? '#16a34a' : r.netCount < 0 ? '#dc2626' : '#94a3b8'"
>
- {{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }}
+ {{ r.netCount }}
}
+
+
+ @if (ddCashTotal > 0) {
+
+
+ Notes not listed
+ {{ ddCashCount }} cash DD{{ ddCashCount === 1 ? '' : 's' }}
+
+ —
+ —
+ —
+ ₹{{ ddCashTotal | number: '1.2-2' }}
+
+ }
+
+
+
+ Total
+ {{ noteVaultInCount + noteDepositCount || '—' }}
+ {{ noteWithdrawalCount + noteSettledOutCount || '—' }}
+ {{ noteNetCount }}
+
+ {{ noteNetAmount >= 0 ? '' : '−' }}₹{{
+ (noteNetAmount < 0 ? -noteNetAmount : noteNetAmount) | number: '1.2-2'
+ }}
+
+
+
}
@@ -102,13 +181,11 @@
- Denomination
- Deposit Pcs
- Deposit Amt
- Withdrawal Pcs
- Withdrawal Amt
- Net Pcs
- Net Amount
+ Coin
+ Came in
+ Went out
+ Left with you
+ Worth
@@ -117,15 +194,16 @@
₹{{ r.denominationValue }}
- {{ r.depositCount }}
- ₹{{ r.depositAmount | number: '1.2-2' }}
- {{ r.withdrawalCount }}
- ₹{{ r.withdrawalAmount | number: '1.2-2' }}
+
+ {{ r.vaultInCount + r.depositCount || '—' }}
+ {{ r.withdrawalCount + r.settledOutCount || '—' }}
0 ? '#16a34a' : r.netCount < 0 ? '#dc2626' : '#94a3b8'"
>
- {{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }}
+ {{ r.netCount }}
}
- @if (loaded && rows.length === 0 && exchangeRows.length === 0) {
- No cash denomination data for this date.
+
+ @if (loaded && rows.length === 0 && exchangeRows.length === 0 && ddCashTotal === 0) {
+
+
+ @if (scopedToCashier && hasTodayLegs && expectedInDrawer !== 0) {
+ No cash crossed the counter on this date. The drawer should hold ₹{{ expectedInDrawer | number: '1.2-2' }}
+ from the day's vault and settlement legs.
+ } @else {
+ No cash denomination data for this date.
+ }
+
}
}
@@ -213,8 +303,8 @@
Voucher No
Date
- A/C No
- A/C Name
+ Debit
+ Credit
Payment Type
Teller
DR (₹)
@@ -227,9 +317,8 @@
@if (r.txnSource === 'GL') {
GL
- } @else {
- {{ r.voucherNo }}
}
+ {{ r.voucherNo }}
{{ r.txnDate }}
@@ -237,8 +326,25 @@
{{ r.txnTime }}
}
- {{ r.acNo }}
- {{ r.acName }}
+
+
+ {{ r.debitParty || r.acName }}
+ @if (r.debitAccount || r.acNo) {
+ {{ r.debitAccount || r.acNo }}
+ }
+
+
+ @if (r.creditParty) {
+ {{ r.creditParty }}
+ @if (r.creditAccount) {
+ {{ r.creditAccount }}
+ }
+ } @else {
+ —
+ }
+
@if (r.paymentType) {
{{ r.paymentType }}
diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts
index 2b0e0a183..929a2af27 100644
--- a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts
+++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts
@@ -38,6 +38,51 @@ export class ClosingCashReportComponent implements OnInit {
loading = false;
loaded = false;
+ /**
+ * Cash taken at the counter for a Demand Draft.
+ *
+ * A cash DD asks for a denomination breakdown but does not require one, and this report is built
+ * entirely from breakdowns — so that cash was missing from the drawer figure even though the
+ * ledger held it and the cashier held the notes. It is counted from the request itself and shown
+ * on its own line, because the denomination rows below cannot account for it.
+ */
+ ddCashTotal = 0;
+ ddCashCount = 0;
+ /** Deposits including the DD cash — the figure the drawer is actually checked against. */
+ depositTotalWithDd = 0;
+
+ /**
+ * The drawer, opening to closing.
+ *
+ * `netTotal` above is only the day's counter movement — deposits less withdrawals. It is not what
+ * the cashier is holding, because it carries nothing forward from yesterday and ignores vault
+ * allocations and inter-cashier legs. The closing balance is the figure that has to match a
+ * physical count at the end of the day, so it is reported separately rather than inferred from
+ * the counter totals.
+ *
+ * Null when the server did not scope the report to one cashier: a branch-wide opening balance is
+ * the branch day register's business, not this report's.
+ */
+ openingBalance: number | null = null;
+ closingBalance: number | null = null;
+
+ /**
+ * The day's own cash legs, which is what a cashier reconciles against the drawer.
+ *
+ * A cashier carries no balance: they draw from the vault in the morning and hand back at close.
+ * The cumulative opening/closing figures answer a question nobody at the counter asks, and one
+ * carrying years of history answers it with a number that cannot be counted against anything on
+ * the desk.
+ */
+ todayVaultIn: number | null = null;
+ todaySettledOut: number | null = null;
+ todayInterCashierIn: number | null = null;
+ todayInterCashierOut: number | null = null;
+ /** Every leg of the day's cash movement, which is wider than deposits less withdrawals. */
+ cashMovement: number | null = null;
+ /** Whether the figures above are one cashier's drawer or the whole branch's counter work. */
+ scopedToCashier = false;
+
// GL View
showGl = false;
glRows: any[] = [];
@@ -63,9 +108,91 @@ export class ClosingCashReportComponent implements OnInit {
selectedTellerId: number | null = null;
selectedCashierId: number | null = null;
+ /**
+ * What should be in the drawer at the end of the day, from the day's own legs.
+ *
+ * Drawn from the vault, plus anything taken over the counter and from another cashier, less what
+ * went back out. This is the figure a cashier counts against — it does not carry a balance
+ * forward, because a cashier does not.
+ */
+ get expectedInDrawer(): number {
+ return (
+ Number(this.todayVaultIn ?? 0) +
+ Number(this.todayInterCashierIn ?? 0) +
+ Number(this.depositTotalWithDd ?? 0) -
+ Number(this.withdrawalTotal ?? 0) -
+ Number(this.todaySettledOut ?? 0) -
+ Number(this.todayInterCashierOut ?? 0)
+ );
+ }
+
+ /** True when any of the day's own legs carries a figure, so the panel is worth showing. */
+ get hasTodayLegs(): boolean {
+ return this.todayVaultIn !== null;
+ }
+
get noteRows(): any[] {
return this.rows.filter((r) => r.kind !== 'coin');
}
+
+ /**
+ * Totals for the notes table, including the un-itemised Demand Draft cash.
+ *
+ * The DD row carries an amount but no piece count, so pieces and amounts are summed separately
+ * rather than one being derived from the other — a denomination times a count would not reach the
+ * DD's figure, and adding a fictional count to make it balance would misstate the drawer.
+ */
+ get noteDepositCount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.depositCount ?? 0), 0);
+ }
+
+ get noteDepositAmount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.depositAmount ?? 0), 0) + this.ddCashTotal;
+ }
+
+ get noteWithdrawalCount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.withdrawalCount ?? 0), 0);
+ }
+
+ get noteWithdrawalAmount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.withdrawalAmount ?? 0), 0);
+ }
+
+ get noteVaultInCount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.vaultInCount ?? 0), 0);
+ }
+
+ get noteVaultInAmount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.vaultInAmount ?? 0), 0);
+ }
+
+ get noteSettledOutCount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.settledOutCount ?? 0), 0);
+ }
+
+ get noteSettledOutAmount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
+ }
+
+ /**
+ * Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount`
+ * already carries forward what a cashier was holding from before today (`openingCount`), for the
+ * force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/
+ * withdrawal/settledOut dropped that carry-forward and put the Total row back into negative
+ * numbers a cashier had already reconciled.
+ */
+ get noteNetCount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0);
+ }
+
+ /**
+ * What is left in the drawer in notes: everything that came in, less everything that went out —
+ * including cash carried forward from before today. See `noteNetCount` for why this sums each
+ * row's own `netAmount` rather than re-deriving it from today's legs.
+ */
+ get noteNetAmount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0);
+ }
get coinRows(): any[] {
return this.rows.filter((r) => r.kind === 'coin');
}
@@ -84,7 +211,6 @@ export class ClosingCashReportComponent implements OnInit {
}
load(): void {
- this.showGl = false;
this.loading = true;
this.service.closingCashReport(this.date).subscribe({
next: (res: any) => {
@@ -93,6 +219,19 @@ export class ClosingCashReportComponent implements OnInit {
this.depositTotal = Number(res?.depositTotal || 0);
this.withdrawalTotal = Number(res?.withdrawalTotal || 0);
this.netTotal = Number(res?.netTotal || 0);
+ this.ddCashTotal = Number(res?.ddCashTotal || 0);
+ this.ddCashCount = Number(res?.ddCashCount || 0);
+ this.depositTotalWithDd = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0);
+ this.scopedToCashier = res?.scopedToCashier === true;
+ // Sent only for a cashier-scoped call, and read as absent rather than zero when missing —
+ // a zero opening balance and no opening balance at all are different statements.
+ this.openingBalance = res?.openingBalance == null ? null : Number(res.openingBalance);
+ this.closingBalance = res?.closingBalance == null ? null : Number(res.closingBalance);
+ this.cashMovement = res?.cashMovement == null ? null : Number(res.cashMovement);
+ this.todayVaultIn = res?.todayVaultIn == null ? null : Number(res.todayVaultIn);
+ this.todaySettledOut = res?.todaySettledOut == null ? null : Number(res.todaySettledOut);
+ this.todayInterCashierIn = res?.todayInterCashierIn == null ? null : Number(res.todayInterCashierIn);
+ this.todayInterCashierOut = res?.todayInterCashierOut == null ? null : Number(res.todayInterCashierOut);
this.loading = false;
this.loaded = true;
},
@@ -102,10 +241,34 @@ export class ClosingCashReportComponent implements OnInit {
this.depositTotal = 0;
this.withdrawalTotal = 0;
this.netTotal = 0;
+ this.ddCashTotal = 0;
+ this.ddCashCount = 0;
+ this.depositTotalWithDd = 0;
+ this.scopedToCashier = false;
+ this.openingBalance = null;
+ this.closingBalance = null;
+ this.cashMovement = null;
+ this.todayVaultIn = null;
+ this.todaySettledOut = null;
+ this.todayInterCashierIn = null;
+ this.todayInterCashierOut = null;
this.loading = false;
this.loaded = true;
}
});
+
+ // GL Entries is scoped to `this.date` too, but was never re-fetched here -- changing the date and
+ // clicking Load left glLoaded=true from whatever date was open before, so a stale, wrong-date GL
+ // table stayed on screen under a header that had already relabelled itself to the new date. If
+ // the panel was open, refresh it for the date just loaded; otherwise just invalidate it so the
+ // next time it's opened it fetches fresh instead of showing leftover rows.
+ this.glOffset = 0;
+ this.glLoaded = false;
+ if (this.showGl) {
+ this.loadGl();
+ } else {
+ this.glRows = [];
+ }
}
toggleGl(): void {
diff --git a/src/app/cashier/day-book/day-book.component.html b/src/app/cashier/day-book/day-book.component.html
new file mode 100644
index 000000000..5c5215145
--- /dev/null
+++ b/src/app/cashier/day-book/day-book.component.html
@@ -0,0 +1,270 @@
+
+
+
+
+
+
{{ title }}
+
+ Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections, interest and
+ journals, with each person's own credit, debit and net.
+
+
+
+
+ chevron_left
+
+
+ Date
+
+
+
+ chevron_right
+
+ Today
+
+ refresh Reload
+
+
+
+
+
+
+
+ Side
+
+ All
+ CR · Credit (money in)
+ DR · Debit (money out)
+
+
+
+ Status
+
+ All
+ Approved
+ Pending
+ Rejected
+
+
+
+ Search
+
+
+
+ download CSV
+
+
+
+ @if (errorMsg) {
+
{{ errorMsg }}
+ }
+ @if (loading) {
+
Loading…
+ }
+
+ @if (!loading && loaded && rows.length === 0) {
+
+
receipt_long
+
No vouchers were raised on {{ date }}.
+
+ }
+
+ @if (!loading && filteredRows.length > 0) {
+
+
+
+ Credit · CR (money in)
+ ₹{{ shownCredit | number: '1.2-2' }}
+
+
−
+
+ Debit · DR (money out)
+ ₹{{ shownDebit | number: '1.2-2' }}
+
+
=
+
= 0"
+ [class.closing-summary__item--net-neg]="shownCredit - shownDebit < 0"
+ >
+ Net
+ ₹{{ shownCredit - shownDebit | number: '1.2-2' }}
+
+
+
+
+ Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
+
+
+
+ @if (byUser.length > 0) {
+
+
+ Officer Verification Sheet
+ Each person's own credit, debit and net for the day
+ {{ byUser.length }} {{ byUser.length === 1 ? 'person' : 'people' }}
+
+
+
+
+
+ Handled by
+ Items
+ Credit (CR)
+ Debit (DR)
+ Net
+ Pending
+
+
+
+ @for (u of byUser; track u.name) {
+
+ {{ u.name }}
+ {{ u.items }}
+ ₹{{ u.credit | number: '1.2-2' }}
+ ₹{{ u.debit | number: '1.2-2' }}
+ = 0"
+ [class.db-byuser__net--neg]="u.net < 0"
+ >
+ ₹{{ u.net | number: '1.2-2' }}
+
+
+ @if (u.pending > 0) {
+ {{ u.pending }}
+ } @else {
+ —
+ }
+
+
+ }
+
+
+
+
+ All staff
+ {{ byUserTotals.items }}
+ ₹{{ byUserTotals.credit | number: '1.2-2' }}
+ ₹{{ byUserTotals.debit | number: '1.2-2' }}
+ = 0"
+ [class.db-byuser__net--neg]="byUserTotals.net < 0"
+ >
+ ₹{{ byUserTotals.net | number: '1.2-2' }}
+
+
+ @if (byUserTotals.pending > 0) {
+ {{ byUserTotals.pending }}
+ } @else {
+ —
+ }
+
+
+
+
+
+
+ }
+
+
+ @for (s of sections; track s.key) {
+
+
+
+ {{ s.label }}
+ {{ s.hint }}
+ {{ s.rows.length }} row{{ s.rows.length === 1 ? '' : 's' }}
+
+
+
+
+ Voucher No
+ Dr/Cr
+ Type
+ Account
+ Party
+ Amount
+ Payment
+ Status
+ By
+ Approved By
+ Time
+
+
+
+ @for (r of s.rows; track r.voucherNo ?? r.section + r.txnId) {
+
+
+ {{ r.voucherNo ?? '—' }}
+
+
+
+ {{ r.side }}
+
+
+ {{ r.kind }}
+
+ {{ r.accountNo ?? '—' }}
+ @if (r.accountType) {
+ · {{ r.accountType }}
+ }
+
+ {{ r.party ?? '—' }}
+ ₹{{ r.amount | number: '1.2-2' }}
+ {{ r.paymentTypeName ?? '—' }}
+
+ {{ r.status }}
+
+ {{ r.handledBy ?? '—' }}
+
+ {{ r.approvedBy ?? '—' }}
+ @if (r.approvedBy && r.actionedAt) {
+ · {{ timeOf(r.actionedAt) }}
+ }
+
+ {{ timeOf(r.submittedAt) }}
+
+ }
+
+
+
+ {{ s.label }} subtotal
+
+ @if (s.credit > 0 && s.debit > 0) {
+ ₹{{ s.credit | number: '1.2-2' }} CR · ₹{{ s.debit | number: '1.2-2' }} DR
+ } @else if (s.credit > 0) {
+ ₹{{ s.credit | number: '1.2-2' }} CR
+ } @else if (s.debit > 0) {
+ ₹{{ s.debit | number: '1.2-2' }} DR
+ } @else {
+ —
+ }
+
+
+
+
+
+
+ }
+ }
+
+ @if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
+
No voucher matches “{{ search }}”.
+ }
+
diff --git a/src/app/cashier/day-book/day-book.component.scss b/src/app/cashier/day-book/day-book.component.scss
new file mode 100644
index 000000000..c9e21ddf1
--- /dev/null
+++ b/src/app/cashier/day-book/day-book.component.scss
@@ -0,0 +1,441 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Layout and page chrome come from cashier-shared.scss; only what is specific to the day book
+ lives here. */
+
+.db-filters {
+ display: flex;
+ align-items: flex-end;
+ gap: 12px;
+ flex-wrap: wrap;
+ padding: 12px 16px;
+ margin-bottom: 12px;
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+
+ /* The search box takes the slack so the two selects keep their natural width. */
+ &__search {
+ flex: 1 1 260px;
+ }
+}
+
+.db-note {
+ margin: 8px 0 12px;
+ font-size: 11px;
+ color: #94a3b8;
+}
+
+/* One card per source register. The heading says which register, and why its rows read differently
+ from the others, so the three are not added up as though they were one list. */
+.db-section {
+ margin-bottom: 14px;
+ border-left: 3px solid #cbd5e1;
+
+ /* Cash that actually crossed the counter — the section a drawer is checked against, so it
+ carries the darker green. */
+ &--CASHIER {
+ border-left-color: #15803d;
+
+ .db-section__head {
+ background: #f0fdf4;
+ }
+ }
+
+ /* Raised but not actioned: no cash has moved yet, so the lighter shade of the same green. */
+ &--CLERK {
+ border-left-color: #86efac;
+
+ .db-section__head {
+ background: #f7fef9;
+ }
+ }
+
+ /* Postings with no counter behind them — interest, charges, journals. Not the branch's cash at
+ all, so a different colour rather than a third green that would read as a third cash state. */
+ &--SYSTEM {
+ border-left-color: #6366f1;
+
+ .db-section__head {
+ background: #f5f5ff;
+ }
+ }
+
+ &__head {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ padding: 10px 14px;
+ background: #f8fafc;
+ border-bottom: 1px solid #e2e8f0;
+ font-size: 12px;
+
+ strong {
+ font-size: 13px;
+ color: #0d1b2a;
+ }
+ }
+
+ &__count {
+ margin-left: auto;
+ font-size: 11px;
+ font-weight: 700;
+ color: #64748b;
+ }
+}
+
+/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
+.db-side {
+ display: inline-block;
+ min-width: 28px;
+ text-align: center;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ background: #f1f5f9;
+ color: #475569;
+
+ &--cr {
+ background: #dcfce7;
+ color: #15803d;
+ }
+
+ &--dr {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+}
+
+/* A hairline on the leading edge, tinted by side: readable without colouring the whole row,
+ which would fight the table's own hover state. */
+.db-row--cr td:first-child {
+ box-shadow: inset 3px 0 0 #22c55e;
+}
+
+.db-row--dr td:first-child {
+ box-shadow: inset 3px 0 0 #ef4444;
+}
+
+.db-muted {
+ color: #94a3b8;
+ font-size: 11px;
+}
+
+.db-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ background: #f1f5f9;
+ color: #475569;
+
+ /* Passed, but nothing handed over yet — the lighter green. */
+ &--ok {
+ background: #dcfce7;
+ color: #15803d;
+ }
+
+ /* Cash has actually left the counter. Filled and dark, so the final state is the one the eye
+ lands on: on this page the difference between "approved" and "paid out" is the whole question. */
+ &--done {
+ background: #15803d;
+ color: #fff;
+ }
+
+ &--pending {
+ background: #fef3c7;
+ color: #b45309;
+ }
+
+ &--rejected {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+}
+
+/* Not defined in cashier-shared.scss, so the day book carries its own. */
+.pg-alert {
+ padding: 10px 14px;
+ margin-bottom: 12px;
+ border-radius: 6px;
+ font-size: 13px;
+
+ &--error {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #b91c1c;
+ }
+}
+
+/* Exception summary, above the table: the day's flagged vouchers listed in full so nobody has to
+ hunt for a badge. */
+.db-exceptions {
+ margin-bottom: 14px;
+ padding: 12px 16px;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ border-left: 4px solid #dc2626;
+ border-radius: 8px;
+
+ &__head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 10px;
+ color: #b91c1c;
+ font-size: 13px;
+
+ mat-icon {
+ font-size: 20px;
+ width: 20px;
+ height: 20px;
+ }
+
+ /* Pushed to the right so the count reads first. */
+ .pg-btn {
+ margin-left: auto;
+ height: 30px;
+ font-size: 12px;
+ }
+ }
+
+ &__item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ padding: 6px 0;
+ font-size: 12px;
+ border-top: 1px dotted #fecaca;
+ }
+
+ &__amt {
+ font-variant-numeric: tabular-nums;
+ color: #334155;
+ }
+
+ &__diff {
+ color: #b91c1c;
+ }
+
+ &__why {
+ color: #0d1b2a;
+ font-weight: 600;
+ }
+}
+
+/* Raising an exception is a deliberate, consequential act — styled to read that way without
+ looking destructive, since nothing is deleted. */
+.pg-btn--warn {
+ background: #fffbeb;
+ border-color: #fde68a;
+ color: #92400e;
+
+ &:hover {
+ background: #fef3c7;
+ }
+}
+
+.db-icon-btn {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ background: #fff;
+ color: #475569;
+ cursor: pointer;
+
+ &:hover {
+ background: #f1f5f9;
+ color: #0d1b2a;
+ }
+
+ mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ }
+
+ /* Attachment count rides on the paperclip so a voucher with a scanned slip reads as different
+ at a glance, without a separate column. */
+ &__count {
+ position: absolute;
+ top: -5px;
+ right: -5px;
+ min-width: 14px;
+ height: 14px;
+ padding: 0 3px;
+ border-radius: 999px;
+ background: #2563eb;
+ color: #fff;
+ font-size: 9px;
+ font-weight: 800;
+ line-height: 14px;
+ }
+}
+
+/* Inline panel, opened under the row it belongs to. */
+.db-panel-row td {
+ background: #f8fafc !important;
+ padding: 0 !important;
+}
+
+.db-panel {
+ padding: 14px 16px;
+ border-left: 3px solid #2563eb;
+
+ &__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ font-size: 13px;
+ color: #0d1b2a;
+ }
+
+ &__body {
+ display: flex;
+ align-items: flex-end;
+ gap: 16px;
+ flex-wrap: wrap;
+ }
+
+ &__remarks {
+ display: block;
+ margin-top: 12px;
+ max-width: 640px;
+ }
+
+ &__warn {
+ margin: 10px 0 0;
+ padding: 8px 12px;
+ border-radius: 6px;
+ background: #fffbeb;
+ border: 1px solid #fde68a;
+ color: #92400e;
+ font-size: 12px;
+ }
+
+ &__actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 14px;
+ }
+}
+
+/* The two amounts being compared, side by side. */
+.db-cmp {
+ padding: 8px 14px;
+ border-radius: 6px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+
+ &__label {
+ display: block;
+ font-size: 10px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #94a3b8;
+ }
+
+ &__value {
+ display: block;
+ margin-top: 2px;
+ font-size: 15px;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+ color: #0d1b2a;
+ }
+
+ &--good {
+ background: #f0fdf4;
+ border-color: #bbf7d0;
+
+ .db-cmp__value {
+ color: #15803d;
+ }
+ }
+
+ &--bad {
+ background: #fef2f2;
+ border-color: #fecaca;
+
+ .db-cmp__value {
+ color: #b91c1c;
+ }
+ }
+}
+
+.pg-table tfoot td {
+ background: #f8fafc;
+ font-weight: 800;
+ font-size: 12px;
+ color: #334155;
+ border-top: 1px solid #e2e8f0;
+}
+
+/* Officer Verification Sheet — the day totalled per person, for whoever signs the book off.
+ Numeric cells use the shared `.num` from cashier-shared.scss rather than a class of their own:
+ that one already right-aligns and tabular-figures both the heading and the cell, which is what
+ lines a money column up. A local class only styled the cells, leaving every heading left-aligned
+ over right-aligned figures. */
+.db-byuser {
+ /* Not one of the three sections below, so no section colour: this is the whole day summed by
+ person, and giving it one of their edges would read as a fourth section. */
+ border-left-color: #94a3b8;
+
+ &__name {
+ font-weight: 600;
+ color: #0f172a;
+ }
+
+ /* Received green, paid-out red — the convention a passbook already uses, so the two columns are
+ told apart without reading the headings. */
+ &__cr {
+ color: #15803d;
+ }
+
+ &__dr {
+ color: #b91c1c;
+ }
+
+ &__net {
+ font-weight: 600;
+
+ &--pos {
+ color: #15803d;
+ }
+
+ &--neg {
+ color: #b91c1c;
+ }
+ }
+
+ /* The sheet's own total sits against a tinted band so the eye lands on the line that has to
+ agree with the day's figures above. */
+ &__total {
+ background: #f8fafc;
+ font-weight: 600;
+
+ td {
+ border-top: 2px solid #cbd5e1;
+ }
+ }
+}
diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts
new file mode 100644
index 000000000..8ce3c0436
--- /dev/null
+++ b/src/app/cashier/day-book/day-book.component.ts
@@ -0,0 +1,378 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { MatIconModule } from '@angular/material/icon';
+import { Router } from '@angular/router';
+import { catchError, of } from 'rxjs';
+import { CashierService } from '../cashier.service';
+
+/**
+ * Which part of the day's work a row came from. Set by the server, one per source register.
+ *
+ * Only counter business appears in the day book. Cash moved between the vault and the drawers is
+ * the bank shifting its own money, not a customer transaction, and it is read on the Cashier
+ * Position and Closing Cash Report where the drawer itself is the subject.
+ */
+type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
+
+interface DayBookRow {
+ voucherNo: string | null;
+ section: DayBookSection;
+ side: 'CR' | 'DR';
+ kind: string;
+ accountNo: string | null;
+ accountType: string | null;
+ party: string | null;
+ amount: number | null;
+ paymentTypeName: string | null;
+ description: string | null;
+ status: string;
+ handledBy: string | null;
+ approvedBy: string | null;
+ txnId: string | null;
+ submittedAt: string | null;
+ actionedAt: string | null;
+}
+
+/**
+ * Day book — every voucher raised on one date in a single list: cash deposits, withdrawals,
+ * disbursements, collections and bank transfers, with the day's credit and debit totals.
+ */
+@Component({
+ selector: 'mifosx-day-book',
+ templateUrl: './day-book.component.html',
+ styleUrls: [
+ '../cashier-shared.scss',
+ './day-book.component.scss'
+ ],
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatIconModule
+ ]
+})
+export class DayBookComponent implements OnInit {
+ private service = inject(CashierService);
+ private router = inject(Router);
+
+ /**
+ * The heading, taken from the route the screen was opened by.
+ *
+ * One screen serves three menus, and only the Branch Ops reading is a different thing: there it
+ * is the branch's book with the verification sheet an officer signs off, rather than one desk's
+ * view of the day.
+ */
+ get title(): string {
+ return this.router.url.includes('/branch-ops/') ? 'Day Book & Officer Verification' : 'Day Book';
+ }
+
+ date = '';
+ side: '' | 'CR' | 'DR' = '';
+ status = '';
+ search = '';
+
+ rows: DayBookRow[] = [];
+ creditTotal = 0;
+ debitTotal = 0;
+ netTotal = 0;
+ loading = false;
+ loaded = false;
+ errorMsg = '';
+
+ ngOnInit(): void {
+ this.date = this.today();
+ this.load();
+ }
+
+ /** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
+ private today(): string {
+ const d = new Date();
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+ }
+
+ load(): void {
+ if (!this.date) return;
+ this.loading = true;
+ this.errorMsg = '';
+ this.service
+ .dayBook(this.date, this.side || undefined, this.status || undefined)
+ .pipe(
+ catchError((err: any) => {
+ this.errorMsg =
+ err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day book.';
+ return of(null);
+ })
+ )
+ .subscribe((res: any) => {
+ this.loading = false;
+ this.loaded = true;
+ this.rows = res?.rows ?? [];
+ this.creditTotal = Number(res?.creditTotal ?? 0);
+ this.debitTotal = Number(res?.debitTotal ?? 0);
+ this.netTotal = Number(res?.netTotal ?? 0);
+ });
+ }
+
+ /** Shifts the selected date by whole days, so yesterday is one click rather than a date picker. */
+ shiftDate(days: number): void {
+ const [
+ y,
+ m,
+ d
+ ] = this.date.split('-').map(Number);
+ const dt = new Date(y, m - 1, d + days);
+ this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
+ this.load();
+ }
+
+ goToday(): void {
+ this.date = this.today();
+ this.load();
+ }
+
+ /**
+ * The day's rows split by where they came from, in the order they are read.
+ *
+ * Cashier work first — that is the counter — then the clerk's outstanding requests. An empty
+ * section is dropped rather than shown as a heading over nothing.
+ *
+ * A section is not "who typed it": a clerk raises a withdrawal that a cashier pays out, so the
+ * heading says which register the entry belongs to, and the By column still names the person.
+ */
+ get sections(): {
+ key: DayBookSection;
+ label: string;
+ hint: string;
+ rows: DayBookRow[];
+ credit: number;
+ debit: number;
+ }[] {
+ const order: { key: DayBookSection; label: string; hint: string }[] = [
+ { key: 'CASHIER', label: 'Cashier', hint: 'Cash actually taken in and paid out over the counter' },
+ { key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' },
+ // Postings that reached the ledger without a request behind them: interest the nightly run
+ // credited, a charge taken from a balance, a manual journal. These are the day's work too,
+ // and an officer accounting for the day cannot do it from the counter registers alone.
+ {
+ key: 'SYSTEM',
+ label: 'System & Journal',
+ hint: 'Interest, charges and journals posted without a counter request'
+ }
+ ];
+ return order
+ .map((s) => {
+ const rows = this.filteredRows.filter((r) => r.section === s.key);
+ return {
+ ...s,
+ rows,
+ credit: rows
+ .filter((r) => r.side === 'CR' && this.countsAsMoney(r))
+ .reduce((t, r) => t + Number(r.amount ?? 0), 0),
+ debit: rows
+ .filter((r) => r.side === 'DR' && this.countsAsMoney(r))
+ .reduce((t, r) => t + Number(r.amount ?? 0), 0)
+ };
+ })
+ .filter((s) => s.rows.length > 0);
+ }
+
+ /** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
+ get filteredRows(): DayBookRow[] {
+ const base = this.rows;
+ const q = this.search.trim().toLowerCase();
+ if (!q) return base;
+ return base.filter((r) =>
+ [
+ r.voucherNo,
+ r.accountNo,
+ r.party,
+ r.kind,
+ r.paymentTypeName,
+ r.handledBy,
+ r.description
+ ]
+ .filter(Boolean)
+ .some((v) => String(v).toLowerCase().includes(q))
+ );
+ }
+
+ /** Totals for what is on screen, so a search or side filter still reconciles. */
+ get shownCredit(): number {
+ return this.filteredRows
+ .filter((r) => r.side === 'CR' && this.countsAsMoney(r))
+ .reduce((s, r) => s + Number(r.amount ?? 0), 0);
+ }
+
+ get shownDebit(): number {
+ return this.filteredRows
+ .filter((r) => r.side === 'DR' && this.countsAsMoney(r))
+ .reduce((s, r) => s + Number(r.amount ?? 0), 0);
+ }
+
+ /** Only approved money is real money — a pending or rejected voucher must not move the totals.
+ * The three source registers don't share one terminal status string: deposits and disbursements
+ * land on APPROVED, but a clerk withdrawal's own finished state is WITHDRAWN — missing it here
+ * meant a completed withdrawal moved real money out yet the DR total stayed at zero. */
+ private countsAsMoney(r: DayBookRow): boolean {
+ const s = (r.status ?? '').toUpperCase();
+ return s === 'APPROVED' || s === 'COMPLETED' || s === 'WITHDRAWN';
+ }
+
+ /**
+ * The day totalled per person, for the officer who signs the book off.
+ *
+ * A branch's day book is read two ways. Entry by entry, to trace one voucher — which the sections
+ * below already give. And person by person, to ask what each member of staff handled and whether
+ * their column adds up: that is the reconciliation a principal officer actually performs, and
+ * without it they are adding a day of rows in their head.
+ *
+ * Built from whoever appears in the day's own rows rather than from a list of staff, so it needs
+ * no configuring: one person working alone gets one line, five people get five, and somebody who
+ * handled nothing today is simply absent instead of showing as a row of zeros.
+ *
+ * Only approved money counts, the same rule the section totals use — a pending request is not
+ * cash anybody is answerable for yet. Pending and rejected rows are still counted as ITEMS, so a
+ * person whose work is all awaiting approval does not vanish from the sheet.
+ *
+ * Ordered by the largest turnover first: the columns worth checking hardest are the ones that
+ * moved the most money.
+ */
+ get byUser(): {
+ name: string;
+ credit: number;
+ debit: number;
+ net: number;
+ items: number;
+ pending: number;
+ }[] {
+ const map = new Map();
+
+ for (const r of this.filteredRows) {
+ // Rows the registers could not attribute are grouped rather than dropped: an unattributed
+ // amount is exactly what an officer needs to see, and hiding it would leave the sheet's own
+ // total disagreeing with the day's.
+ const name = (r.handledBy ?? '').trim() || 'Unattributed';
+ const entry = map.get(name) ?? { name, credit: 0, debit: 0, items: 0, pending: 0 };
+
+ entry.items++;
+ if (this.countsAsMoney(r)) {
+ const amount = Number(r.amount ?? 0);
+ if (r.side === 'CR') {
+ entry.credit += amount;
+ } else {
+ entry.debit += amount;
+ }
+ } else {
+ entry.pending++;
+ }
+ map.set(name, entry);
+ }
+
+ return Array.from(map.values())
+ .map((e) => ({ ...e, net: e.credit - e.debit }))
+ .sort((a, b) => b.credit + b.debit - (a.credit + a.debit));
+ }
+
+ /** The sheet's own totals, which must equal the day's — that is what makes it verifiable. */
+ get byUserTotals(): { credit: number; debit: number; net: number; items: number; pending: number } {
+ return this.byUser.reduce(
+ (t, u) => ({
+ credit: t.credit + u.credit,
+ debit: t.debit + u.debit,
+ net: t.net + u.net,
+ items: t.items + u.items,
+ pending: t.pending + u.pending
+ }),
+ { credit: 0, debit: 0, net: 0, items: 0, pending: 0 }
+ );
+ }
+
+ /**
+ * Approved and paid out are not the same state, and the day book is where the difference matters:
+ * an approved withdrawal is a promise, a withdrawn one is money gone. Both used to render in the
+ * same green, so a reader could not tell from the badge whether the customer had actually been
+ * handed the cash — the one thing this page is read to find out.
+ */
+ statusClass(status: string): string {
+ switch ((status ?? '').toUpperCase()) {
+ case 'WITHDRAWN':
+ case 'COMPLETED':
+ case 'CLOSED':
+ // Cash has left the counter. The darker, filled badge — the final state.
+ return 'db-badge db-badge--done';
+ case 'APPROVED':
+ // Passed, but nobody has handed anything over yet.
+ return 'db-badge db-badge--ok';
+ case 'PENDING':
+ return 'db-badge db-badge--pending';
+ case 'REJECTED':
+ return 'db-badge db-badge--rejected';
+ default:
+ return 'db-badge';
+ }
+ }
+
+ /** Time of day only — the date is already the page's subject. */
+ timeOf(iso: string | null): string {
+ if (!iso) return '—';
+ const t = iso.split('T')[1];
+ return t ? t.slice(0, 5) : '—';
+ }
+
+ exportCsv(): void {
+ const header = [
+ 'Section',
+ 'Voucher No',
+ 'Side',
+ 'Type',
+ 'Account',
+ 'Party',
+ 'Amount',
+ 'Payment Type',
+ 'Status',
+ 'Handled By',
+ 'Approved By',
+ 'Time'
+ ];
+ const lines = this.filteredRows.map((r) =>
+ [
+ r.section,
+ r.voucherNo ?? '',
+ r.side,
+ r.kind,
+ r.accountNo ?? '',
+ r.party ?? '',
+ String(r.amount ?? 0),
+ r.paymentTypeName ?? '',
+ r.status,
+ r.handledBy ?? '',
+ r.approvedBy ?? '',
+ this.timeOf(r.submittedAt)
+ ]
+ // Quote every field and double any embedded quote: names and narrations routinely
+ // contain commas, which would otherwise split into extra columns.
+ .map((v) => `"${String(v).replace(/"/g, '""')}"`)
+ .join(',')
+ );
+ const csv = [
+ header.join(','),
+ ...lines
+ ].join('\n');
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `day-book-${this.date}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+}
diff --git a/src/app/cashier/end-of-day-report/end-of-day-report.component.html b/src/app/cashier/end-of-day-report/end-of-day-report.component.html
new file mode 100644
index 000000000..6a8d5eac7
--- /dev/null
+++ b/src/app/cashier/end-of-day-report/end-of-day-report.component.html
@@ -0,0 +1,606 @@
+
+
+
+
+
+
+
End of the Day Report
+
What moved today, whether the cash agrees with the books, and who signs off.
+
+
+
+ Business date
+
+
+
+ refresh
+ Reload
+
+
+ print
+ Print / Save as PDF
+
+
+
+
+ @if (loading) {
+
Loading the day’s figures…
+ }
+
+ @if (loaded) {
+
+
+
+
Daily EOD & Transaction Report
+
+
+
+
+
+ 1 Transaction Summary
+
+ @if (sectionLines.length) {
+ Where the money came from
+
+
+
+ Source
+ Entries
+ Money in
+ Money out
+
+
+
+ @for (l of sectionLines; track l.label) {
+
+ {{ l.label }}
+ {{ l.count }}
+ {{ l.credit ? (l.credit | currency: 'INR') : '—' }}
+ {{ l.debit ? (l.debit | currency: 'INR') : '—' }}
+
+ }
+
+
+
+ Total
+
+ {{ txnCount }}
+
+
+ {{ summaryCreditTotal | currency: 'INR' }}
+
+
+ {{ summaryDebitTotal | currency: 'INR' }}
+
+
+
+
+
+ What kind of entries
+
+
+
+ Kind
+ Entries
+ Money in
+ Money out
+
+
+
+ @for (l of kindLines; track l.label) {
+
+ {{ l.label }}
+ {{ l.count }}
+ {{ l.credit ? (l.credit | currency: 'INR') : '—' }}
+ {{ l.debit ? (l.debit | currency: 'INR') : '—' }}
+
+ }
+
+
+
+
+ Only entries that actually moved money. Anything still pending is listed at the bottom. These figures match
+ the Day Book for the same date.
+
+ } @else {
+ No transactions on this date.
+ }
+
+
+
+
+ 2 Cash
+
+ @if (scopedToCashier) {
+ Your drawer today
+
+
+
+ Cash you took from the vault this morning
+ {{ openingFloat | currency: 'INR' }}
+
+
+ Cash you took from members deposits, including DD cash
+ +{{ cashDeposits | currency: 'INR' }}
+
+ @if (interCashierIn) {
+
+ Cash another cashier gave you
+ +{{ interCashierIn | currency: 'INR' }}
+
+ }
+
+ Cash you paid to members withdrawals
+ −{{ cashWithdrawals | currency: 'INR' }}
+
+ @if (settledOut) {
+
+ Cash you handed back to the vault
+ −{{ settledOut | currency: 'INR' }}
+
+ }
+ @if (interCashierOut) {
+
+ Cash you gave another cashier
+ −{{ interCashierOut | currency: 'INR' }}
+
+ }
+
+ So your drawer should hold
+
+ @if (expectedCash !== null) {
+ {{ expectedCash | currency: 'INR' }}
+ } @else {
+ —
+ }
+
+
+
+ System says you hold
+
+ @if (actualCash !== null) {
+ {{ actualCash | currency: 'INR' }}
+ } @else {
+ nothing recorded
+ }
+
+
+ @if (variance !== null) {
+
+ Difference
+
+ {{ variance | currency: 'INR' }}
+
+
+ }
+
+
+
+ @if (variance !== null) {
+ @if (varianceOk) {
+
+ check_circle
+ Your cash agrees. Nothing short, nothing over.
+
+ } @else {
+
+ error
+ {{ variance! > 0 ? 'Extra' : 'Short' }} by
+ {{ (variance! > 0 ? variance : -variance!) | currency: 'INR' }}. Explain this before closing.
+
+ }
+ } @else {
+ Today’s cash legs were not sent for this login, so nothing is compared.
+ }
+ } @else {
+ Cash that moved across the branch today
+
+
+
+ Cash taken from members deposits
+ +{{ branchDeposits ?? 0 | currency: 'INR' }}
+
+
+ Cash paid to members withdrawals and closures
+ −{{ branchWithdrawals ?? 0 | currency: 'INR' }}
+
+
+ Net change today
+
+ {{ branchNetMovement | currency: 'INR' }}
+
+
+
+
+
+ Cash the branch is holding right now
+
+
+
+ In the vaults
+ {{ vaultTotal ?? 0 | currency: 'INR' }}
+
+
+ With the cashiers
+ {{ drawersTotal ?? 0 | currency: 'INR' }}
+
+
+ Total cash in the branch
+
+ @if (branchHeldTotal !== null) {
+ {{ branchHeldTotal | currency: 'INR' }}
+ } @else {
+ nothing recorded
+ }
+
+
+
+
+
+
+ These are two different things and are not subtracted from each other. The first is today’s movement;
+ the second is everything the branch has accumulated to date. Each cashier’s own drawer is checked
+ against their own day on their login, and the branch figure is signed off on the Day Open / Day Close
+ screen.
+
+ }
+
+
+
+
+ 3 Handled By
+
+ @if (byUser.length) {
+ What each person handled
+
+
+
+ Handled by
+ Entries
+ Money in
+ Money out
+ Net
+
+
+
+ @for (u of byUser; track u.label) {
+
+ {{ u.label }}
+ {{ u.count }}
+ {{ u.credit ? (u.credit | currency: 'INR') : '—' }}
+ {{ u.debit ? (u.debit | currency: 'INR') : '—' }}
+ {{ u.credit - u.debit | currency: 'INR' }}
+
+ }
+
+
+
+ Total
+
+ {{ txnCount }}
+
+
+ {{ summaryCreditTotal | currency: 'INR' }}
+
+
+ {{ summaryDebitTotal | currency: 'INR' }}
+
+
+ {{ summaryCreditTotal - summaryDebitTotal | currency: 'INR' }}
+
+
+
+
+ } @else {
+ Nobody moved money on this date.
+ }
+
+ @if (cashierPositions.length) {
+ What each cashier is holding
+
+
+
+ Cashier
+ Cash held
+
+
+
+ @for (p of cashierPositions; track p.cashierId) {
+
+ {{ p.cashierName }}
+ {{ p.totalAmount | currency: 'INR' }}
+
+ }
+
+ @if (cashierPositions.length > 1) {
+
+
+ Total
+
+ {{ cashierPositionTotal | currency: 'INR' }}
+
+
+
+ }
+
+
+ This is what each drawer should contain right now — a running total, not just today. Count against it
+ before settling to the vault.
+
+ }
+
+
+
+
+ 4 Every Transaction
+
+ @if (approvedRows.length) {
+
+
+
+ Voucher
+ Kind
+ Account
+ Party
+ Handled by
+ Money in
+ Money out
+
+
+
+ @for (r of approvedRows; track r.voucherNo + '|' + r.txnId) {
+
+ {{ r.voucherNo || '—' }}
+ {{ kindLabel(r.kind) }}
+ {{ r.accountNo || '—' }}
+ {{ r.party || '—' }}
+ {{ r.handledBy || '—' }}
+ {{ r.side === 'CR' ? (r.amount | currency: 'INR') : '—' }}
+ {{ r.side === 'DR' ? (r.amount | currency: 'INR') : '—' }}
+
+ }
+
+
+
+
+ Total — {{ txnCount }} entries
+
+
+ {{ summaryCreditTotal | currency: 'INR' }}
+
+
+ {{ summaryDebitTotal | currency: 'INR' }}
+
+
+
+
+ } @else {
+ No transactions on this date.
+ }
+
+ @if (pendingRows.length) {
+ Still pending — no money has moved on these
+
+
+
+ Voucher
+ Kind
+ Account
+ Party
+ Raised by
+ Amount
+
+
+
+ @for (r of pendingRows; track r.voucherNo + '|' + r.txnId) {
+
+ {{ r.voucherNo || '—' }}
+ {{ kindLabel(r.kind) }}
+ {{ r.accountNo || '—' }}
+ {{ r.party || '—' }}
+ {{ r.handledBy || '—' }}
+ {{ r.amount | currency: 'INR' }}
+
+ }
+
+
+ }
+
+
+
+ @if (priorDayRows.length) {
+
+
+ nights_stay
+ Posted Overnight — work of
+ {{ priorDayDate ? (priorDayDate | date: 'dd MMM yyyy') : 'the previous day' }}
+
+
+
+
+
+ These belong to
+ {{
+ priorDayDate ? (priorDayDate | date: 'dd MMM yyyy') : 'the previous day'
+ }}
+ the day the system posted them against — already closed
+
+
→
+
+ Showing on
+ {{ date | date: 'dd MMM yyyy' }}
+ today’s report, because the run finished after midnight
+
+
+
+
+ The nightly run posted these after
+ {{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'the previous day' }} had already closed and been signed
+ off, so they surface on today’s report instead. In the ledger they sit on
+ {{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'that date' }}, and they are
+ not counted in any figure above.
+
+
+
+
+
+ Voucher
+ Kind
+ Account
+ Party
+ Ledger date
+ Money in
+ Money out
+
+
+
+ @for (r of priorDayRows; track r.voucherNo + '|' + r.txnId) {
+
+ {{ r.voucherNo || '—' }}
+ {{ kindLabel(r.kind) }}
+ {{ r.accountNo || '—' }}
+ {{ r.party || '—' }}
+
+ {{ r.effectiveDate ? (r.effectiveDate | date: 'dd MMM yyyy') : '—' }}
+ {{ r.side === 'CR' ? (r.amount | currency: 'INR') : '—' }}
+ {{ r.side === 'DR' ? (r.amount | currency: 'INR') : '—' }}
+
+ }
+
+
+
+
+ Total — {{ priorDayRows.length }} entries
+
+
+ {{ priorDayCredit | currency: 'INR' }}
+
+
+ {{ priorDayDebit | currency: 'INR' }}
+
+
+
+
+
+ }
+
+
+
+ 5 Anything Outstanding
+
+
+
+ Entries handled
+ {{ txnCount }} entries · {{ kindLines.length }} kind(s)
+
+ 0">
+ Waiting for approval
+
+ @if (pendingRows.length) {
+ {{ pendingRows.length }} entry(ies) — the day cannot close until these are done
+ } @else {
+ None
+ }
+
+
+ 0">
+ Slips that did not match
+
+ @if (mismatchRows.length) {
+ {{ mismatchRows.length }} slip(s) do not agree with the system
+ } @else {
+ None
+ }
+
+
+
+ Slips not checked yet
+
+ {{
+ unverifiedRows.length
+ ? unverifiedRows.length + ' entry(ies) still to be checked against paper'
+ : 'All checked'
+ }}
+
+
+ @if (priorDayRows.length) {
+
+ Posted overnight
+
+ {{ priorDayRows.length }} entry(ies) for
+ {{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'the previous day' }} — listed in their own
+ section, not in today’s figures
+
+
+ }
+
+ Status
+
+ {{
+ allClear
+ ? 'Everything is clear — the day is ready to close.'
+ : 'Sort out the items above before closing the day.'
+ }}
+
+
+
+
+
+
+
+
+
+
{{ supervising ? 'Prepared by' : 'Cashier' }}
+
{{ preparedBy || '' }}
+
+
+
+
Manager / Principal Officer
+
+
+
+
+
+
+ }
+
diff --git a/src/app/cashier/end-of-day-report/end-of-day-report.component.scss b/src/app/cashier/end-of-day-report/end-of-day-report.component.scss
new file mode 100644
index 000000000..15bd8bdab
--- /dev/null
+++ b/src/app/cashier/end-of-day-report/end-of-day-report.component.scss
@@ -0,0 +1,448 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* The sheet itself, which is what gets signed. Bordered on screen so it reads as one document
+ rather than three panels, and the same borders carry into print. */
+
+/* Centred in the page, because it is a document rather than a screen: a signed sheet pinned to the
+ left edge of a wide monitor reads as a panel that failed to fill its space. */
+.eod-sheet {
+ background: #fff;
+ border: 1px solid #cbd5e1;
+ border-radius: 8px;
+ padding: 28px 32px 24px;
+ max-width: 900px;
+ margin: 0 auto;
+ width: 100%;
+}
+
+/* The toolbar and the loading line above it share the sheet's own edges, not the viewport's. */
+.eod .pg-head,
+.eod > .pg-empty {
+ max-width: 900px;
+ margin: 0 auto;
+ width: 100%;
+}
+
+.eod-sheet__head {
+ border-bottom: 2px solid #0f172a;
+ padding-bottom: 14px;
+ margin-bottom: 22px;
+}
+
+.eod-sheet__title {
+ margin: 0 0 14px;
+ font-size: 19px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: #0f172a;
+ text-align: center;
+}
+
+.eod-sheet__meta {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px 18px;
+}
+
+.eod-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.eod-meta__label {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #64748b;
+}
+
+.eod-meta__value {
+ font-size: 13px;
+ font-weight: 600;
+ color: #0f172a;
+}
+
+/* ── Sections ── */
+.eod-section {
+ margin-bottom: 26px;
+
+ /* A section must not be split across two printed pages: a reconciliation whose expected line is
+ on page one and whose variance is on page two is not a document anybody can sign. */
+ break-inside: avoid;
+ page-break-inside: avoid;
+}
+
+.eod-section__title {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin: 0 0 12px;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #0f172a;
+ border-bottom: 1px solid #e2e8f0;
+ padding-bottom: 7px;
+}
+
+.eod-section__no {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border-radius: 4px;
+ background: #0f172a;
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ flex: 0 0 auto;
+}
+
+.eod-note {
+ margin: 8px 0 0;
+ font-size: 11.5px;
+ color: #64748b;
+ line-height: 1.5;
+}
+
+/* Names the table below it, so two tables in one section do not read as one long list. */
+.eod-caption {
+ margin: 0 0 6px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #475569;
+}
+
+.eod-caption--spaced {
+ margin-top: 18px;
+}
+
+/* The line-by-line list is wider than the rest of the sheet, so it scrolls in its own box rather
+ than making the whole page scroll sideways. */
+.eod-section--wide {
+ .pg-table {
+ min-width: 100%;
+ }
+}
+
+.eod-txns {
+ font-size: 12px;
+
+ td,
+ th {
+ white-space: nowrap;
+ }
+}
+
+/* Voucher numbers and account numbers are read digit by digit, so they get a fixed pitch. */
+.eod-mono {
+ font-family: 'Roboto Mono', ui-monospace, 'Courier New', monospace;
+ font-size: 11.5px;
+}
+
+/* Last night's batch. Set apart on purpose: these figures belong to a day that has already been
+ signed off, so the block has to read as an appendix rather than as part of the day being counted.
+ Indigo, because that is already the day book's colour for system postings. */
+.eod-section--prior {
+ background: #f5f3ff;
+ border: 1px solid #ddd6fe;
+ border-radius: 8px;
+ padding: 16px 18px 14px;
+ margin-top: 30px;
+
+ .eod-section__title {
+ color: #5b21b6;
+ border-bottom-color: #ddd6fe;
+ }
+
+ .pg-table th {
+ background: #ede9fe;
+ color: #5b21b6;
+ }
+}
+
+/* Sits in the title row in place of the numbered chip: this block is outside the numbered sequence,
+ because it is not a step in counting today. */
+.eod-section__icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ color: #7c3aed;
+ flex: 0 0 auto;
+}
+
+.eod-note--lead {
+ margin: 0 0 12px;
+ color: #5b21b6;
+}
+
+/* The two dates, stated as a pair with an arrow between them. Spelling out "belongs to X, showing
+ on Y" is the only way a reader can be sure which of the two a figure is counted under — a single
+ date in a heading leaves exactly the ambiguity this block exists to remove. */
+.eod-datepair {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin: 0 0 14px;
+ padding: 12px 14px;
+ background: #fff;
+ border: 1px solid #ddd6fe;
+ border-radius: 6px;
+ flex-wrap: wrap;
+}
+
+.eod-datepair__item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex: 1 1 200px;
+}
+
+.eod-datepair__label {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #7c3aed;
+}
+
+.eod-datepair__value {
+ font-size: 15px;
+ font-weight: 700;
+ color: #0f172a;
+}
+
+.eod-datepair__hint {
+ font-size: 10.5px;
+ color: #64748b;
+ line-height: 1.4;
+}
+
+.eod-datepair__arrow {
+ font-size: 20px;
+ color: #a78bfa;
+ flex: 0 0 auto;
+}
+
+/* Dates in the row are read against the ledger, so they do not shrink into the grey the way a
+ secondary column normally would. */
+.eod-datecell {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: #5b21b6;
+ white-space: nowrap;
+}
+
+.eod-hint {
+ display: block;
+ font-size: 10.5px;
+ font-weight: 400;
+ color: #94a3b8;
+}
+
+.eod-blank {
+ color: #94a3b8;
+ font-style: italic;
+}
+
+/* ── Reconciliation table ── */
+
+/* Two columns only, so the label takes the room and the figure sits in a fixed money column. */
+.eod-recon {
+ td:first-child {
+ width: auto;
+ }
+
+ td:last-child {
+ width: 190px;
+ }
+}
+
+.eod-recon__expected td {
+ border-top: 1px solid #94a3b8;
+ background: #f8fafc;
+}
+
+.eod-recon__variance td {
+ border-top: 2px solid #0f172a;
+}
+
+.eod-recon__variance.eod-ok td {
+ background: #f0fdf4;
+ color: #15803d;
+}
+
+.eod-recon__variance.eod-bad td {
+ background: #fef2f2;
+ color: #b91c1c;
+}
+
+/* ── Verdict line ── */
+.eod-verdict {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ margin: 12px 0 0;
+ padding: 9px 12px;
+ border-radius: 6px;
+ font-size: 12.5px;
+ font-weight: 600;
+ line-height: 1.45;
+
+ mat-icon {
+ font-size: 17px;
+ width: 17px;
+ height: 17px;
+ flex: 0 0 auto;
+ }
+}
+
+.eod-verdict--ok {
+ background: #f0fdf4;
+ border: 1px solid #86efac;
+ color: #15803d;
+}
+
+.eod-verdict--bad {
+ background: #fef2f2;
+ border: 1px solid #fca5a5;
+ color: #b91c1c;
+}
+
+/* ── Operational notes ── */
+.eod-notes {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+
+ li {
+ display: flex;
+ gap: 14px;
+ padding: 8px 10px;
+ border-bottom: 1px solid #f1f5f9;
+ font-size: 12.5px;
+
+ &:last-child {
+ border-bottom: 0;
+ }
+ }
+}
+
+.eod-notes__label {
+ flex: 0 0 190px;
+ font-weight: 600;
+ color: #475569;
+}
+
+.eod-notes__value {
+ color: #0f172a;
+}
+
+.eod-notes--warn {
+ background: #fffbeb;
+
+ .eod-notes__value {
+ color: #b45309;
+ font-weight: 600;
+ }
+}
+
+.eod-notes--ok {
+ background: #f0fdf4;
+
+ .eod-notes__value {
+ color: #15803d;
+ font-weight: 600;
+ }
+}
+
+/* ── Signatures ── */
+.eod-sign {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 40px;
+ margin-top: 46px;
+ break-inside: avoid;
+ page-break-inside: avoid;
+}
+
+.eod-sign__block {
+ text-align: center;
+}
+
+.eod-sign__line {
+ border-bottom: 1px solid #0f172a;
+ height: 34px;
+ margin-bottom: 7px;
+}
+
+.eod-sign__role {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #475569;
+}
+
+.eod-sign__name {
+ font-size: 12px;
+ color: #64748b;
+ margin-top: 2px;
+}
+
+.eod-foot {
+ margin: 26px 0 0;
+ padding-top: 12px;
+ border-top: 1px dashed #cbd5e1;
+ font-size: 10.5px;
+ color: #94a3b8;
+ text-align: center;
+ line-height: 1.5;
+}
+
+/* ── Print ──
+ The sheet is the document; the shell, the toolbar and the app chrome are not. Colours are forced
+ so the variance verdict survives a printer that would otherwise drop backgrounds. */
+@media print {
+ .no-print,
+ .pg-head {
+ display: none !important;
+ }
+
+ .eod-sheet {
+ border: 0;
+ border-radius: 0;
+ padding: 0;
+ max-width: none;
+ }
+
+ .eod-sheet,
+ .eod-recon__variance td,
+ .eod-verdict,
+ .eod-notes--warn,
+ .eod-notes--ok {
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+
+ .eod-sheet__title {
+ font-size: 16px;
+ }
+
+ .pg-table {
+ font-size: 11px;
+ }
+}
diff --git a/src/app/cashier/end-of-day-report/end-of-day-report.component.ts b/src/app/cashier/end-of-day-report/end-of-day-report.component.ts
new file mode 100644
index 000000000..88d8f9720
--- /dev/null
+++ b/src/app/cashier/end-of-day-report/end-of-day-report.component.ts
@@ -0,0 +1,536 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { MatIconModule } from '@angular/material/icon';
+import { CashierService } from '../cashier.service';
+import { SettingsService } from 'app/settings/settings.service';
+import { AuthenticationService } from 'app/core/authentication/authentication.service';
+
+/** One part of the day's work — a section of the day book, or a kind of entry within it. */
+interface SummaryLine {
+ label: string;
+ credit: number;
+ debit: number;
+ count: number;
+}
+
+/**
+ * The single sheet a cashier signs the day off with.
+ *
+ * Every other cashier screen answers one question — what is in the drawer, which vouchers are
+ * unverified, what did each person handle. At close of day somebody has to sign one piece of paper
+ * saying the money was counted and it agreed, and until now that meant reading four screens and
+ * copying figures onto a form by hand.
+ *
+ * Nothing here is computed on the server that was not already being computed: the closing report
+ * supplies the drawer legs, the day book supplies the transactions, and the cashier position
+ * supplies what the system believes is held. This screen assembles them in the order a person
+ * verifying the day reads them in, and prints.
+ */
+@Component({
+ selector: 'mifosx-end-of-day-report',
+ templateUrl: './end-of-day-report.component.html',
+ styleUrls: [
+ '../cashier-shared.scss',
+ './end-of-day-report.component.scss'
+ ],
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatIconModule
+ ]
+})
+export class EndOfDayReportComponent implements OnInit {
+ private service = inject(CashierService);
+ private settings = inject(SettingsService);
+ private auth = inject(AuthenticationService);
+
+ date = '';
+ loading = false;
+ loaded = false;
+
+ /** Who is signing, and for which branch — read from the session, never typed in. */
+ preparedBy = '';
+ branchName = '';
+
+ // ── Section 1: transaction summary ──
+ rows: any[] = [];
+ creditTotal = 0;
+ debitTotal = 0;
+
+ // ── Section 2: cash drawer ──
+ /** Drawn from the vault in the morning: a cashier's opening float. */
+ openingFloat = 0;
+ cashDeposits = 0;
+ interCashierIn = 0;
+ cashWithdrawals = 0;
+ settledOut = 0;
+ interCashierOut = 0;
+ /** Whether the server sent this day's own cash legs, which only a cashier-scoped call gets. */
+ hasDayLegs = false;
+ /** Whether the drawer figures are one cashier's or the whole branch's counter work. */
+ scopedToCashier = false;
+
+ /** What the drawers hold between them, per the system. */
+ drawersTotal: number | null = null;
+
+ /**
+ * Each cashier and what their drawer holds.
+ *
+ * A clerk holds no drawer of their own — they supervise the counter — so a single "actual cash"
+ * figure says nothing to them. The position endpoint already answers for whoever is asking: one
+ * drawer for a cashier, every drawer for a clerk or officer, so the same call serves both and the
+ * screen reads differently depending on who opened it.
+ */
+ cashierPositions: { cashierId: number | null; cashierName: string; totalAmount: number }[] = [];
+
+ /**
+ * The branch's own expected cash, from the day register.
+ *
+ * A branch-wide reading cannot be assembled from the per-cashier day legs: those are only
+ * returned when the call is scoped to one cashier. The branch day register already keeps this
+ * arithmetic for the close-day gate — opening cash, plus approved deposits, less what was
+ * withdrawn — so the sheet reads it rather than inventing a second version of the same sum.
+ *
+ * Null when no day has been opened for this date, which is a real state: there is nothing to
+ * reconcile against on a day the branch never opened.
+ */
+ branchOpeningCash: number | null = null;
+ branchDeposits: number | null = null;
+ branchWithdrawals: number | null = null;
+ branchExpectedCash: number | null = null;
+
+ /**
+ * What the vaults hold, which a branch-wide count must include.
+ *
+ * The branch register's expected cash covers the whole branch — vault and every drawer together —
+ * because cash moved from the vault to a cashier has not left the branch. Counting only the
+ * drawers against it would report the entire vault as a shortage.
+ */
+ vaultTotal: number | null = null;
+
+ ngOnInit(): void {
+ this.date = this.businessDate();
+ this.preparedBy = this.currentUserName();
+ this.branchName = this.currentOfficeName();
+ this.load();
+ }
+
+ /**
+ * The date the books are open on, not the wall clock.
+ *
+ * An EOD sheet dated by the browser would be filed against the wrong day whenever the branch is
+ * still working yesterday's business date, which is exactly when a day is being closed.
+ */
+ private businessDate(): string {
+ const d = this.settings.businessDate;
+ const safe = d instanceof Date && !isNaN(d.getTime()) ? d : new Date();
+ return `${safe.getFullYear()}-${String(safe.getMonth() + 1).padStart(2, '0')}-${String(safe.getDate()).padStart(2, '0')}`;
+ }
+
+ /** The member of staff behind the login, falling back to the username when no staff is linked. */
+ private currentUserName(): string {
+ const c = this.auth.getCredentials();
+ return c ? c.staffDisplayName || c.username || '' : '';
+ }
+
+ private currentOfficeName(): string {
+ return this.auth.getCredentials()?.officeName || '';
+ }
+
+ load(): void {
+ this.loading = true;
+ this.loaded = false;
+
+ this.service.dayBook(this.date).subscribe({
+ next: (res: any) => {
+ this.rows = res?.rows || [];
+ this.creditTotal = Number(res?.creditTotal || 0);
+ this.debitTotal = Number(res?.debitTotal || 0);
+ this.loading = false;
+ this.loaded = true;
+ },
+ error: () => {
+ this.rows = [];
+ this.creditTotal = 0;
+ this.debitTotal = 0;
+ this.loading = false;
+ this.loaded = true;
+ }
+ });
+
+ this.service.closingCashReport(this.date).subscribe({
+ next: (res: any) => {
+ // The four day legs are only sent for a cashier-scoped call. Read as absent rather than
+ // zero: a leg that is missing and a leg that is genuinely nil mean different things, and
+ // treating the first as the second is what let the sheet claim an expected balance of zero
+ // for a whole branch.
+ this.hasDayLegs = res?.todayVaultIn != null;
+ this.openingFloat = Number(res?.todayVaultIn || 0);
+ this.cashDeposits = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0);
+ this.cashWithdrawals = Number(res?.withdrawalTotal || 0);
+ this.settledOut = Number(res?.todaySettledOut || 0);
+ this.interCashierIn = Number(res?.todayInterCashierIn || 0);
+ this.interCashierOut = Number(res?.todayInterCashierOut || 0);
+ this.scopedToCashier = res?.scopedToCashier === true;
+ },
+ error: () => {
+ this.openingFloat = 0;
+ this.cashDeposits = 0;
+ this.cashWithdrawals = 0;
+ this.settledOut = 0;
+ this.interCashierIn = 0;
+ this.interCashierOut = 0;
+ this.scopedToCashier = false;
+ this.hasDayLegs = false;
+ }
+ });
+
+ // The branch's expected cash for the date, which is the only basis a branch-wide reading can
+ // reconcile on. Fails quietly for a plain cashier, who is not allowed to operate the day.
+ this.service.branchDayClosePreview(this.date).subscribe({
+ next: (res: any) => {
+ this.branchOpeningCash = res?.openingCash == null ? null : Number(res.openingCash);
+ this.branchDeposits = res?.deposits == null ? null : Number(res.deposits);
+ this.branchWithdrawals = res?.withdrawals == null ? null : Number(res.withdrawals);
+ this.branchExpectedCash = res?.expectedCash == null ? null : Number(res.expectedCash);
+ },
+ error: () => {
+ this.branchOpeningCash = null;
+ this.branchDeposits = null;
+ this.branchWithdrawals = null;
+ this.branchExpectedCash = null;
+ }
+ });
+
+ // One object for a cashier, an array of every cashier for a clerk or officer — the server
+ // decides from the caller's own roles, so this screen does not have to ask who is reading it.
+ this.service.cashierPositionReport(undefined, this.date).subscribe({
+ next: (res: any) => {
+ const list = Array.isArray(res) ? res : res ? [res] : [];
+ this.cashierPositions = list
+ .filter((p: any) => p && p.cashierId != null)
+ .map((p: any) => ({
+ cashierId: p.cashierId == null ? null : Number(p.cashierId),
+ cashierName: p.cashierName || `Cashier ${p.cashierId}`,
+ totalAmount: Number(p.totalAmount || 0)
+ }))
+ .sort((a: any, b: any) => b.totalAmount - a.totalAmount);
+ this.drawersTotal = this.cashierPositions.length
+ ? this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0)
+ : null;
+ },
+ error: () => {
+ // Nothing to count against, so the variance line stays blank rather than reading zero — a
+ // variance of zero is a claim that somebody counted and it agreed.
+ this.cashierPositions = [];
+ this.drawersTotal = null;
+ }
+ });
+
+ // Vault stock, so a branch-wide count is of the whole branch and not just the counters.
+ this.service.vaultPosition(undefined, this.date).subscribe({
+ next: (res: any) => {
+ const list = Array.isArray(res) ? res : res ? [res] : [];
+ this.vaultTotal = list.reduce((s: number, v: any) => s + Number(v?.totalAmount || 0), 0);
+ },
+ error: () => {
+ this.vaultTotal = null;
+ }
+ });
+ }
+
+ // ── Section 1 ──
+
+ /**
+ * Only the entries that actually moved money.
+ *
+ * A pending request is listed on the day book so nobody loses sight of it, but it has taken no
+ * cash — counting it into an EOD summary would make the sheet disagree with the drawer.
+ */
+ /**
+ * Last night's batch: posted against yesterday, surfacing on this report.
+ *
+ * A voucher's timestamp is the minute the job ran, while the transaction and the journal entry
+ * behind it carry the business date. So the interest run that fires just after midnight stamps
+ * itself with today although the ledger says yesterday — and yesterday was counted, reported and
+ * signed off hours before. Re-opening that day would invalidate a signed sheet, so the work is
+ * listed here instead, under its own heading, saying which day it belongs to. It is excluded from
+ * every total on this page: it is not today's money.
+ */
+ get priorDayRows(): any[] {
+ return this.rows.filter((r) => r.section === 'SYSTEM_PRIOR');
+ }
+
+ get priorDayCredit(): number {
+ return this.priorDayRows.reduce((s, r) => s + (r.side === 'CR' ? Number(r.amount || 0) : 0), 0);
+ }
+
+ get priorDayDebit(): number {
+ return this.priorDayRows.reduce((s, r) => s + (r.side === 'DR' ? Number(r.amount || 0) : 0), 0);
+ }
+
+ /** The business date last night's batch worked on, for the heading. */
+ get priorDayDate(): string | null {
+ const withDate = this.priorDayRows.find((r) => r.effectiveDate);
+ return withDate ? withDate.effectiveDate : null;
+ }
+
+ get approvedRows(): any[] {
+ return this.rows.filter(
+ (r) =>
+ r.section !== 'SYSTEM_PRIOR' &&
+ (r.status === 'APPROVED' || r.status === 'COMPLETED' || r.status === 'WITHDRAWN')
+ );
+ }
+
+ /**
+ * The day split the way this system actually records it: where the entry came from.
+ *
+ * Not split by payment mode, which is the obvious shape for a retail till but the wrong one here.
+ * A payment type is only recorded against a denomination breakdown, and a breakdown is only
+ * written for physical cash — so a UPI or transfer deposit carries no payment type at all, and a
+ * mode-wise table would file every non-cash entry under "not specified" while claiming to be a
+ * breakdown by mode. The day book's own sections are recorded on every row, so this agrees with
+ * the day book for the same date instead of quietly disagreeing with it.
+ */
+ get sectionLines(): SummaryLine[] {
+ const order = [
+ 'CASHIER',
+ 'CLERK',
+ 'SYSTEM'
+ ];
+ const labels: Record = {
+ CASHIER: 'Cash counter',
+ CLERK: 'Clerk desk',
+ SYSTEM: 'System — interest, charges, journals'
+ };
+ const map = new Map();
+ for (const r of this.approvedRows) {
+ const key = order.includes(r.section) ? r.section : 'SYSTEM';
+ let line = map.get(key);
+ if (!line) {
+ line = { label: labels[key] || key, credit: 0, debit: 0, count: 0 };
+ map.set(key, line);
+ }
+ this.addTo(line, r);
+ }
+ return order.filter((k) => map.has(k)).map((k) => map.get(k)!);
+ }
+
+ /**
+ * What kind of entry it was — deposit, withdrawal, interest posting, charge.
+ *
+ * The sections above say where the day's work came from; this says what the work was, which is
+ * the question somebody checking a figure against a register actually asks.
+ */
+ get kindLines(): SummaryLine[] {
+ const map = new Map();
+ for (const r of this.approvedRows) {
+ const key = (r.kind || 'OTHER').toString();
+ let line = map.get(key);
+ if (!line) {
+ line = { label: this.titleCase(key), credit: 0, debit: 0, count: 0 };
+ map.set(key, line);
+ }
+ this.addTo(line, r);
+ }
+ return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit));
+ }
+
+ /** Adds one day-book row onto a summary line, on the side the row belongs to. */
+ private addTo(line: SummaryLine, row: any): void {
+ const amt = Number(row.amount || 0);
+ if (row.side === 'CR') {
+ line.credit += amt;
+ } else {
+ line.debit += amt;
+ }
+ line.count += 1;
+ }
+
+ /** DD CASH, INTEREST_POSTING and the like, as a person would read them. */
+ kindLabel(raw: string | null | undefined): string {
+ if (!raw) {
+ return '—';
+ }
+ return raw
+ .toString()
+ .replace(/_/g, ' ')
+ .toLowerCase()
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+ }
+
+ private titleCase(raw: string): string {
+ return this.kindLabel(raw);
+ }
+
+ get txnCount(): number {
+ return this.approvedRows.length;
+ }
+
+ /** More than one drawer in view means a supervisor is reading, not a cashier. */
+ get supervising(): boolean {
+ return this.cashierPositions.length > 1 || (!this.scopedToCashier && this.cashierPositions.length > 0);
+ }
+
+ get cashierPositionTotal(): number {
+ return this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0);
+ }
+
+ /**
+ * Each person who handled the day's work, with their own credit, debit and net.
+ *
+ * The clerk signing the day off is verifying other people's work, so the sheet has to say whose.
+ * Read off the day book's own handledBy, which every register stamps.
+ */
+ get byUser(): SummaryLine[] {
+ const map = new Map();
+ for (const r of this.approvedRows) {
+ const key = (r.handledBy || 'Unattributed').toString();
+ let line = map.get(key);
+ if (!line) {
+ line = { label: key, credit: 0, debit: 0, count: 0 };
+ map.set(key, line);
+ }
+ this.addTo(line, r);
+ }
+ return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit));
+ }
+
+ get summaryCreditTotal(): number {
+ return this.sectionLines.reduce((s, l) => s + l.credit, 0);
+ }
+
+ get summaryDebitTotal(): number {
+ return this.sectionLines.reduce((s, l) => s + l.debit, 0);
+ }
+
+ // ── Section 2 ──
+
+ /** Everything that came into the drawer today, over the counter and from another cashier. */
+ get totalReceived(): number {
+ return this.cashDeposits + this.interCashierIn;
+ }
+
+ /** Everything that left it: paid to members, handed to the vault, passed to another cashier. */
+ get totalPaidOut(): number {
+ return this.cashWithdrawals + this.settledOut + this.interCashierOut;
+ }
+
+ /** What should be on the desk: the float, plus what came in, less what went out. */
+ get expectedClosing(): number {
+ return this.openingFloat + this.totalReceived - this.totalPaidOut;
+ }
+
+ /**
+ * Whether an expected-against-held comparison can honestly be made.
+ *
+ * Only for a single cashier, and only when the server sent that cashier's day legs. Both halves
+ * then describe the same thing: today's legs, against a drawer that carries nothing forward
+ * because a cashier draws in the morning and hands back at close.
+ *
+ * Never branch-wide. The branch register's expected cash is *today's* arithmetic — yesterday's
+ * closing, plus today's deposits, less today's withdrawals — while the vault and drawer positions
+ * are running totals accumulated since the system went live. Subtracting one from the other
+ * compares an afternoon against a lifetime: it reported ₹2,11,932 extra on a day when ₹100 came
+ * in and ₹100 went out. There is no branch-wide running total to compare against, so the sheet
+ * shows the branch its movement and each drawer's position, and states no difference at all
+ * rather than a confident wrong one.
+ */
+ get canReconcile(): boolean {
+ return this.scopedToCashier && this.hasDayLegs;
+ }
+
+ /** The cash the system believes this cashier is holding. */
+ get actualCash(): number | null {
+ return this.scopedToCashier ? this.drawersTotal : null;
+ }
+
+ /** What today's legs say should be in the drawer. */
+ get expectedCash(): number | null {
+ return this.canReconcile ? this.expectedClosing : null;
+ }
+
+ /** Held less expected, for one cashier's own day. Null wherever that comparison is not honest. */
+ get variance(): number | null {
+ const expected = this.expectedCash;
+ if (expected === null || this.actualCash === null) {
+ return null;
+ }
+ return this.actualCash - expected;
+ }
+
+ /** Vaults and drawers together — what the branch is holding, per the system. */
+ get branchHeldTotal(): number | null {
+ if (this.drawersTotal === null && this.vaultTotal === null) {
+ return null;
+ }
+ return Number(this.drawersTotal ?? 0) + Number(this.vaultTotal ?? 0);
+ }
+
+ /** Today's net cash movement across the branch: what came in less what went out. */
+ get branchNetMovement(): number {
+ return Number(this.branchDeposits ?? 0) - Number(this.branchWithdrawals ?? 0);
+ }
+
+ /** True only when a count exists and it agrees to the paisa. */
+ get varianceOk(): boolean {
+ return this.variance !== null && Math.abs(this.variance) < 0.005;
+ }
+
+ get varianceClass(): string {
+ if (this.variance === null) {
+ return '';
+ }
+ return this.varianceOk ? 'eod-ok' : 'eod-bad';
+ }
+
+ // ── Section 3 ──
+
+ /** Entries still waiting on somebody, which is what a day cannot be closed over. */
+ get pendingRows(): any[] {
+ return this.rows.filter((r) => r.status === 'PENDING' || r.status === 'SUBMITTED');
+ }
+
+ /** Cash entries whose paper slip nobody has checked against the screen. */
+ get unverifiedRows(): any[] {
+ return this.approvedRows.filter((r) => !r.verifyStatus);
+ }
+
+ /** A slip that was checked and did not agree with what was keyed in. */
+ get mismatchRows(): any[] {
+ return this.rows.filter((r) => r.verifyStatus === 'MISMATCH' || r.verifyStatus === 'DISPUTED');
+ }
+
+ /**
+ * Whether the day is ready to close.
+ *
+ * Nothing pending, no slip disagreeing, and — where a cash comparison was actually made — the
+ * cash agreeing. A branch-wide reading makes no comparison, so it is not held to one: requiring
+ * `varianceOk` there meant the sheet could never say a day was clear however clean it was.
+ */
+ get allClear(): boolean {
+ if (this.pendingRows.length > 0 || this.mismatchRows.length > 0) {
+ return false;
+ }
+ return this.variance === null || this.varianceOk;
+ }
+
+ reload(): void {
+ this.load();
+ }
+
+ print(): void {
+ window.print();
+ }
+}
diff --git a/src/app/cashier/my-work/my-work.component.html b/src/app/cashier/my-work/my-work.component.html
new file mode 100644
index 000000000..e4c96e7db
--- /dev/null
+++ b/src/app/cashier/my-work/my-work.component.html
@@ -0,0 +1,216 @@
+
+
+
+
+
+
MY WORK
+
My Vouchers
+
+ Everything you entered and everything you approved — receipts, payments and journals in one list.
+
+
+
+
+ Period
+
+ Day
+ Month
+
+
+
+ {{ period === 'DAY' ? 'Date' : 'Month of' }}
+
+
+
+ refresh Run
+
+
+
+
+ @if (error) {
+
{{ error }}
+ }
+
+ @if (loading) {
+
Loading…
+ }
+
+ @if (report && !loading) {
+
+ {{ report.displayName || report.username }}
+ ·
+ {{ report.from | date: 'dd MMM yyyy' }}
+ @if (report.from !== report.to) {
+ to {{ report.to | date: 'dd MMM yyyy' }}
+ }
+
+
+
+
+
+
Received
+
₹{{ receivedTotal | number: '1.2-2' }}
+
+
+
Payments
+
₹{{ paymentTotal | number: '1.2-2' }}
+
+
+
Journal
+
₹{{ journalTotal | number: '1.2-2' }}
+
+
+
Entered
+
{{ raisedCount }}
+
+
+
Approved
+
{{ verifiedCount }}
+
+
+
+ @if (report.count > 0) {
+
+
+ Side
+
+ All
+ Received
+ Payment
+ Journal
+
+
+
+ My part
+
+ All
+ Entered
+ Approved
+ Entered & approved
+
+
+ @if (isFiltered) {
+ clear Clear
+ }
+
+ }
+
+ @if (rows.length === 0) {
+
+ @if (report.count === 0) {
+ Nothing recorded against you for this period.
+ } @else {
+ No vouchers match these filters — {{ report.count }} hidden.
+ }
+
+ } @else {
+
+ Showing {{ rows.length }}
+ @if (isFiltered) {
+ of {{ report.count }}
+ }
+ {{ rows.length === 1 ? 'voucher' : 'vouchers' }}
+ Total ₹{{ filteredTotal | number: '1.2-2' }}
+
+
+
+ @for (g of groups; track g.key) {
+
+
+ {{ g.label }}
+ {{ g.hint }}
+
+ {{ g.rows.length }} {{ g.rows.length === 1 ? 'voucher' : 'vouchers' }}
+ ₹{{ g.total | number: '1.2-2' }}
+
+
+
+
+
+
+ Voucher
+ Side
+ Type
+ Debit
+ Credit
+ Amount
+ My part
+ Status
+ Time
+
+
+
+ @for (r of g.rows; track r.voucherNo) {
+
+
+
+ {{ r.voucherNo }}
+ @if (loadingDetailFor === r.voucherNo) {
+ hourglass_empty
+ }
+
+
+ {{ sideLabel(r.side) }}
+
+ {{ r.voucherType }}
+
+ {{ r.debitParty || '—' }}
+ @if (r.debitAccount) {
+ {{ r.debitAccount }}
+ }
+
+
+ {{ r.creditParty || '—' }}
+ @if (r.creditAccount) {
+ {{ r.creditAccount }}
+ }
+ @if (r.extraLines > 0) {
+ +{{ r.extraLines }} more
+ }
+
+ ₹{{ r.amount | number: '1.2-2' }}
+
+
+ {{ roleIcon(r.myRole) }}
+ {{ roleLabel(r.myRole) }}
+
+
+ @if (r.myRole === 'VERIFIED' && r.raisedBy) {
+ by {{ r.raisedBy }}
+ }
+
+
+
+ {{ statusLabel(r.verificationStatus) }}
+
+
+ {{ r.createdAt | date: 'HH:mm' }}
+
+ }
+
+
+
+
+ }
+ }
+ }
+
diff --git a/src/app/cashier/my-work/my-work.component.scss b/src/app/cashier/my-work/my-work.component.scss
new file mode 100644
index 000000000..4e8d33e25
--- /dev/null
+++ b/src/app/cashier/my-work/my-work.component.scss
@@ -0,0 +1,309 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Who and when, above the day's figures. */
+.mw-who {
+ font-size: 13px;
+ color: #475569;
+ margin: 0 0 12px;
+
+ strong {
+ color: #0f172a;
+ }
+
+ &__sep {
+ margin: 0 6px;
+ color: #cbd5e1;
+ }
+}
+
+/* The day at a glance: the two sides a branch balances, then what the user's part was. */
+.mw-tiles {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 10px;
+ margin-bottom: 16px;
+}
+
+.mw-tile {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ padding: 10px 12px;
+
+ &__label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #64748b;
+ margin-bottom: 4px;
+ }
+
+ &__value {
+ font-size: 18px;
+ font-weight: 600;
+ color: #0f172a;
+
+ /* Received green, paid-out red — the convention a passbook already uses, so the two totals
+ are told apart without reading the labels. */
+ &--cr {
+ color: #15803d;
+ }
+
+ &--dr {
+ color: #b91c1c;
+ }
+ }
+}
+
+.mw-filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.mw-count {
+ font-size: 12px;
+ color: #64748b;
+ margin-bottom: 8px;
+
+ &__total {
+ float: right;
+ font-weight: 600;
+ color: #0f172a;
+ }
+}
+
+.mw-table {
+ font-size: 13px;
+}
+
+/* Money cells use the shared `.num` from cashier-shared.scss, which right-aligns and
+ tabular-figures the heading as well as the cell. A local class only reached the cells, leaving
+ every heading left-aligned over right-aligned figures. */
+
+.mw-vno {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.mw-type {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: #64748b;
+ white-space: nowrap;
+}
+
+.mw-time {
+ white-space: nowrap;
+ color: #64748b;
+}
+
+/* The account number or the maker's name, under the party it belongs to. */
+.mw-acct {
+ display: block;
+ font-size: 11px;
+ color: #94a3b8;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+.mw-more {
+ display: inline-block;
+ margin-left: 6px;
+ font-size: 10px;
+ padding: 1px 5px;
+ border-radius: 8px;
+ background: #f1f5f9;
+ color: #64748b;
+}
+
+.mw-badge {
+ display: inline-block;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 2px 7px;
+ border-radius: 10px;
+ white-space: nowrap;
+
+ &--CR {
+ background: #dcfce7;
+ color: #15803d;
+ }
+
+ &--DR {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+
+ &--JV {
+ background: #e0e7ff;
+ color: #4338ca;
+ }
+}
+
+/* Whose work the row is, marked on the row itself.
+ A coloured left edge and a faint tint, rather than a different text colour: the eye finds the
+ edge while scanning a column of figures, which is how a day's list is actually read. Kept faint
+ so the amounts stay the thing being read. */
+.mw-row {
+ border-left: 3px solid transparent;
+
+ &--RAISED {
+ border-left-color: #86efac;
+ }
+
+ /* Somebody else's entry that this person passed. The row a reviewer came looking for, so it
+ carries the darker of the two greens. */
+ &--VERIFIED {
+ border-left-color: #15803d;
+ background: #f0fdf4;
+ }
+
+ /* Entered and approved by the same person. Legitimate in a small branch, but the one combination
+ an auditor stops on, so it is the only row given a warm tint. */
+ &--RAISED_AND_VERIFIED {
+ border-left-color: #d97706;
+ background: #fffbf3;
+ }
+}
+
+.mw-row--clickable {
+ cursor: pointer;
+
+ &:hover {
+ background: #f8fafc;
+ }
+}
+
+.mw-vno__spinner {
+ margin-left: 4px;
+ font-size: 14px;
+ vertical-align: middle;
+ color: #94a3b8;
+}
+
+/* One section per part played. The heading carries the section's own count and total, so a maker's
+ tally and a checker's are each readable without adding rows up. */
+.mw-group {
+ margin-bottom: 16px;
+
+ /* Two shades of one colour rather than two colours: entering and approving are stages of the
+ same piece of work, and the darker edge reads as the later one. */
+ border-left: 3px solid #86efac;
+
+ /* Same shades the row edges use, so a section and its rows read as one thing. */
+ &--VERIFIED {
+ border-left-color: #15803d;
+ }
+
+ &--RAISED_AND_VERIFIED {
+ border-left-color: #d97706;
+ }
+
+ &__head {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ padding: 10px 14px;
+ border-bottom: 1px solid #e2e8f0;
+ background: #f8fafc;
+
+ strong {
+ font-size: 13px;
+ color: #0f172a;
+ }
+ }
+
+ &__hint {
+ font-size: 11px;
+ color: #64748b;
+ }
+
+ &__count {
+ margin-left: auto;
+ font-size: 11px;
+ color: #64748b;
+ white-space: nowrap;
+ }
+
+ &__total {
+ margin-left: 8px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+}
+
+/* Where the voucher stands. Three states, because there are three things a reader does: nothing
+ (done), chase it (pending), or look into it (bad). */
+.mw-status {
+ font-size: 11px;
+ font-weight: 600;
+ white-space: nowrap;
+
+ /* Approved, posted — settled but nothing handed over. Lighter than 'paid' but still dark enough
+ to read as body text; a pale green on white fails at this size. */
+ &--done {
+ color: #16a34a;
+ }
+
+ /* Cash actually gone. Darker and filled, the same way the day book marks it. */
+ &--paid {
+ background: #15803d;
+ color: #fff;
+ padding: 2px 8px;
+ border-radius: 999px;
+ }
+
+ &--pending {
+ color: #b45309;
+ }
+
+ &--bad {
+ color: #b91c1c;
+ }
+}
+
+/* The part played, as a small pill with its own glyph. */
+.mw-part {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ padding: 2px 8px 2px 6px;
+ border-radius: 10px;
+ white-space: nowrap;
+
+ &__icon {
+ font-size: 13px;
+ width: 13px;
+ height: 13px;
+ line-height: 13px;
+ }
+
+ &--RAISED {
+ background: #dcfce7;
+ color: #166534;
+ }
+
+ &--VERIFIED {
+ background: #15803d;
+ color: #fff;
+ }
+
+ &--RAISED_AND_VERIFIED {
+ background: #fef3c7;
+ color: #b45309;
+ }
+}
diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts
new file mode 100644
index 000000000..4dbee818f
--- /dev/null
+++ b/src/app/cashier/my-work/my-work.component.ts
@@ -0,0 +1,363 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { MatIconModule } from '@angular/material/icon';
+import { MatDialog, MatDialogModule } from '@angular/material/dialog';
+import { catchError, finalize, of } from 'rxjs';
+import { CashierService } from '../cashier.service';
+import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
+
+/** What the signed-in user's part in a voucher was. */
+export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
+
+/** Which register the voucher was numbered in: receipt, payment, or journal. */
+export type VoucherSide = 'CR' | 'DR' | 'JV';
+
+interface MyWorkRow {
+ voucherNo: string;
+ side: VoucherSide | null;
+ voucherType: string | null;
+ amount: number | null;
+ createdAt: string | null;
+ raisedBy: string | null;
+ branchName: string | null;
+ verificationStatus: string | null;
+ verifiedByName: string | null;
+ verifiedAt: string | null;
+ debitParty: string | null;
+ debitAccount: string | null;
+ creditParty: string | null;
+ creditAccount: string | null;
+ /** Sides beyond the one debit and one credit shown — a deposit that also took a charge. */
+ extraLines: number;
+ myRole: MyRole;
+}
+
+interface MyWorkReport {
+ from: string;
+ to: string;
+ username: string;
+ displayName: string;
+ vouchers: MyWorkRow[];
+ count: number;
+ raisedCount: number;
+ verifiedCount: number;
+ receivedTotal: number;
+ paymentTotal: number;
+ journalTotal: number;
+}
+
+/**
+ * One person's day at the branch: what they entered and what they approved, in one list.
+ *
+ * The Received and Payment registers answer "what moved today" across everyone. This answers the
+ * question each member of staff asks at a close — "what did I do today" — which those two cannot,
+ * because a checker's approval raises no voucher of its own and leaves its only trace on the
+ * verification row.
+ */
+@Component({
+ selector: 'mifosx-my-work',
+ templateUrl: './my-work.component.html',
+ styleUrls: [
+ '../cashier-shared.scss',
+ './my-work.component.scss'
+ ],
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatIconModule,
+ MatDialogModule
+ ]
+})
+export class MyWorkComponent implements OnInit {
+ private service = inject(CashierService);
+ private dialog = inject(MatDialog);
+
+ /** Which voucher's detail is currently being fetched, so the row that was clicked can show its
+ * own spinner instead of a page-wide one for what is a small, single-row lookup. */
+ loadingDetailFor: string | null = null;
+
+ /** Day is the working default; a month is what a close or an audit asks for. */
+ period: 'DAY' | 'MONTH' = 'DAY';
+ anchor = this.todayIso();
+
+ /**
+ * Narrow to one side, or to the part the user played. Both applied in the browser, and only to
+ * the table below — the five summary tiles are pinned to Entered work regardless (see
+ * enteredRows), so this can default to showing everything without muddying them.
+ */
+ sideFilter: '' | VoucherSide = '';
+ roleFilter: '' | MyRole = '';
+
+ report: MyWorkReport | null = null;
+ loading = false;
+ error: string | null = null;
+
+ ngOnInit(): void {
+ this.load();
+ }
+
+ load(): void {
+ this.loading = true;
+ this.error = null;
+
+ const { from, to } = this.range();
+ this.service
+ .myVoucherWork(from, to)
+ .pipe(
+ catchError((err: any) => {
+ this.error =
+ err?.error?.errors?.[0]?.defaultUserMessage ??
+ err?.error?.developerMessage ??
+ 'Your work for this period could not be loaded.';
+ return of(null);
+ })
+ )
+ .subscribe((res: MyWorkReport | null) => {
+ this.loading = false;
+ this.report = res;
+ });
+ }
+
+ /**
+ * The rows after the two on-screen filters.
+ *
+ * Filtered here rather than at the server: the day's own work is a small list, and narrowing it
+ * without a round trip is what makes the filters feel like part of reading the page.
+ */
+ get rows(): MyWorkRow[] {
+ const all = this.report?.vouchers ?? [];
+ return all.filter(
+ (r) => (!this.sideFilter || r.side === this.sideFilter) && (!this.roleFilter || r.myRole === this.roleFilter)
+ );
+ }
+
+ /**
+ * The rows split by the part the user played, each group with its own total.
+ *
+ * Entering a request and passing somebody else's are different jobs answerable to different
+ * people, and a branch reads them separately: "what did I put through" is a maker's own tally,
+ * "what did I let through" is a checker's. One list mixed them, so neither total could be read
+ * off the page — a manager who entered one voucher and approved twenty saw twenty-one rows and
+ * had to sort them by eye.
+ *
+ * An empty group is dropped rather than shown as a heading over nothing, so a clerk who never
+ * approves anything sees one section and not a permanently empty second one.
+ */
+ get groups(): { key: MyRole; label: string; hint: string; rows: MyWorkRow[]; total: number }[] {
+ const order: { key: MyRole; label: string; hint: string }[] = [
+ { key: 'RAISED', label: 'Entered by me', hint: 'Requests and postings you put through' },
+ { key: 'VERIFIED', label: 'Approved by me', hint: "Other people's work you passed" },
+ {
+ key: 'RAISED_AND_VERIFIED',
+ label: 'Entered & approved by me',
+ hint: 'Both roles on the same voucher — kept separate for the record'
+ }
+ ];
+ return order
+ .map((g) => {
+ const rows = this.rows.filter((r) => r.myRole === g.key);
+ return { ...g, rows, total: rows.reduce((sum, r) => sum + Number(r.amount ?? 0), 0) };
+ })
+ .filter((g) => g.rows.length > 0);
+ }
+
+ /** True when a filter is hiding part of what was loaded, so the count can say so. */
+ get isFiltered(): boolean {
+ return !!this.sideFilter || !!this.roleFilter;
+ }
+
+ clearFilters(): void {
+ this.sideFilter = '';
+ this.roleFilter = '';
+ }
+
+ /** The total of the rows on screen, which is not the day's total once a filter is on. */
+ get filteredTotal(): number {
+ return this.rows.reduce((sum, r) => sum + Number(r.amount ?? 0), 0);
+ }
+
+ /**
+ * What this person themselves put through today — never narrowed by the Side or My part
+ * filters, which exist only to browse the table below.
+ *
+ * The five summary tiles are pinned to this set on purpose. They answer one question, "what did
+ * I personally do today", and that answer cannot depend on which slice of the list a person
+ * happens to have the table filtered to at the moment — a Received figure that changed whenever
+ * someone picked "Approved" to go look at what they'd passed would not be their own total any
+ * more, it would be whatever the table was showing.
+ */
+ get enteredRows(): MyWorkRow[] {
+ const all = this.report?.vouchers ?? [];
+ return all.filter((r) => r.myRole === 'RAISED' || r.myRole === 'RAISED_AND_VERIFIED');
+ }
+
+ get receivedTotal(): number {
+ return this.enteredRows.filter((r) => r.side === 'CR').reduce((sum, r) => sum + Number(r.amount ?? 0), 0);
+ }
+
+ get paymentTotal(): number {
+ return this.enteredRows.filter((r) => r.side === 'DR').reduce((sum, r) => sum + Number(r.amount ?? 0), 0);
+ }
+
+ get journalTotal(): number {
+ return this.enteredRows
+ .filter((r) => r.side !== 'CR' && r.side !== 'DR')
+ .reduce((sum, r) => sum + Number(r.amount ?? 0), 0);
+ }
+
+ get raisedCount(): number {
+ return this.enteredRows.length;
+ }
+
+ get verifiedCount(): number {
+ const all = this.report?.vouchers ?? [];
+ return all.filter((r) => r.myRole === 'VERIFIED' || r.myRole === 'RAISED_AND_VERIFIED').length;
+ }
+
+ sideLabel(side: VoucherSide | null): string {
+ if (side === 'CR') {
+ return 'Received';
+ }
+ if (side === 'DR') {
+ return 'Payment';
+ }
+ return 'Journal';
+ }
+
+ roleLabel(role: MyRole): string {
+ if (role === 'RAISED') {
+ return 'Entered';
+ }
+ if (role === 'VERIFIED') {
+ return 'Approved';
+ }
+ return 'Entered & approved';
+ }
+
+ /**
+ * Where the voucher stands, in words a branch uses.
+ *
+ * A voucher with no status at all is not unknown — it is a posting that never went through a
+ * request: interest the nightly run credited, a charge taken from a balance. Those are done the
+ * moment they exist, so they read as Posted rather than as a blank a reader has to interpret.
+ */
+ statusLabel(status: string | null): string {
+ const s = (status ?? '').toUpperCase();
+ if (!s) {
+ return 'Posted';
+ }
+ if (s === 'WITHDRAWN') {
+ return 'Paid out';
+ }
+ if (s === 'APPROVED') {
+ return 'Approved';
+ }
+ if (s === 'PENDING') {
+ return 'Pending';
+ }
+ if (s === 'REJECTED') {
+ return 'Rejected';
+ }
+ if (s === 'CLOSED') {
+ return 'Closed';
+ }
+ // OK / MISMATCH come from a physical cash verification rather than a request.
+ return s.charAt(0) + s.slice(1).toLowerCase();
+ }
+
+ /**
+ * Groups the statuses by what a reader does about them.
+ *
+ * 'paid' is kept apart from 'done' because approved and paid out are different facts: one is a
+ * promise, the other is money gone. Sharing a colour hid exactly the thing somebody opens this
+ * page to check.
+ */
+ statusClass(status: string | null): string {
+ const s = (status ?? '').toUpperCase();
+ if (s === 'PENDING') {
+ return 'pending';
+ }
+ if (s === 'REJECTED' || s === 'MISMATCH') {
+ return 'bad';
+ }
+ if (s === 'WITHDRAWN' || s === 'COMPLETED' || s === 'CLOSED') {
+ return 'paid';
+ }
+ return 'done';
+ }
+
+ /**
+ * A glyph per role, so the part played reads before the words do.
+ *
+ * A pen for an entry the user made, a tick for one they approved, and a warning for both on the
+ * same voucher — which a small branch does legitimately, and an auditor should still see marked.
+ */
+ roleIcon(role: MyRole): string {
+ if (role === 'RAISED') {
+ return 'edit';
+ }
+ if (role === 'VERIFIED') {
+ return 'check_circle';
+ }
+ return 'warning';
+ }
+
+ /** The period the anchor date names: that day, or the whole calendar month around it. */
+ private range(): { from: string; to: string } {
+ if (this.period === 'DAY') {
+ return { from: this.anchor, to: this.anchor };
+ }
+ const [
+ y,
+ m
+ ] = this.anchor.split('-').map(Number);
+ const first = `${y}-${String(m).padStart(2, '0')}-01`;
+ // Day 0 of the next month is the last day of this one, which avoids a table of month lengths
+ // and gets February right in a leap year.
+ const last = new Date(y, m, 0);
+ const lastIso = `${last.getFullYear()}-${String(last.getMonth() + 1).padStart(2, '0')}-${String(
+ last.getDate()
+ ).padStart(2, '0')}`;
+ return { from: first, to: lastIso };
+ }
+
+ /** Opens the full slip for one voucher — every debit and credit line, not just the first side of
+ * each a table row has room to show. */
+ openDetail(voucherNo: string): void {
+ if (this.loadingDetailFor) {
+ return;
+ }
+ this.loadingDetailFor = voucherNo;
+ this.service
+ .voucherDetail(voucherNo)
+ .pipe(
+ catchError(() => of(null)),
+ finalize(() => (this.loadingDetailFor = null))
+ )
+ .subscribe((detail: any) => {
+ if (!detail) {
+ return;
+ }
+ this.dialog.open(VoucherDetailDialogComponent, {
+ data: { detail },
+ width: '600px',
+ autoFocus: false
+ });
+ });
+ }
+
+ private todayIso(): string {
+ const d = new Date();
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+ }
+}
diff --git a/src/app/cashier/my-work/voucher-detail-dialog.component.ts b/src/app/cashier/my-work/voucher-detail-dialog.component.ts
new file mode 100644
index 000000000..93137c3c7
--- /dev/null
+++ b/src/app/cashier/my-work/voucher-detail-dialog.component.ts
@@ -0,0 +1,320 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+
+export interface VoucherDetailDialogData {
+ /** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
+ * only renders, so one loading state lives in the row's own click handler rather than being
+ * duplicated inside every dialog that could ever show a voucher. */
+ detail: {
+ voucherNo: string;
+ voucherType: string | null;
+ amount: number | null;
+ branchName: string | null;
+ createdBy: string | null;
+ createdAt: string | null;
+ totalDebit: number;
+ totalCredit: number;
+ balanced: boolean;
+ lines: {
+ lineNo: number;
+ entryType: 'DR' | 'CR';
+ partyName: string | null;
+ accountNo: string | null;
+ amount: number;
+ description: string | null;
+ }[];
+ /** GL entries a native platform command posted for the same underlying event, outside this
+ * voucher entirely — a share redemption's Dr Share Capital / Cr Share Sundry pair, posted at
+ * manager approval, before this voucher's own Dr Sundry / Cr Cash at Vault settlement leg even
+ * exists. Empty for every voucher that already is the whole transaction on its own. */
+ relatedLines: {
+ stage: string;
+ entryType: 'DR' | 'CR';
+ partyName: string | null;
+ accountNo: string | null;
+ amount: number;
+ description: string | null;
+ }[];
+ };
+}
+
+/** The full slip behind a summarised register row — every debit and credit line a voucher is
+ * made of, not just the first side of each that a table row has room for. */
+@Component({
+ selector: 'mifosx-voucher-detail-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ MatDialogModule,
+ MatButtonModule,
+ MatIconModule
+ ],
+ template: `
+
+
+
+
+ Raised by {{ detail.createdBy || '—' }}
+ @if (detail.createdAt) {
+ {{ detail.createdAt | date: 'dd MMM yyyy, h:mm a' }}
+ }
+ @if (!detail.balanced) {
+
+ warning
+ Debit and credit do not balance
+
+ }
+
+
+
+
+
+ Side
+ Party
+ Account No.
+ Amount
+
+
+
+ @for (line of detail.lines; track line.lineNo) {
+
+
+
+ {{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
+
+
+ {{ line.partyName || '—' }}
+ {{ line.accountNo || '—' }}
+ ₹{{ line.amount | number: '1.2-2' }}
+
+ @if (line.description) {
+
+
+ {{ line.description }}
+
+ }
+ } @empty {
+
+ This voucher's individual lines were not recorded.
+
+ }
+
+ @if (detail.lines.length > 0) {
+
+
+ Total
+
+ ₹{{ detail.totalDebit | number: '1.2-2' }} / ₹{{ detail.totalCredit | number: '1.2-2' }}
+
+
+
+ }
+
+
+ @if (detail.relatedLines.length > 0) {
+
+ }
+
+
+ Close
+
+
+ `,
+ styles: [
+ `
+ .vd-shell {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 20px 24px 16px;
+ min-width: 420px;
+ max-width: 560px;
+ }
+ .vd-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ }
+ .vd-header__title {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+ .vd-header__title h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ }
+ .vd-header__title p {
+ margin: 2px 0 0;
+ font-size: 13px;
+ color: rgba(0, 0, 0, 0.6);
+ }
+ .vd-icon {
+ color: #1976d2;
+ }
+ .vd-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 16px;
+ font-size: 13px;
+ color: rgba(0, 0, 0, 0.65);
+ }
+ .vd-unbalanced {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: #b91c1c;
+ font-weight: 600;
+ }
+ .vd-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+ }
+ .vd-table th {
+ text-align: left;
+ text-transform: uppercase;
+ font-size: 11px;
+ letter-spacing: 0.03em;
+ color: rgba(0, 0, 0, 0.5);
+ padding: 6px 8px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+ }
+ .vd-table td {
+ padding: 8px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+ }
+ .vd-table .num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ }
+ .vd-acct {
+ color: rgba(0, 0, 0, 0.55);
+ }
+ .vd-side {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ }
+ .vd-side--dr {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+ .vd-side--cr {
+ background: #dcfce7;
+ color: #15803d;
+ }
+ .vd-desc-row td {
+ padding-top: 0;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+ }
+ .vd-desc {
+ font-size: 12px;
+ color: rgba(0, 0, 0, 0.5);
+ font-style: italic;
+ }
+ .vd-empty {
+ text-align: center;
+ color: rgba(0, 0, 0, 0.5);
+ padding: 16px 8px;
+ }
+ .vd-table tfoot td {
+ font-weight: 600;
+ border-bottom: none;
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
+ }
+ .vd-related {
+ border-top: 1px dashed rgba(0, 0, 0, 0.15);
+ padding-top: 12px;
+ }
+ .vd-related__title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: #6d28d9;
+ margin-bottom: 8px;
+ }
+ .vd-table--related td {
+ background: #faf5ff;
+ }
+ .vd-related__hint {
+ margin: 6px 0 0;
+ font-size: 12px;
+ color: rgba(0, 0, 0, 0.5);
+ }
+ .vd-actions {
+ display: flex;
+ justify-content: flex-end;
+ }
+ `
+ ]
+})
+export class VoucherDetailDialogComponent {
+ dialogRef = inject(MatDialogRef);
+ private data = inject(MAT_DIALOG_DATA);
+
+ detail = this.data.detail;
+}
diff --git a/src/app/cashier/share-collection/share-collection.component.html b/src/app/cashier/share-collection/share-collection.component.html
deleted file mode 100644
index a17ed89f7..000000000
--- a/src/app/cashier/share-collection/share-collection.component.html
+++ /dev/null
@@ -1,375 +0,0 @@
-
-
-
-
-
-
-
- @if (pageState !== 'success') {
-
-
- search
-
-
-
- manage_search
- {{ pageState === 'searching' ? 'Searching…' : 'Search' }}
-
-
- }
-
-
- @if (errorMsg) {
-
- error_outline
- {{ errorMsg }}
-
- }
-
-
- @if (pageState === 'searching') {
-
-
hourglass_empty
-
Looking up account…
-
- }
-
-
- @if (pageState === 'loaded') {
-
-
-
- person
-
-
-
{{ account.clientName }}
-
- {{ account.accountNo }}
- {{ account.productName }}
- {{ account.status?.value }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Account
- Dr / Cr
- Amount
-
-
-
-
- Cash Vault GL
- Dr
- {{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}
-
-
- Share Sundry GL
- Cr
- {{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}
-
- @if (totalChargeAmount > 0) {
-
- Charge Receivable GL
- Cr
- {{ currencySymbol }}{{ totalChargeAmount | number: '1.2-2' }}
-
- }
-
-
-
- info_outline
- Funds move to Share GL & Income GL when account is activated .
-
-
-
-
-
- @if (isCashPayment && allDenominations.length > 0) {
-
-
-
-
- @if (!denomMatchesTotal && denominationTotal > 0) {
-
- warning
- Denomination ₹{{ denominationTotal | number: '1.2-2' }} must equal Total Payable ₹{{
- totalAmount | number: '1.2-2'
- }}.
-
- }
- @if (denominationTotal > 0) {
-
-
- clear Clear
-
-
- }
-
-
- }
-
-
-
-
- Transaction Date *
-
-
-
-
- Payment Mode
-
- — Cash (Default) —
- @for (pt of paymentTypes; track pt.id) {
- {{ pt.name }}
- }
-
-
-
-
- Note (Optional)
-
-
-
-
-
-
- }
-
-
- @if (pageState === 'success' && collectionResult) {
-
-
- check_circle
-
-
Collection Posted Successfully
-
- Cash of
- {{ collectionResult.currencyCode }} {{ collectionResult.totalAmount | number: '1.2-2' }}
- collected from {{ collectionResult.clientName }} on
- {{ collectionResult.collectionDate }} by {{ collectionResult.collectedBy }} .
-
-
-
-
- Transaction ID
- {{ collectionResult.transactionId }}
-
-
-
-
- @for (entry of collectionResult.glEntries; track entry.account) {
-
- {{ entry.type }}. {{ entry.account }}
- {{ entry.currency }} {{ entry.amount | number: '1.2-2' }}
-
- }
-
-
-
- Funds are held in Sundry GL. They will move to Share Equity GL when the account is activated.
-
-
-
- add New Collection
-
-
- }
-
diff --git a/src/app/cashier/share-collection/share-collection.component.scss b/src/app/cashier/share-collection/share-collection.component.scss
deleted file mode 100644
index c3a6606a2..000000000
--- a/src/app/cashier/share-collection/share-collection.component.scss
+++ /dev/null
@@ -1,701 +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/.
- */
-
-// ── Search Bar ───────────────────────────────────────────────────────────────
-
-.sc-search-bar {
- display: flex;
- align-items: center;
- gap: 10px;
- background: #fff;
- border: 1px solid #e2e8f0;
- border-radius: 12px;
- padding: 10px 14px;
-
- &__input-wrap {
- display: flex;
- align-items: center;
- gap: 8px;
- flex: 1 1 auto;
- }
-
- &__icon {
- color: #94a3b8;
- font-size: 20px;
- width: 20px;
- height: 20px;
- flex-shrink: 0;
- }
-
- &__input {
- flex: 1 1 auto;
- border: none;
- outline: none;
- font-size: 14px;
- font-family: inherit;
- color: #0d1b2a;
- background: transparent;
-
- &::placeholder {
- color: #94a3b8;
- }
- }
-}
-
-// ── Alert ────────────────────────────────────────────────────────────────────
-
-.sc-alert {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 12px 16px;
- border-radius: 10px;
- font-size: 13px;
- font-weight: 500;
-
- mat-icon {
- font-size: 20px;
- width: 20px;
- height: 20px;
- flex-shrink: 0;
- }
-
- &--error {
- background: #fef2f2;
- border: 1px solid #fecaca;
- color: #dc2626;
- }
-}
-
-// ── Spinner ──────────────────────────────────────────────────────────────────
-
-.sc-spin {
- animation: sc-spin 1.2s linear infinite;
- display: block;
- margin: 0 auto 8px;
- font-size: 32px !important;
- width: 32px !important;
- height: 32px !important;
- color: #94a3b8;
-}
-
-@keyframes sc-spin {
- from {
- transform: rotate(0deg);
- }
-
- to {
- transform: rotate(360deg);
- }
-}
-
-// ── Member Card ──────────────────────────────────────────────────────────────
-
-.sc-member-card {
- display: flex;
- align-items: center;
- gap: 16px;
- background: #fff;
- border: 1px solid #e2e8f0;
- border-radius: 12px;
- padding: 16px 20px;
-
- &__avatar {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 48px;
- height: 48px;
- border-radius: 50%;
- background: #eff6ff;
- flex-shrink: 0;
-
- mat-icon {
- color: #2563eb;
- font-size: 24px;
- width: 24px;
- height: 24px;
- }
- }
-
- &__info {
- flex: 1 1 auto;
- min-width: 0;
- }
-
- &__name {
- font-size: 16px;
- font-weight: 700;
- color: #1e293b;
- margin-bottom: 6px;
- }
-
- &__meta {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- }
-
- &__shares {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- }
-}
-
-// ── Badges ───────────────────────────────────────────────────────────────────
-
-.sc-badge {
- display: inline-flex;
- align-items: center;
- height: 22px;
- padding: 0 8px;
- border-radius: 5px;
- font-size: 11px;
- font-weight: 700;
- background: #f1f5f9;
- color: #475569;
- border: 1px solid #e2e8f0;
-
- &--account {
- background: #eff6ff;
- color: #1d4ed8;
- border-color: #bfdbfe;
- font-family: 'Roboto Mono', monospace;
- }
-
- &--product {
- background: #f0fdf4;
- color: #15803d;
- border-color: #bbf7d0;
- }
-
- &--pending {
- background: #fffbeb;
- color: #d97706;
- border-color: #fde68a;
- }
-
- &--active {
- background: #f0fdf4;
- color: #15803d;
- border-color: #bbf7d0;
- }
-}
-
-// ── Editable Share Inputs ────────────────────────────────────────────────────
-
-.sc-shares-inputs {
- display: flex;
- align-items: center;
- gap: 10px;
- flex-wrap: wrap;
- justify-content: flex-end;
-}
-
-.sc-shares-field {
- display: flex;
- flex-direction: column;
- gap: 3px;
-}
-
-.sc-shares-label {
- font-size: 10px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.07em;
- color: #94a3b8;
-}
-
-.sc-shares-input {
- width: 110px;
- height: 38px;
- border: 1.5px solid #e2e8f0;
- border-radius: 8px;
- padding: 0 10px;
- font-size: 14px;
- font-weight: 700;
- font-family: inherit;
- color: #1353a0;
- text-align: right;
- outline: none;
- background: #f8fafc;
- transition: border-color 0.15s;
-
- &:focus {
- border-color: #1353a0;
- background: #fff;
- }
-
- &::-webkit-outer-spin-button,
- &::-webkit-inner-spin-button {
- appearance: none;
- }
-}
-
-.sc-shares-sep {
- font-size: 20px;
- font-weight: 300;
- color: #94a3b8;
- padding-top: 16px;
-}
-
-.sc-shares-result {
- text-align: right;
-}
-
-.sc-shares-total {
- font-size: 20px;
- font-weight: 800;
- color: #0f4f3c;
- font-variant-numeric: tabular-nums;
-}
-
-// ── Editable Charge Amount ───────────────────────────────────────────────────
-
-.sc-charge-amount {
- padding: 4px 14px !important;
-}
-
-.sc-charge-input {
- width: 100px;
- height: 32px;
- border: 1.5px solid #e2e8f0;
- border-radius: 6px;
- padding: 0 8px;
- font-size: 13px;
- font-weight: 700;
- font-family: inherit;
- color: #1353a0;
- text-align: right;
- outline: none;
- background: #f8fafc;
- transition: border-color 0.15s;
-
- &:focus {
- border-color: #1353a0;
- background: #fff;
- }
-
- &::-webkit-outer-spin-button,
- &::-webkit-inner-spin-button {
- appearance: none;
- }
-}
-
-// ── Split Layout ─────────────────────────────────────────────────────────────
-
-.sc-split {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 16px;
-
- @media (width <= 900px) {
- grid-template-columns: 1fr;
- }
-}
-
-// ── Breakdown Card ───────────────────────────────────────────────────────────
-
-.sc-breakdown {
- &__header {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 12px 16px;
- background: #f8fafc;
- border-bottom: 1px solid #e2e8f0;
- font-size: 13px;
- font-weight: 700;
- color: #334155;
-
- mat-icon {
- font-size: 18px;
- width: 18px;
- height: 18px;
- color: #0f4f3c;
- }
- }
-}
-
-.sc-gl-preview {
- .sc-breakdown__header {
- mat-icon {
- color: #1d4ed8;
- }
- }
-}
-
-.sc-gl-note {
- display: flex;
- align-items: flex-start;
- gap: 6px;
- padding: 10px 14px;
- font-size: 12px;
- color: #64748b;
- background: #f8fafc;
- border-top: 1px solid #e2e8f0;
-
- mat-icon {
- font-size: 16px;
- width: 16px;
- height: 16px;
- flex-shrink: 0;
- margin-top: 1px;
- color: #94a3b8;
- }
-}
-
-// ── Table helpers ────────────────────────────────────────────────────────────
-
-.sc-dim {
- color: #94a3b8;
- font-size: 12px;
-}
-
-.sc-total-row td {
- background: #f8fafc;
- border-top: 2px solid #e2e8f0;
- font-size: 14px;
-}
-
-.sc-total-amount {
- color: #0f4f3c;
-}
-
-// ── Dr / Cr badges ───────────────────────────────────────────────────────────
-
-.sc-dr-badge,
-.sc-cr-badge {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 20px;
- border-radius: 4px;
- font-size: 11px;
- font-weight: 800;
- letter-spacing: 0.04em;
-}
-
-.sc-dr-badge {
- background: #fef2f2;
- color: #dc2626;
- border: 1px solid #fecaca;
-}
-
-.sc-cr-badge {
- background: #f0fdf4;
- color: #15803d;
- border: 1px solid #bbf7d0;
-}
-
-// ── Footer ───────────────────────────────────────────────────────────────────
-
-.sc-footer {
- justify-content: space-between !important;
-
- &__summary {
- display: flex;
- flex-direction: column;
- gap: 2px;
- }
-
- &__label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.07em;
- color: #64748b;
- }
-
- &__amount {
- font-size: 22px;
- font-weight: 800;
- color: #0f4f3c;
- font-variant-numeric: tabular-nums;
- }
-
- &__actions {
- display: flex;
- align-items: center;
- gap: 10px;
- }
-}
-
-// ── Denomination Panel ────────────────────────────────────────────────────────
-
-.sc-denom {
- padding: 0;
- overflow: hidden;
-}
-
-.sc-denom-body {
- padding: 14px 16px;
-}
-
-.sc-denom-table {
- width: 100%;
-
- th,
- td {
- padding: 7px 10px;
- font-size: 13px;
- }
-
- tfoot td {
- border-top: 2px solid #e2e8f0;
- background: #f8fafc;
- font-size: 14px;
- }
-}
-
-.sc-denom-chip {
- display: inline-flex;
- align-items: center;
- height: 24px;
- padding: 0 10px;
- border-radius: 5px;
- font-size: 12px;
- font-weight: 700;
- background: #eff6ff;
- color: #1d4ed8;
- border: 1px solid #bfdbfe;
-
- &--coin {
- background: #fffbeb;
- color: #d97706;
- border-color: #fde68a;
- }
-}
-
-.sc-denom-stepper {
- display: flex;
- align-items: center;
- gap: 4px;
- justify-content: flex-end;
-
- button {
- width: 26px;
- height: 26px;
- border: 1.5px solid #e2e8f0;
- border-radius: 5px;
- background: #f8fafc;
- color: #475569;
- font-size: 16px;
- line-height: 1;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
-
- &:hover:not(:disabled) {
- background: #e0f2fe;
- border-color: #7dd3fc;
- color: #0369a1;
- }
-
- &:disabled {
- opacity: 0.35;
- cursor: not-allowed;
- }
- }
-
- input {
- width: 62px;
- height: 28px;
- border: 1.5px solid #e2e8f0;
- border-radius: 6px;
- padding: 0 6px;
- font-size: 13px;
- font-weight: 600;
- font-family: inherit;
- text-align: right;
- outline: none;
- background: #fff;
-
- &:focus {
- border-color: #1353a0;
- }
-
- &::-webkit-outer-spin-button,
- &::-webkit-inner-spin-button {
- appearance: none;
- }
- }
-}
-
-.sc-denom-badge {
- margin-left: auto;
- display: inline-flex;
- align-items: center;
- height: 22px;
- padding: 0 10px;
- border-radius: 5px;
- font-size: 12px;
- font-weight: 700;
-
- &--match {
- background: #f0fdf4;
- color: #15803d;
- border: 1px solid #bbf7d0;
- }
-
- &--mismatch {
- background: #fef2f2;
- color: #dc2626;
- border: 1px solid #fecaca;
- }
-}
-
-.sc-denom-required {
- margin-left: auto;
- font-size: 12px;
- font-weight: 700;
- color: #d97706;
-}
-
-.sc-denom-mismatch {
- display: flex;
- align-items: center;
- gap: 6px;
- margin-top: 10px;
- padding: 9px 12px;
- background: #fef2f2;
- border: 1px solid #fecaca;
- border-radius: 8px;
- font-size: 12px;
- font-weight: 600;
- color: #dc2626;
-
- mat-icon {
- font-size: 16px;
- width: 16px;
- height: 16px;
- flex-shrink: 0;
- }
-}
-
-.sc-denom-actions {
- display: flex;
- justify-content: flex-end;
- margin-top: 10px;
-}
-
-// ── Success ──────────────────────────────────────────────────────────────────
-
-.sc-success {
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- padding: 40px 24px;
- gap: 16px;
- background: #fff;
- border: 1px solid #e2e8f0;
- border-radius: 16px;
-
- &__icon-wrap {
- width: 72px;
- height: 72px;
- border-radius: 50%;
- background: #f0fdf4;
- display: flex;
- align-items: center;
- justify-content: center;
-
- mat-icon {
- font-size: 40px;
- width: 40px;
- height: 40px;
- color: #16a34a;
- }
- }
-
- &__title {
- font-size: 22px;
- font-weight: 700;
- color: #1e293b;
- margin: 0;
- }
-
- &__sub {
- font-size: 14px;
- color: #64748b;
- max-width: 480px;
- margin: 0;
- }
-
- &__ref {
- display: flex;
- align-items: center;
- gap: 10px;
- background: #f1f5f9;
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- padding: 8px 16px;
- }
-
- &__ref-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.07em;
- color: #94a3b8;
- white-space: nowrap;
- }
-
- &__ref-val {
- font-family: 'Roboto Mono', 'Courier New', monospace;
- font-size: 13px;
- font-weight: 600;
- color: #1353a0;
- }
-
- &__note {
- font-size: 12px;
- color: #94a3b8;
- max-width: 420px;
- margin: 0;
- text-align: center;
- }
-
- &__gl {
- display: flex;
- flex-direction: column;
- gap: 6px;
- width: 100%;
- max-width: 400px;
- background: #f8fafc;
- border: 1px solid #e2e8f0;
- border-radius: 10px;
- padding: 14px 16px;
- }
-
- &__gl-row {
- display: flex;
- justify-content: space-between;
- font-size: 13px;
- font-weight: 600;
- padding: 4px 0;
- border-bottom: 1px dotted #e2e8f0;
-
- &:last-child {
- border-bottom: none;
- }
-
- &--dr {
- color: #dc2626;
- }
-
- &--cr {
- color: #15803d;
- }
- }
-}
diff --git a/src/app/cashier/share-collection/share-collection.component.ts b/src/app/cashier/share-collection/share-collection.component.ts
deleted file mode 100644
index e9d2119f3..000000000
--- a/src/app/cashier/share-collection/share-collection.component.ts
+++ /dev/null
@@ -1,282 +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 { MatIconModule } from '@angular/material/icon';
-import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatSelectModule } from '@angular/material/select';
-import { MatInputModule } from '@angular/material/input';
-import { catchError } from 'rxjs/operators';
-import { of } from 'rxjs';
-import { CashierService } from '../cashier.service';
-
-type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success';
-
-interface Denomination {
- id: number;
- value: number;
- kind: 'note' | 'coin';
- label: string;
-}
-
-interface ShareCharge {
- id: number;
- name: string;
- amount: number;
- currencySymbol: string;
-}
-
-@Component({
- selector: 'mifosx-share-collection',
- templateUrl: './share-collection.component.html',
- styleUrls: [
- '../cashier-shared.scss',
- './share-collection.component.scss'
- ],
- standalone: true,
- imports: [
- CommonModule,
- FormsModule,
- RouterModule,
- MatIconModule,
- MatSnackBarModule,
- MatFormFieldModule,
- MatSelectModule,
- MatInputModule
- ]
-})
-export class ShareCollectionComponent implements OnInit {
- private service = inject(CashierService);
- private snackBar = inject(MatSnackBar);
-
- pageState: PageState = 'idle';
- accountInput = '';
- transactionDate = '';
- paymentTypeId: number | null = null;
- paymentTypes: any[] = [];
- note = '';
- errorMsg = '';
- saving = false;
-
- account: any = null;
- charges: ShareCharge[] = [];
- collectionResult: any = null; // filled after successful POST
-
- // Editable fields — cashier can override if API returns 0
- requestedSharesInput = 0;
- pricePerShare = 0;
-
- // ── Denomination ────────────────────────────────────────────────────────────
- notes: Denomination[] = [];
- coins: Denomination[] = [];
- denominationCounts: Record = {};
-
- get shareAmount(): number {
- return this.requestedSharesInput * this.pricePerShare;
- }
-
- get totalChargeAmount(): number {
- return this.charges.reduce((s, c) => s + c.amount, 0);
- }
-
- get totalAmount(): number {
- return this.shareAmount + this.totalChargeAmount;
- }
-
- get currencySymbol(): string {
- return this.account?.currency?.displaySymbol ?? '₹';
- }
-
- get isCashPayment(): boolean {
- if (this.paymentTypeId === null) return true;
- const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId);
- return !!pt?.isCashPayment;
- }
-
- get allDenominations(): Denomination[] {
- const desc = (a: Denomination, b: Denomination) => b.value - a.value;
- return [
- ...[...this.notes].sort(desc),
- ...[...this.coins].sort(desc)
- ];
- }
-
- get denominationTotal(): number {
- return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0);
- }
-
- get denomMatchesTotal(): boolean {
- return this.denominationTotal === this.totalAmount;
- }
-
- get isValid(): boolean {
- if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false;
- if (this.isCashPayment && this.allDenominations.length > 0) {
- return this.denomMatchesTotal;
- }
- return true;
- }
-
- ngOnInit(): void {
- const d = new Date();
- this.transactionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
- this.service
- .getPaymentTypes()
- .pipe(catchError(() => of([])))
- .subscribe((types: any[]) => (this.paymentTypes = types || []));
- this.loadDenominations();
- }
-
- private loadDenominations(): void {
- this.service
- .getActiveDenominations()
- .pipe(catchError(() => of([])))
- .subscribe((list: any[]) => {
- const rows: Denomination[] = (list || []).map((d: any) => ({
- id: d.id,
- value: d.denomination ?? d.value,
- kind: d.type === 'coin' ? 'coin' : 'note',
- label: '₹' + (d.denomination ?? d.value)
- }));
- const order = (a: Denomination, b: Denomination) => b.value - a.value;
- this.notes = rows.filter((r) => r.kind === 'note').sort(order);
- this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
- });
- }
-
- // ── Denomination helpers ──────────────────────────────────────────────────
-
- denomKey(d: Denomination): string {
- return `${d.kind}:${d.value}`;
- }
-
- countFor(d: Denomination): number {
- return this.denominationCounts[this.denomKey(d)] || 0;
- }
-
- subtotalFor(d: Denomination): number {
- return this.countFor(d) * d.value;
- }
-
- onDenomChange(d: Denomination, raw: any): void {
- this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
- }
-
- bumpDenom(d: Denomination, delta: number): void {
- this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
- }
-
- clearDenominations(): void {
- this.denominationCounts = {};
- }
-
- searchAccount(): void {
- const query = this.accountInput.trim();
- if (!query) return;
- this.pageState = 'searching';
- this.account = null;
- this.charges = [];
- this.errorMsg = '';
-
- this.service
- .getShareAccountForCollection(query)
- .pipe(
- catchError((err) => {
- this.errorMsg =
- err?.error?.errors?.[0]?.defaultUserMessage ||
- err?.error?.developerMessage ||
- 'Account not found. Please check the account ID and try again.';
- this.pageState = 'idle';
- return of(null);
- })
- )
- .subscribe((acc: any) => {
- if (!acc) return;
- this.account = acc;
-
- // Auto-detect shares from summary or first pending purchasedShares entry
- const pendingTx = (acc.purchasedShares || []).find(
- (t: any) =>
- t.status?.code === 'purchasedSharesStatusType.applied' && t.type?.code === 'purchasedSharesType.purchased'
- );
- this.requestedSharesInput = Number(
- acc.summary?.totalPendingForApprovalShares || pendingTx?.numberOfShares || acc.requestedShares || 0
- );
- this.pricePerShare = Number(pendingTx?.purchasedPrice || acc.currentMarketPrice || acc.unitPrice || 0);
-
- // Show ALL charges on the account — no type filtering
- this.charges = (acc.charges || []).map((c: any) => ({
- id: c.chargeId ?? c.id,
- name: c.name,
- amount: Number(c.amount ?? c.amountOutstanding ?? 0),
- currencySymbol: c.currency?.displaySymbol ?? '₹'
- }));
-
- this.pageState = 'loaded';
- });
- }
-
- recalc(): void {
- // triggered by input changes — nothing extra needed, getters auto-recalculate
- }
-
- submit(): void {
- if (!this.isValid || this.saving) return;
- this.saving = true;
- const payload = {
- shareAccountId: this.account.id,
- accountNo: this.account.accountNo,
- clientName: this.account.clientName,
- transactionDate: this.transactionDate,
- requestedShares: this.requestedSharesInput,
- pricePerShare: this.pricePerShare,
- shareAmount: this.shareAmount,
- chargeAmount: this.totalChargeAmount,
- totalAmount: this.totalAmount,
- paymentTypeId: this.paymentTypeId || null,
- note: this.note || null
- };
-
- this.service.collectSharePayment(payload).subscribe({
- next: (result: any) => {
- this.collectionResult = result;
- this.snackBar.open('Share collection posted successfully.', 'Close', { duration: 3000 });
- this.pageState = 'success';
- this.saving = false;
- },
- error: (err: any) => {
- const msg =
- err?.error?.errors?.[0]?.defaultUserMessage ||
- err?.error?.developerMessage ||
- err?.error?.defaultUserMessage ||
- 'Collection failed. Please try again.';
- this.snackBar.open(msg, 'Close', { duration: 8000 });
- this.saving = false;
- }
- });
- }
-
- reset(): void {
- this.pageState = 'idle';
- this.accountInput = '';
- this.account = null;
- this.charges = [];
- this.errorMsg = '';
- this.note = '';
- this.paymentTypeId = null;
- this.requestedSharesInput = 0;
- this.pricePerShare = 0;
- this.collectionResult = null;
- this.saving = false;
- this.clearDenominations();
- }
-}
diff --git a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts
new file mode 100644
index 000000000..0c232ba68
--- /dev/null
+++ b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts
@@ -0,0 +1,910 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
+import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+
+export interface CashierIssueDdDialogData {
+ leaflet: any;
+ branchName: string;
+ sponsorBankName: string;
+ faceValue: number;
+ chargesAmount: number;
+ gstPercentage: number;
+ gstAmount: number;
+ totalCashPayable: number;
+ /**
+ * How the draft is being paid for. Cash is a walk-in purchaser the bank may know nothing about,
+ * so their name, address and Aadhaar have to be captured here. An account debit is a member whose
+ * account is already on screen — those fields are not asked for, and must not be sent, because
+ * the identity is the account holder's rather than a walk-in's.
+ */
+ paymentMethod?: 'CASH' | 'ACCOUNT_DEBIT';
+ /** The account being debited, shown so the clerk can see what is funding the draft. */
+ accountNo?: string;
+ /** The account holder, for the same reason. */
+ accountHolderName?: string;
+ /**
+ * How many drafts this slip raises. Absent or 1 is the ordinary single draft and the dialog is
+ * unchanged; 2 or more turns it into a slip, asking for a face value, charge and beneficiary per
+ * draft and reporting the combined total the account will be debited for.
+ */
+ ddCount?: number;
+ /**
+ * Whether the funding account is a GL account rather than a member's. Only affects wording: a GL
+ * account has no passbook, so it is not promised one.
+ */
+ glFunded?: boolean;
+ /**
+ * The leaflets provisionally shown against each line of a slip.
+ *
+ * Only ever indicative. The server allocates the real numbers inside the locked transaction that
+ * submits the slip, so what is shown here is the next-available run at the moment the dialog was
+ * opened — accurate unless another clerk submits first, which is precisely why the numbers cannot
+ * be promised until submit returns them.
+ */
+ previewDdNumbers?: string[];
+}
+
+/** One captured draft on a multi-DD slip. */
+export interface DdSlipLineResult {
+ beneficiaryName: string;
+ payableAtBranch: string;
+ payableAtCity: string;
+ faceValue: number;
+ chargesAmount: number;
+}
+
+@Component({
+ selector: 'mifosx-cashier-issue-dd-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ ReactiveFormsModule,
+ MatDialogModule,
+ MatFormFieldModule,
+ MatInputModule,
+ MatButtonModule,
+ MatIconModule
+ ],
+ template: `
+
+
+
+
+
+ @if (isSlip) {
+
+
+ Drafts On This Slip
+ {{ lineCount }}
+ {{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}
+
+
+
+ Total Face Value
+ ₹{{ slipFaceValue | number: '1.2-2' }}
+ + Charges ₹{{ slipCharges | number: '1.2-2' }}
+
+
+
+ GST ({{ data.gstPercentage }}%)
+ ₹{{ slipGst | number: '1.2-2' }}
+ on charges only
+
+
+
+ Total To Debit
+ ₹{{ slipTotal | number: '1.2-2' }}
+ {{ data.accountNo || 'Account' }} · one debit
+
+
+
+
+
+
info
+
+ DD numbers below are provisional . The final numbers are allocated when the slip is
+ submitted and will be confirmed on the receipt.
+
+
+ } @else {
+
+
+
+
+ DD Number Being Issued
+ {{ data.leaflet.ddNumber }}
+ {{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}
+
+
+
+ Face Value
+ ₹{{ data.faceValue | number: '1.2-2' }}
+ + Charges ₹{{ data.chargesAmount | number: '1.2-2' }}
+
+
+
+ GST ({{ data.gstPercentage }}%)
+ ₹{{ data.gstAmount | number: '1.2-2' }}
+ on charges only
+
+
+
+ {{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}
+ ₹{{ data.totalCashPayable | number: '1.2-2' }}
+
+ @if (isAccountDebit) {
+ {{ data.accountNo || 'Account' }}
+ } @else {
+ Denominations verified
+ }
+
+
+
+ }
+
+
+ @if (isAccountDebit) {
+
+
account_balance_wallet
+
+
Debiting {{ data.accountNo }}
+ @if (data.accountHolderName) {
+ · {{ data.accountHolderName }}
+ }
+
+ @if (isSlip) {
+ ₹{{ slipTotal | number: '1.2-2' }} will be debited from this account as a
+ single transaction covering all {{ lineCount }} drafts, when a checker approves the
+ slip —
+
+ @if (isGlFunded) {
+ one journal entry against this GL account, not {{ lineCount }}.
+ } @else {
+ the member's passbook shows one entry, not {{ lineCount }}.
+ }
+ } @else {
+ ₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
+ approves. No cash is taken at the counter.
+ }
+
+
+
+ }
+
+
+ @if (isSlip) {
+
+
+ @for (lineCtrl of slipLines.controls; track $index) {
+
+ }
+
+
+ } @else {
+
+
+ @if (!isAccountDebit) {
+ Walk-in Purchaser Details
+
+
+
+ Walk-in Purchaser Name *
+
+ Purchaser name is required
+ Must be at least 3 characters
+
+
+
+ 12-Digit Aadhaar Number *
+
+ Masked to last 4 digits before storage
+ Aadhaar number is required
+ Must be exactly 12 numeric digits
+
+
+
+
+ Purchaser Address / Location *
+
+ Address is required
+
+ }
+
+
+ Beneficiary & Payment Details
+
+
+ Beneficiary Name (In Favour Of) *
+
+ Beneficiary name is required
+ Max 120 characters
+
+
+
+
+ Payable At - Branch *
+
+ Payable Branch is required
+
+
+
+ Payable At - City *
+
+ Payable City is required
+
+
+
+ }
+
+
+
+ @if (isSlip) {
+
+ {{ lineCount }} drafts · ₹{{ slipTotal | number: '1.2-2' }} to debit
+
+ }
+ Cancel
+
+ {{ isSlip ? 'Save Slip Details' : 'Save DD Details' }}
+
+
+
+ `,
+ styles: [
+ `
+ .dd-dialog-shell {
+ display: flex;
+ flex-direction: column;
+ max-height: 90vh;
+ }
+ .dd-dialog-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 24px;
+ background: #0f172a;
+ color: #ffffff;
+ border-top-left-radius: 8px;
+ border-top-right-radius: 8px;
+ }
+ .dd-dialog-header__title {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+ .dd-dialog-header__title h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: #ffffff;
+ }
+ .dd-dialog-header__title p {
+ margin: 2px 0 0 0;
+ font-size: 12px;
+ color: #94a3b8;
+ }
+ .dd-icon {
+ color: #38bdf8;
+ font-size: 28px;
+ width: 28px;
+ height: 28px;
+ }
+ .close-btn {
+ color: #94a3b8;
+ }
+ .close-btn:hover {
+ color: #ffffff;
+ }
+ .dd-dialog-body {
+ padding: 20px 24px;
+ overflow-y: auto;
+ }
+ .dd-summary-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ padding: 14px 18px;
+ margin-bottom: 20px;
+ }
+ .summary-col {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+ .summary-col.highlight-col {
+ background: #ecfdf5;
+ padding: 6px 12px;
+ border-radius: 6px;
+ border: 1px solid #a7f3d0;
+ }
+ .summary-divider {
+ width: 1px;
+ height: 36px;
+ background: #e2e8f0;
+ }
+ .summary-col .label {
+ font-size: 11px;
+ text-transform: uppercase;
+ color: #64748b;
+ font-weight: 600;
+ }
+ .dd-number-highlight {
+ font-size: 16px;
+ color: #0369a1;
+ font-family: monospace;
+ }
+ .summary-col .amount {
+ font-size: 15px;
+ color: #1e293b;
+ }
+ .summary-col .total-amount {
+ font-size: 16px;
+ color: #065f46;
+ }
+ .summary-col .sublabel {
+ font-size: 11px;
+ color: #94a3b8;
+ }
+ .form-section-title {
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: #475569;
+ margin: 16px 0 10px 0;
+ border-bottom: 1px solid #f1f5f9;
+ padding-bottom: 4px;
+ }
+ .form-section-title:first-child {
+ margin-top: 0;
+ }
+ .dd-form {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .full-width {
+ width: 100%;
+ }
+ .form-row {
+ display: flex;
+ gap: 16px;
+ }
+ .form-field-flex {
+ flex: 1;
+ }
+ .dd-dialog-actions {
+ padding: 12px 24px;
+ border-top: 1px solid #e2e8f0;
+ }
+ /* The account about to be debited — read before the form, so a wrong one is caught here. */
+ .dd-funding-note {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ margin: 14px 0 4px;
+ padding: 11px 14px;
+ background: #eff6ff;
+ border: 1px solid #bfdbfe;
+ border-left: 3px solid #1d4ed8;
+ border-radius: 6px;
+ font-size: 13px;
+ color: #334155;
+ }
+ .dd-funding-note mat-icon {
+ color: #1d4ed8;
+ flex-shrink: 0;
+ }
+ .dd-funding-sub {
+ margin-top: 3px;
+ font-size: 11.5px;
+ line-height: 1.5;
+ color: #64748b;
+ }
+ /* One card per draft on a slip — the boundary is what stops a clerk typing line 2's
+ beneficiary against line 1's amount. */
+ .dd-line-card {
+ border: 1px solid #e2e8f0;
+ border-left: 3px solid #0369a1;
+ border-radius: 8px;
+ padding: 14px 16px 4px;
+ margin-bottom: 14px;
+ background: #fcfdff;
+ }
+ .dd-line-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 12px;
+ }
+ .dd-line-badge {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: #ffffff;
+ background: #0369a1;
+ padding: 3px 9px;
+ border-radius: 999px;
+ }
+ .dd-line-number {
+ font-family: monospace;
+ font-size: 12px;
+ color: #475569;
+ }
+ .dd-line-number em {
+ font-style: normal;
+ color: #94a3b8;
+ font-size: 11px;
+ }
+ .dd-line-total {
+ margin-left: auto;
+ font-size: 12px;
+ font-weight: 600;
+ color: #065f46;
+ background: #ecfdf5;
+ border: 1px solid #a7f3d0;
+ padding: 3px 10px;
+ border-radius: 6px;
+ }
+ .dd-preview-note {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ margin: 0 0 16px;
+ padding: 9px 12px;
+ background: #fffbeb;
+ border: 1px solid #fde68a;
+ border-radius: 6px;
+ font-size: 12px;
+ line-height: 1.5;
+ color: #78350f;
+ }
+ .dd-preview-note mat-icon {
+ color: #b45309;
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+ }
+ .dd-actions-total {
+ margin-right: auto;
+ font-size: 13px;
+ color: #475569;
+ }
+ .dd-actions-total strong {
+ color: #065f46;
+ }
+ `
+ ]
+})
+export class CashierIssueDdDialogComponent implements OnInit {
+ private fb = inject(FormBuilder);
+ private dialogRef = inject(MatDialogRef);
+ public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
+
+ form!: FormGroup;
+ /** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */
+ slipForm!: FormGroup;
+
+ /** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
+ get isAccountDebit(): boolean {
+ return this.data.paymentMethod === 'ACCOUNT_DEBIT';
+ }
+
+ /** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */
+ get lineCount(): number {
+ const n = Number(this.data.ddCount) || 1;
+ return n > 1 ? n : 1;
+ }
+
+ /** Whether this dialog is capturing a multi-DD slip rather than a single draft. */
+ get isSlip(): boolean {
+ return this.lineCount > 1;
+ }
+
+ /** Whether the debit lands on a GL account rather than a member's — wording only. */
+ get isGlFunded(): boolean {
+ return !!this.data.glFunded;
+ }
+
+ /** The form the Save button validates against — whichever of the two was built. */
+ get activeForm(): FormGroup {
+ return this.isSlip ? this.slipForm : this.form;
+ }
+
+ get slipLines(): FormArray {
+ return this.slipForm.get('lines') as FormArray;
+ }
+
+ /** The provisional leaflet number shown against a line, if the caller supplied a run of them. */
+ previewNumberAt(index: number): string | null {
+ return this.data.previewDdNumbers?.[index] ?? null;
+ }
+
+ private lineValueAt(index: number, field: string): number {
+ return Number(this.slipLines.at(index)?.get(field)?.value) || 0;
+ }
+
+ /**
+ * GST on one line's charge.
+ *
+ * Rounded per line, matching the server, which computes and stores GST against each draft rather
+ * than once for the slip. Summing rounded lines and rounding a summed charge can differ by a
+ * paisa, and it is the per-line figure that ends up on the draft.
+ */
+ lineGstAt(index: number): number {
+ const charges = this.lineValueAt(index, 'chargesAmount');
+ if (charges <= 0) return 0;
+ return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100;
+ }
+
+ /** What one line adds to the debit — its face value, its charge and the GST on that charge. */
+ lineTotalAt(index: number): number {
+ return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index);
+ }
+
+ get slipFaceValue(): number {
+ return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0);
+ }
+
+ get slipCharges(): number {
+ return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0);
+ }
+
+ get slipGst(): number {
+ return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0);
+ }
+
+ /** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */
+ get slipTotal(): number {
+ return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9);
+ }
+
+ ngOnInit(): void {
+ if (this.isSlip) {
+ this.slipForm = this.fb.group({
+ lines: this.fb.array(
+ Array.from({ length: this.lineCount }, () =>
+ this.fb.group({
+ beneficiaryName: [
+ '',
+ [
+ Validators.required,
+ Validators.maxLength(120)
+ ]
+ ],
+ payableAtBranch: [
+ '',
+ [Validators.required]
+ ],
+ payableAtCity: [
+ '',
+ [Validators.required]
+ ],
+ // Zero is rejected rather than merely warned about: the server refuses a line with a
+ // face value at or below zero, and a slip that fails there has already cost the clerk
+ // every other line they typed.
+ faceValue: [
+ null as
+ | number
+ | null,
+ [
+ Validators.required,
+ Validators.min(0.01)
+ ]
+ ],
+ chargesAmount: [
+ 0,
+ [Validators.min(0)]
+ ]
+ })
+ )
+ )
+ });
+ return;
+ }
+
+ // Walk-in identity is required for cash and rejected outright for an account debit — the server
+ // refuses a walk-in name alongside an account — so the controls are only built when they apply.
+ // Registering them unconditionally and merely hiding them would hold the form invalid on a
+ // required field the clerk cannot see.
+ this.form = this.fb.group(
+ this.isAccountDebit
+ ? {
+ beneficiaryName: [
+ '',
+ [
+ Validators.required,
+ Validators.maxLength(120)
+ ]
+ ],
+ payableAtBranch: [
+ '',
+ [Validators.required]
+ ],
+ payableAtCity: [
+ '',
+ [Validators.required]
+ ]
+ }
+ : {
+ walkinPurchaserName: [
+ '',
+ [
+ Validators.required,
+ Validators.minLength(3)
+ ]
+ ],
+ walkinPurchaserAddress: [
+ '',
+ [Validators.required]
+ ],
+ walkinPurchaserAadhaar: [
+ '',
+ [
+ Validators.required,
+ Validators.pattern(/^[0-9]{12}$/)
+ ]
+ ],
+ beneficiaryName: [
+ '',
+ [
+ Validators.required,
+ Validators.maxLength(120)
+ ]
+ ],
+ payableAtBranch: [
+ '',
+ [Validators.required]
+ ],
+ payableAtCity: [
+ '',
+ [Validators.required]
+ ]
+ }
+ );
+ }
+
+ onClose(): void {
+ this.dialogRef.close();
+ }
+
+ onSubmit(): void {
+ if (this.activeForm.invalid) return;
+
+ // A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on
+ // a member-account debit, where the purchaser is the account holder already on record.
+ if (this.isSlip) {
+ const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => {
+ const v = c.value;
+ return {
+ beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(),
+ payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(),
+ payableAtCity: (v.payableAtCity || '').trim().toUpperCase(),
+ faceValue: Number(v.faceValue) || 0,
+ chargesAmount: Number(v.chargesAmount) || 0
+ };
+ });
+ this.dialogRef.close({ lines });
+ return;
+ }
+
+ const val = this.form.value;
+ const beneficiary = {
+ beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
+ payableAtBranch: (val.payableAtBranch || '').trim().toUpperCase(),
+ payableAtCity: (val.payableAtCity || '').trim().toUpperCase()
+ };
+ // Walk-in fields are left off entirely on an account debit rather than sent empty: the server
+ // rejects a purchaser name arriving alongside an account, and '' would be sent as one.
+ this.dialogRef.close(
+ this.isAccountDebit
+ ? beneficiary
+ : {
+ walkinPurchaserName: (val.walkinPurchaserName || '').trim().toUpperCase(),
+ walkinPurchaserAddress: (val.walkinPurchaserAddress || '').trim().toUpperCase(),
+ walkinPurchaserAadhaar: (val.walkinPurchaserAadhaar || '').trim(),
+ ...beneficiary
+ }
+ );
+ }
+}
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html
index 12498d5b3..586117016 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.html
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.html
@@ -120,12 +120,9 @@
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
- ~
- Withdrawal Slip
- Cheque
- Disbursement
- Enter Bank Transfer
- DD (Demand Draft)
+ @for (t of typOptions; track t.value) {
+ {{ t.label }}
+ }
@@ -137,6 +134,23 @@
027 · Industrial
+ @if (row.typ === 'DD') {
+
+ SPONSOR BANK
+
+ @if (sponsorBanks.length === 0) {
+ -- Select Bank --
+ }
+ @for (sb of sponsorBanks; track sb.id) {
+ {{ sb.name }}
+ }
+
+
+ }
TXN TP.
- — Select —
@for (pt of paymentTypesForRow(row); track pt.id) {
{{ pt.name }}
}
@@ -153,22 +166,50 @@
AC. TP.
-
- @for (a of acTypOptions; track a) {
- {{ a }}
- }
-
+ @if (acTypLockedByToken && row === selectedRow) {
+ {{ acTypLockedLabel }}
+ } @else {
+
+ @for (a of acTypOptionsFor(row); track a) {
+ {{ a }}
+ }
+
+ }
+ @if (showsEntryType(row)) {
+
+ ENTRY TYPE
+
+ Select…
+ @for (t of entryTypeOptions; track t.id) {
+ {{ t.name }}
+ }
+
+
+ }
@@ -185,7 +226,28 @@
(input)="onAcNoInput(row, i, $any($event.target).value)"
(click)="$event.stopPropagation()"
(blur)="onAcNoBlur(row, i)"
- placeholder="Account number"
+ [placeholder]="
+ isDdAccountDebitRow(row)
+ ? 'Account to debit'
+ : row.typ === 'DD'
+ ? ddGlMapping?.ddPayableGlCode || 'Auto GL'
+ : 'Account number'
+ "
+ [readonly]="
+ (row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
+ "
+ [class.ws-f__ctl--readonly]="
+ (row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
+ "
+ [title]="
+ acTypLockedByToken && row === selectedRow
+ ? 'Locked — filled from a token-driven request, not a normal account entry'
+ : isDdAccountDebitRow(row)
+ ? 'Member account the draft is funded from'
+ : row.typ === 'DD'
+ ? 'Configured Sponsor DD Payable GL (Auto-selected)'
+ : 'Account number'
+ "
/>
@@ -205,7 +267,10 @@
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 !== 'Enter Bank Transfer'
+ row.acTyp === 'GL' &&
+ row.typ !== 'Disbursement' &&
+ row.typ !== 'Fund Transfer' &&
+ row.typ !== 'Enter Bank Transfer'
"
(click)="row.dbCr = 'Dr'"
>
@@ -236,14 +301,14 @@
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
>
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
- @if (!isClerk && activeWithdrawalRequest) {
-
🔒
+ @if (!isClerk && (activeWithdrawalRequest || activeClosureRequest)) {
+
🔒
}
@if (row.acTyp !== 'SHR') {
₹
@@ -254,9 +319,13 @@
[value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
- [placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
- [readonly]="!isClerk && !!activeWithdrawalRequest"
- [class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
+ [placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
+ [readonly]="
+ isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
+ "
+ [class.ws-amt-input--locked]="
+ isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
+ "
/>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
@@ -266,8 +335,21 @@
}}/share
}
+
+ @if (isMultiDdRow(row)) {
+
+ {{
+ row.ddLines?.length
+ ? 'Total of ' + row.ddLines?.length + ' drafts'
+ : 'Set per draft in Fill DD Details'
+ }}
+
+ }
- @if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
+ @if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
RESOLUTION NO.
- } @else if (row.typ !== 'Withdrawal Slip') {
+ } @else if (isClerk && row.typ !== 'Withdrawal Slip') {
CHEQUE NO.
- @if (isDdCashGlRow(row)) {
+ @if (isDdRow(row)) {
DD Charges & GST — Admin Rate {{ ddGstPercentage }}%
+
+ @if (canRowBeMultiDd(row)) {
+
+
NO. OF DD
+
+
+
+ @if (isMultiDdRow(row)) {
+
One debit · {{ ddCountFor(row) }} drafts
+ }
+
+ }
CHARGES
-
+
₹
+
+ @if (isMultiDdRow(row)) {
+
Set per draft
+ }
@@ -368,25 +484,57 @@
+
+
+
+ {{
+ row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
+ }}
+
+ {{
+ row.ddLines?.length
+ ? row.ddLines?.length + ' Drafts Captured · Edit Slip'
+ : row.ddDetails
+ ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
+ : isMultiDdRow(row)
+ ? 'Fill ' + ddCountFor(row) + ' DD Details'
+ : 'Fill DD Details'
+ }}
+
+
@@ -398,6 +546,18 @@
}
+
+ @if (selectedRow?.typ === 'DD' && !isDdGlMappingConfigured) {
+
+ }
+
@if (!isClerk && loadingWithdrawal) {
@@ -436,6 +596,73 @@
}
+ @if (isClerk && pendingClosure) {
+
+
lock
+
+
Closing This Account
+
+ @if (closurePreview) {
+ Balance: ₹{{ closurePreview.balance | number: '1.2-2' }}
+ ·
+ Interest: +₹{{ closurePreview.interest | number: '1.2-2' }}
+ ·
+ Payable: ₹{{ closurePreview.total | number: '1.2-2' }}
+ }
+
+
+ Enter TOK. NO. below and press Save to send this for the Bank Manager's
+ approval. The account closes when the cashier hands over the cash.
+
+
+
+ close
+
+
+ }
+ @if (!isClerk && activeClosureRequest) {
+
+
lock
+
+
Approved Account Closure Linked
+
+ Token: {{ activeClosureRequest.tok_no }}
+ ·
+ Cash to Hand Over:
+ ₹{{ activeClosureRequest.cashier_disbursed_amount | number: '1.2-2' }}
+ ·
+ Client: {{ activeClosureRequest.client_name }}
+ ·
+ By Clerk: {{ activeClosureRequest.clerk_name }}
+ ·
+ Approved by: {{ activeClosureRequest.manager_name || '—' }}
+
+
+ @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.
+ }
+
+
+
+ close
+
+
+ }
@if (loadingAccount) {
@@ -804,7 +1031,7 @@
4. Freeze Amount
- {{ currencySymbol }}0.00
+ {{ currencySymbol }}{{ freezeAmount | number: '1.2-2' }}
5. Withdrawable Amt.
@@ -865,7 +1092,7 @@
Cash Hold
- 0.00
+ {{ cashHold | number: '1.2-2' }}
Transfer Hold
@@ -873,7 +1100,7 @@
Clearing Hold
- 0.00
+ {{ clearingHold | number: '1.2-2' }}
After Trans.
@@ -889,6 +1116,11 @@
Currency
{{ accountDetails.currency?.code ?? 'INR' }}
+ @if (canPrintStatement) {
+
+ print Statement
+
+ }
} @else {
@@ -989,7 +1221,7 @@
Cash Hold
- 0.00
+ {{ cashHold | number: '1.2-2' }}
Tr. Hold
@@ -997,13 +1229,19 @@
Clr. Hold
- 0.00
+ {{ clearingHold | number: '1.2-2' }}
After Trans.
{{ currencySymbol }}{{ clearBalance | number: '1.2-2' }}
+ @if (canPrintStatement) {
+
+
+ print Statement
+
+ }
@if (accountAlerts.length > 0) {
@@ -1326,32 +1564,51 @@
TYPE
-
- ~
- Withdrawal Slip
- Cheque
- Disbursement
- Enter Bank Transfer
- DD (Demand Draft)
+
+ @for (t of typOptions; track t.value) {
+ {{ t.label }}
+ }
BRANCH
-
+
001 · Main
002 · M.G. Road
014 · Station Rd
027 · Industrial
+ @if (row.typ === 'DD') {
+
+ SPONSOR BANK
+
+ @if (sponsorBanks.length === 0) {
+ -- Select Bank --
+ }
+ @for (sb of sponsorBanks; track sb.id) {
+ {{ sb.name }}
+ }
+
+
+ }
TXN TP.
- — Select —
@for (pt of paymentTypesForRow(row); track pt.id) {
{{ pt.name }}
}
@@ -1359,21 +1616,50 @@
AC. TP.
-
- @for (a of acTypOptions; track a) {
- {{ a }}
- }
-
+ @if (acTypLockedByToken && row === selectedRow) {
+ {{ acTypLockedLabel }}
+ } @else {
+
+ @for (a of acTypOptionsFor(row); track a) {
+ {{ a }}
+ }
+
+ }
+ @if (showsEntryType(row)) {
+
+ ENTRY TYPE
+
+ Select…
+ @for (t of entryTypeOptions; track t.id) {
+ {{ t.name }}
+ }
+
+
+ }
@@ -1387,7 +1673,20 @@
[value]="row.acNo"
(input)="onAcNoInput(row, i, $any($event.target).value)"
(blur)="onAcNoBlur(row, i)"
- placeholder="Account number"
+ [placeholder]="
+ row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
+ "
+ [readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
+ [class.ws-f__ctl--readonly]="
+ row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
+ "
+ [title]="
+ acTypLockedByToken && row === selectedRow
+ ? 'Locked — filled from a token-driven request, not a normal account entry'
+ : row.typ === 'DD'
+ ? 'Configured Sponsor DD Payable GL (Auto-selected)'
+ : 'Account number'
+ "
/>
@@ -1406,7 +1705,10 @@
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 !== 'Enter Bank Transfer'
+ row.acTyp === 'GL' &&
+ row.typ !== 'Disbursement' &&
+ row.typ !== 'Fund Transfer' &&
+ row.typ !== 'Enter Bank Transfer'
"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
@@ -1432,14 +1734,16 @@
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
- @if (!isClerk && activeWithdrawalRequest) {
- 🔒
+ @if (!isClerk && (activeWithdrawalRequest || activeClosureRequest)) {
+ 🔒
}
@if (row.acTyp !== 'SHR') {
₹
@@ -1450,9 +1754,13 @@
[value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
- [placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
- [readonly]="!isClerk && !!activeWithdrawalRequest"
- [class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
+ [placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
+ [readonly]="
+ isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
+ "
+ [class.ws-amt-input--locked]="
+ isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
+ "
/>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
@@ -1462,8 +1770,23 @@
}}/share
}
+
+ @if (isMultiDdRow(row)) {
+
+ {{
+ row.ddLines?.length
+ ? 'Total of ' + row.ddLines?.length + ' drafts'
+ : 'Set per draft in Fill DD Details'
+ }}
+
+ }
- @if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
+ @if (
+ row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
+ ) {
RESOLUTION NO.
MEETING DATE
- } @else {
+ } @else if (isClerk) {
- @if (isDdCashGlRow(row)) {
+ @if (isDdRow(row)) {
DD Charges & GST — Admin Rate {{ ddGstPercentage }}%
+
+ @if (canRowBeMultiDd(row)) {
+
+
NO. OF DD
+
+
+
+ @if (isMultiDdRow(row)) {
+
One debit · {{ ddCountFor(row) }} drafts
+ }
+
+ }
CHARGES
-
+
₹
+
+ @if (isMultiDdRow(row)) {
+
Set per draft
+ }
@@ -1539,25 +1906,57 @@
+
+
+
+ {{
+ row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
+ }}
+
+ {{
+ row.ddLines?.length
+ ? row.ddLines?.length + ' Drafts Captured · Edit Slip'
+ : row.ddDetails
+ ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
+ : isMultiDdRow(row)
+ ? 'Fill ' + ddCountFor(row) + ' DD Details'
+ : 'Fill DD Details'
+ }}
+
+
@@ -1655,9 +2054,22 @@
class="ws-btn ws-btn--ghost"
type="button"
(click)="router.navigate(['/clerk/my-disbursement-requests'])"
- title="View my disbursement requests"
+ title="View my fund requests — Disbursement, Collection, Fund Transfer and Bank Transfer"
>
- My Disbursements
+ Disbursements & Transfer
+
+ }
+ @if (canShowAccountActions) {
+
+
+
+
+ Action
}
@@ -1681,6 +2093,91 @@
}
+
+@if (showAccountActions) {
+
+
+
+
+
+ {{ accountDetails?.accountNo }}
+ {{ clientName }}
+
+
+
+
+
+ Close this account
+ Pays out the full balance in cash and closes the account, after manager approval.
+
+
+
+ @if (closeAccountChecked) {
+ @if (closurePreviewLoading) {
+
Posting interest to the account…
+ } @else if (closurePreviewError) {
+
{{ closurePreviewError }}
+ } @else if (closurePreview) {
+
+
+ Current Balance
+ ₹{{ closurePreview.balance | number: '1.2-2' }}
+
+ @if (closurePreview.interest < 0) {
+
+
+ Interest correction
+ − ₹{{ -closurePreview.interest | number: '1.2-2' }}
+
+ } @else {
+
+ Interest to date
+ + ₹{{ closurePreview.interest | number: '1.2-2' }}
+
+ }
+
+ Payable on Closure
+ ₹{{ closurePreview.total | number: '1.2-2' }}
+
+ @if (!closurePreview.upToDate) {
+
+ 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.
+
+ }
+
+ Interest has been posted to the account as of {{ closurePreview.asOfDate }} — this is the account's real
+ balance now, not an estimate. Cash is handed over rounded to whole rupees.
+
+
+
+
+ This amount will be filled into AMOUNT on the slip. Enter TOK. NO. and
+ press Save to send it for the Bank Manager's approval.
+
+ }
+ }
+
+
+ Cancel
+
+ Save
+
+
+
+
+}
+
@if (showRejectDialog) {
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss
index 00c01b31c..06858c598 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.scss
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss
@@ -457,6 +457,21 @@ $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;
@@ -901,6 +916,20 @@ input.ws-f__ctl--readonly {
font-size: 11px;
}
+/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled
+ from a token-driven request whose real account type row.acTyp cannot show (see
+ acTypLockedByToken in the component). */
+span.ws-f__ctl--locked {
+ display: inline-flex;
+ align-items: center;
+ background: #f1f5f9;
+ color: #475569;
+ cursor: not-allowed;
+ user-select: none;
+ font-size: 11px;
+ font-weight: 600;
+}
+
input.ws-amt-input--locked {
background: #fef9c3;
color: #713f12;
@@ -2335,12 +2364,171 @@ 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;
@@ -3107,6 +3295,21 @@ 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;
@@ -3195,3 +3398,67 @@ select.te-cell {
transform: rotate(360deg);
}
}
+
+.ws-dd-unconfigured-banner {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 10px 16px;
+ padding: 12px 16px;
+ background: #fffbeb;
+ border: 1.5px solid #fcd34d;
+ border-radius: 8px;
+ color: #92400e;
+ font-size: 13px;
+
+ mat-icon {
+ color: #d97706;
+ font-size: 24px;
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
+ }
+
+ &__text {
+ flex: 1;
+ line-height: 1.4;
+
+ strong {
+ color: #78350f;
+ }
+ }
+}
+
+.te-dd-issue-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ background: linear-gradient(135deg, #0284c7, #0ea5e9);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 7px 16px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ box-shadow: 0 2px 4px rgb(2 132 199 / 35%);
+ transition: all 0.15s ease-in-out;
+ letter-spacing: 0.2px;
+
+ &:hover:not(:disabled) {
+ background: linear-gradient(135deg, #0369a1, #0284c7);
+ box-shadow: 0 3px 8px rgb(2 132 199 / 50%);
+ transform: translateY(-1px);
+ }
+
+ &:active:not(:disabled) {
+ transform: translateY(0);
+ }
+
+ &:disabled {
+ background: #cbd5e1;
+ color: #64748b;
+ cursor: not-allowed;
+ box-shadow: none;
+ }
+}
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts
index e75976599..0c916bfe4 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.ts
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-import { Component, OnInit, OnDestroy, inject, Input } from '@angular/core';
+import { Component, OnInit, OnDestroy, inject, Input, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
@@ -20,6 +20,7 @@ import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
+import { ClerkClosureRequestService } from '../clerk-closure-request.service';
import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service';
import { ClerkDisbursementService } from '../clerk-disbursement.service';
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
@@ -30,6 +31,8 @@ import {
CustomerSearchResult
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
+import { OrganizationService } from '../../organization/organization.service';
+import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
interface TxnRow {
typ: string;
@@ -49,10 +52,79 @@ interface TxnRow {
routingCode: string;
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
ddCharges: number | null;
+ sponsorBankId?: number | null;
+ /**
+ * What a GL credit is actually for — the id of a code value under "GLEntryType" (General, Share
+ * Collection, DD Issue, or whatever an admin has added under Admin > System > Manage Codes).
+ *
+ * A cashier Receipt (or a clerk Collection) into a GL account is the counter picking, by hand,
+ * any account to post cash into — deliberately, so a broken product mapping can never block a
+ * genuine voucher. That freedom is also the hole: two unrelated flows can land in the same GL
+ * account with nothing on the row itself saying which one the money was collected for, and a
+ * manager applying a share request can pick up a voucher that was really meant to fund a DD. This
+ * tag is what a day-end reconciliation groups by, and what Share Advance Collection / Sponsor DD
+ * issuance check before adopting a row — only shown/sent for AC.TP=GL, DR./CR.=Cr rows.
+ */
+ entryTypeCvId?: number | null;
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string;
meetingDate?: string;
+ /**
+ * Beneficiary and leaflet captured in the DD dialog, held on the row until Save.
+ *
+ * The dialog used to submit the request itself, which left the worksheet's own Save button doing
+ * nothing for a DD row — the clerk filled the dialog, the request was already gone, and pressing
+ * Save afterwards had no effect. Filling in details and committing them are two different
+ * actions, so the dialog now only records what was entered and Save is what sends it.
+ */
+ ddDetails?: {
+ leafletId: number;
+ ddNumber: string;
+ beneficiaryName: string;
+ payableAtBranch: string;
+ payableAtCity: string;
+ /** Present only for a walk-in cash draft. */
+ purchaser?: { name: string; address: string; aadhaar: string };
+ /** Note-and-coin breakdown, cash drafts only. */
+ denominations?: { [key: string]: number };
+ fundingAccountId?: number;
+ fundingClientId?: number;
+ };
+ /**
+ * How many drafts this one row raises. 1 (or unset) is the ordinary single draft; 2 or more make
+ * the row a multi-DD slip — one debit funding several drafts, each to its own beneficiary.
+ *
+ * Typed by the clerk BEFORE opening the dialog, so the dialog knows how many beneficiary forms to
+ * present. Only a member-account debit can be a slip (see canRowBeMultiDd()).
+ */
+ ddCount?: number | null;
+ /**
+ * The per-draft lines of a multi-DD slip, captured in the dialog and held here until Save — the
+ * same "fill now, commit on Save" contract as ddDetails above.
+ *
+ * Each line carries its OWN face value and charges: a slip's drafts are routinely for different
+ * amounts to different beneficiaries, and the charge is per draft rather than per slip. That is
+ * why the row's own AMOUNT and CHARGES fields are locked and derived from these lines while a
+ * slip is being built — a clerk-typed row total could disagree with the lines, and the account
+ * would then be debited for a figure the drafts don't add up to.
+ */
+ ddLines?: DdSlipLine[];
+ /** The inventory the slip's leaflets are drawn from. The batch endpoint allocates by inventory
+ * rather than by leaflet, since the numbers are only picked inside its own locked transaction. */
+ ddInventoryId?: number;
+ /** Resolved funding account for a slip, captured alongside the lines for the same reason. */
+ ddFundingAccountId?: number;
+ ddFundingClientId?: number;
+}
+
+/** One draft on a multi-DD slip — its own beneficiary, its own face value, its own charges. */
+interface DdSlipLine {
+ beneficiaryName: string;
+ payableAtBranch: string;
+ payableAtCity: string;
+ faceValue: number;
+ chargesAmount: number;
}
interface Denomination {
@@ -62,6 +134,709 @@ interface Denomination {
label: string;
}
+// ── Account Statement Dialog ───────────────────────────────────────────────────
+//
+// Deliberately the same layout and print mechanism as
+// savings/savings-account-view/passbook-tab.component.ts (bank-header/branch-line/
+// account-details-band/balance-summary-strip/statement-table/statement-footer, cloned into a popup
+// window for window.print()) rather than a generated PDF: a UCB's own passbook screen is the
+// reference for what a statement should look like, and jsPDF's own layout would inevitably drift
+// from it. Nothing downloads on open — the statement is only ever shown as a preview here; printing
+// (and "Save as PDF" from the browser's own print dialog, if a file is wanted) is a deliberate,
+// separate click.
+
+@Component({
+ selector: 'mifosx-account-statement-dialog',
+ template: `
+
+ `,
+ styles: [
+ `
+ .asd-shell {
+ display: flex;
+ flex-direction: column;
+ height: 82vh;
+ width: 780px;
+ max-width: 90vw;
+ }
+ .asd-filterbar {
+ display: flex;
+ align-items: flex-end;
+ gap: 12px;
+ padding: 12px 16px;
+ border-bottom: 1px solid #e2e8f0;
+ }
+ .asd-field {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ font-size: 11px;
+ color: #64748b;
+
+ input {
+ padding: 6px 8px;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ font-size: 12px;
+ }
+ }
+ .asd-spacer {
+ flex: 1;
+ }
+ .asd-btn {
+ padding: 7px 14px;
+ border-radius: 6px;
+ border: 1px solid #e2e8f0;
+ background: #fff;
+ cursor: pointer;
+ font-size: 13px;
+
+ &--primary {
+ background: #1e40af;
+ border-color: #1e40af;
+ color: #fff;
+ }
+ }
+ .asd-scroll {
+ flex: 1;
+ overflow: auto;
+ padding: 16px;
+ background: #eef1f6;
+ }
+
+ /* Statement look — mirrors passbook-tab.component's PRINT_STYLES exactly, so this preview and
+ a real passbook read the same way. */
+ .statement-wrapper {
+ background: #fff;
+ border: 1px solid #d0d7e3;
+ border-radius: 6px;
+ overflow: hidden;
+ font-family: 'Segoe UI', Arial, sans-serif;
+ color: #333;
+ }
+ .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;
+
+ mat-icon {
+ color: #1a3a6b;
+ }
+ }
+ .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: rgb(255 255 255 / 75%);
+ font-size: 10px;
+ margin-top: 3px;
+ }
+ .branch-line {
+ background: #e8edf5;
+ border-bottom: 1px solid #d0d7e3;
+ padding: 5px 20px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 16px;
+ font-size: 9.5px;
+ color: #4a5a7a;
+ }
+ .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;
+ }
+ .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: rgb(255 255 255 / 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: rgb(255 255 255 / 15%);
+ margin: 6px 0;
+ }
+ .statement-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11px;
+ }
+ .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 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;
+ }
+ .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;
+ }
+ .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;
+ }
+ .txn-desc {
+ font-weight: 500;
+ }
+ .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-block {
+ padding: 40px;
+ text-align: center;
+ color: #aaa;
+ font-size: 13px;
+ }
+ .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;
+ }
+ `
+ ],
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatIconModule
+ ]
+})
+export class AccountStatementDialogComponent implements OnInit {
+ dialogRef = inject
>(MatDialogRef);
+ data = inject<{ account: any }>(MAT_DIALOG_DATA);
+ private organizationService = inject(OrganizationService);
+
+ @ViewChild('statementContent') statementContent!: ElementRef;
+
+ today = new Date().toISOString().slice(0, 10);
+ fromDate = '';
+ toDate = this.today;
+
+ bankName = 'Bank';
+ bankSubtitle = 'Account Statement';
+ branchName = '';
+ branchAddress = '';
+ ifscCode = '';
+ micrCode = '';
+
+ accountNo = '';
+ accountHolder = '';
+ productName = '';
+ currencySym = '₹';
+ currentBalance = 0;
+ availableBalance = 0;
+
+ private allRows: any[] = [];
+ filteredRows: any[] = [];
+
+ ngOnInit(): void {
+ const acc = this.data.account ?? {};
+ this.accountNo = acc.accountNo ?? '';
+ this.accountHolder = acc.clientName ?? acc.groupName ?? '';
+ this.productName = acc.savingsProductName ?? '';
+ this.currencySym = acc.currency?.displaySymbol ?? acc.currency?.code ?? '₹';
+ this.currentBalance = Number(acc.summary?.accountBalance ?? 0);
+ this.availableBalance = Number(acc.summary?.availableBalance ?? this.currentBalance);
+
+ this.allRows = this.buildRows(acc.transactions ?? []);
+ this.filteredRows = [...this.allRows];
+
+ forkJoin({
+ institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))),
+ branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([])))
+ }).subscribe(({ institution, branches }: any) => {
+ if (institution) {
+ this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank';
+ this.bankSubtitle = institution.licenseNumber ? `Lic. No. ${institution.licenseNumber}` : 'Account Statement';
+ }
+ const branch = Array.isArray(branches) ? branches[0] : null;
+ if (branch) {
+ this.branchName = branch.officeName ?? '';
+ this.branchAddress = branch.address ?? '';
+ this.ifscCode = branch.ifscCode ?? '';
+ this.micrCode = branch.micrCode ?? '';
+ }
+ });
+ }
+
+ private transactionDateAsTime(date: any): number {
+ if (Array.isArray(date) && date.length >= 3) {
+ return new Date(date[0], date[1] - 1, date[2]).getTime();
+ }
+ return new Date(date).getTime();
+ }
+
+ private isDebit(transactionType: any): boolean {
+ return !!(
+ transactionType?.withdrawal ||
+ transactionType?.feeDeduction ||
+ transactionType?.overdraftInterest ||
+ transactionType?.withholdTax
+ );
+ }
+
+ /** Mirrors passbook-tab.component's recalculateRunningBalances()/isDebit() -- same account, same
+ * ledger, so the two screens must never disagree on what a transaction is worth. */
+ private buildRows(transactions: any[]): any[] {
+ const nonReversed = (transactions ?? []).filter((t: any) => !t.reversed);
+ const sorted = [...nonReversed].sort((a, b) => {
+ const da = this.transactionDateAsTime(a.date);
+ const db = this.transactionDateAsTime(b.date);
+ return da !== db ? da - db : (a.id ?? 0) - (b.id ?? 0);
+ });
+ let balance = 0;
+ return sorted.map((txn) => {
+ const affectsBalance =
+ !txn.transactionType?.accrual && !txn.transactionType?.amountHold && !txn.transactionType?.amountRelease;
+ const debit = this.isDebit(txn.transactionType);
+ if (affectsBalance) {
+ balance += debit ? -txn.amount : txn.amount;
+ }
+ const time = this.transactionDateAsTime(txn.date);
+ return {
+ id: txn.id,
+ time,
+ dateLabel: isNaN(time)
+ ? '—'
+ : new Date(time).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }),
+ description: txn.transactionType?.value ?? '—',
+ debit: debit ? Number(txn.amount) : 0,
+ credit: !debit ? Number(txn.amount) : 0,
+ runningBalance: Math.round(balance * 100) / 100
+ };
+ });
+ }
+
+ applyFilter(): void {
+ if (!this.fromDate || !this.toDate) {
+ this.filteredRows = [...this.allRows];
+ return;
+ }
+ const from = new Date(this.fromDate).getTime();
+ const to = new Date(this.toDate).getTime();
+ this.filteredRows = this.allRows.filter((r) => r.time >= from && r.time <= to);
+ }
+
+ /** Same clone-into-popup-window approach as passbook-tab.component.printPassbook() -- the on-screen
+ * preview is never itself printed directly, since the dialog chrome around it must not appear. */
+ printStatement(): void {
+ const source = this.statementContent?.nativeElement as HTMLElement;
+ if (!source) return;
+
+ const clone = source.cloneNode(true) as HTMLElement;
+ clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => {
+ const insideLogo = !!el.closest('.bank-logo-circle');
+ if (insideLogo) {
+ el.outerHTML =
+ ' ';
+ } else {
+ el.remove();
+ }
+ });
+
+ const printWindow = window.open('', 'Statement', 'height=800,width=1100');
+ if (!printWindow) return;
+
+ printWindow.document.write(`
+
+
+
+ Account Statement - ${this.accountNo}
+
+
+
+ ${clone.outerHTML}
+
+
+
+ `);
+ printWindow.document.close();
+ printWindow.focus();
+ }
+
+ /**
+ * A popup window opened via window.open() doesn't inherit this component's own scoped Angular
+ * styles, so the exact same statement CSS has to be re-supplied here as plain text -- this is a
+ * duplicate of the `styles: [...]` block above by necessity, not an oversight; keep the two in sync
+ * if either changes. Mirrors passbook-tab.component.ts's own PRINT_STYLES constant for the same
+ * reason.
+ */
+ 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-wrapper { background: #fff; border: 1px solid #d0d7e3; border-radius: 6px; overflow: hidden; }
+ .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; }
+ .branch-line { background: #e8edf5; border-bottom: 1px solid #d0d7e3; padding: 5px 20px; display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 9.5px; color: #4a5a7a; }
+ .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; }
+ .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; }
+ .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 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; }
+ .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; }
+ .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; }
+ .txn-desc { font-weight: 500; }
+ .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-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; }
+ .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; }
+ `;
+}
+
// ── Joint Holder Detail Dialog ────────────────────────────────────────────────
@Component({
@@ -751,14 +1526,39 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Active approved withdrawal (cashier disburse flow)
activeWithdrawalRequest: any = null;
activeDividendClaimRequest: any = null;
+ activeClosureRequest: any = null;
loadingWithdrawal = false;
+ // ── Account Actions (clerk) — "Close this account" ──────────────────────────
+ /** Account Actions popup, opened from the footer once an account is loaded. */
+ showAccountActions = false;
+ /** The one action offered today: close the account (Cash payout, maker-checker). */
+ closeAccountChecked = false;
+ /** Interest accrued to today, previewed the moment the checkbox is ticked so the clerk can see
+ * what the member will actually receive before submitting. Null until calculated.
+ * `upToDate` is false when the backend could not refresh the accrual (e.g. this user lacks the
+ * CALCULATEINTEREST permission) and the figure shown is the last stored one instead. */
+ closurePreview: {
+ balance: number;
+ interest: number;
+ total: number;
+ upToDate: boolean;
+ asOfDate: string | null;
+ } | null = null;
+ closurePreviewLoading = false;
+ closurePreviewError = '';
+ /** Closure applied to the slip but not yet submitted: the AMOUNT is filled and Save will raise a
+ * closure request instead of an ordinary withdrawal request. TOK. NO. and DESCRIPTION are taken
+ * from the slip's own fields, not from the popup. */
+ pendingClosure = false;
+
private http = inject(HttpClient);
private cashierService = inject(CashierService);
private authService = inject(AuthenticationService);
private dialog = inject(MatDialog);
private clerkUiState = inject(ClerkUiStateService);
private clerkWithdrawalService = inject(ClerkWithdrawalService);
+ private closureRequestService = inject(ClerkClosureRequestService);
private dividendClaimRequestService = inject(DividendClaimRequestService);
private clerkDisbursementService = inject(ClerkDisbursementService);
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
@@ -778,23 +1578,64 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
loadingAccount = false;
accountError = '';
- /** Share redemption settlement is always a book-transfer to the linked savings account — never a
- * cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
- * does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
- * the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
- * Every other account type/mode keeps the full, unfiltered list. */
+ /**
+ * A teller only ever picks Cash or Transfer — which specific internal settlement it actually is
+ * (a share redemption, a dividend payout, a GL-to-GL or GL-to-bank book entry) is business logic
+ * the backend already threads through from whichever payment type the clerk picked here (see
+ * ClerkShareWithdrawalRequestApiResource#creditLinkedSavingsAccount on the backend), not a separate
+ * teller-facing choice. System-defined payment types (Share Redemption Transfer, Dividend Claim
+ * Settlement, the loan repayment-adjustment types, ...) exist purely as internal audit labels and
+ * are never offered here.
+ *
+ * Share redemption settlement is always a book-transfer to the linked savings account — never a
+ * cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
+ * does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
+ * the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
+ */
paymentTypesForRow(row: TxnRow): any[] {
+ // Sponsor-bank payment types are excluded. They were added for the Demand Draft module, which
+ // picks its bank from m_sponsor_bank on the DD row itself, and they are not counter instruments
+ // — a clerk choosing "SBI" as the TXN TP. of a withdrawal says nothing about how the money
+ // moves. They are recognised by their description rather than by name so that adding another
+ // sponsor bank does not put it back on this list.
+ const tellerFacing = this.paymentTypes.filter(
+ (pt: any) => !pt.isSystemDefined && !/sponsor bank/i.test(pt.description ?? '')
+ );
+ if (!this.isClerk) {
+ // Cashier counter takes cash only — cheque/transfer/DD instruments go through their own
+ // clerk/back-office approval flows, never straight through the cash drawer.
+ return tellerFacing.filter((pt: any) => !!pt.isCashPayment);
+ }
if (row.acTyp === 'SHR') {
- return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
+ return tellerFacing.filter((pt: any) => !pt.isCashPayment);
}
const isShareSettlementCredit =
- row.typ === 'Enter Bank Transfer' &&
+ (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer') &&
row.dbCr === 'Cr' &&
- this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR');
+ this.rows.some(
+ (r) => (r.typ === 'Enter Bank Transfer' || r.typ === 'Fund Transfer') && r.dbCr === 'Dr' && r.acTyp === 'SHR'
+ );
if (isShareSettlementCredit) {
- return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
+ return tellerFacing.filter((pt: any) => !pt.isCashPayment);
}
- return this.paymentTypes;
+ // A withdrawal slip is a cash instruction and nothing else: the clerk raises it, the cashier
+ // counts the notes out against the token. There is no non-cash way to settle one, so cash is
+ // the only choice offered rather than the default among several.
+ if (row.typ === 'Withdrawal Slip') {
+ return tellerFacing.filter((pt: any) => !!pt.isCashPayment);
+ }
+ // A clerk's Demand Draft is never funded by cash they take themselves. Cash for a draft is
+ // counted at the counter, so it comes either from the cashier's own DD screen or from an
+ // approved cashier deposit request the clerk points the draft at; the clerk's own route is to
+ // debit the member's account. Offering cash here let a draft be raised against notes no drawer
+ // had counted, which is exactly how the six cash DDs with no denomination breakdown arose.
+ if (row.typ === 'DD') {
+ return tellerFacing.filter((pt: any) => !pt.isCashPayment);
+ }
+ // Everything else can go either way, so both are offered and the clerk says which. A cheque or
+ // a bank transfer normally settles through the instrument, a disbursement or fund transfer
+ // between accounts — the type alone does not fix it to one or the other.
+ return tellerFacing;
}
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
@@ -826,6 +1667,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Extended cashier data
clientPhotoUrl: string | null = null;
signatureUrl: string | null = null;
+ /** Cash Hold (a manual/staff-placed lien) vs Clearing Hold (a cheque/DD deposit not yet realized,
+ * or a presented cheque not yet settled) — fetched separately from the account's own detail
+ * response, since only plain savings accounts (m_savings_account) carry this breakdown today. */
+ holdBreakdown: { cashHold: number; clearingHold: number } | null = null;
accountNotes: any[] = [];
standingInstructions: any[] = [];
todayTransactions: any[] = [];
@@ -840,6 +1685,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private lastLoadedAcNo = '';
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
cashVaultGlId: number | null = null;
+ /** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
+ * entry has to be filed against THIS account, not whichever account the teller typed in, since
+ * that account is never the one physical notes actually moved through (see submitGlJournalEntry).
+ * Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
+ * its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
+ cashVaultGlCode: string | null = null;
+ cashVaultGlName: string | null = null;
// Joint holder expanded details (keyed by index)
expandedHolder: number | null = null;
@@ -894,6 +1746,63 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
'GL',
'LA'
];
+
+ /**
+ * Account types a clerk may fund a demand draft from.
+ *
+ * A clerk raises a draft against a member's own balance (SD/CA) or, now, against a GL account
+ * directly for an institutional/holding-account draft (see isDdAccountDebitRow()) — both are
+ * governed, maker-checker submissions. GL used to be offered here too in a different sense: picking
+ * it quietly switched the row onto the CASHIER's walk-in cash path instead (the one that asks for a
+ * purchaser's name and a note-and-coin count), which isDdAccountDebitRow() now disambiguates by
+ * role — GL means "debit this GL account" for a clerk, and still means "cash/GL purchase" for a
+ * cashier, who never reaches this list at all (isDdAccountDebitRow() is clerk-only).
+ */
+ private static readonly DD_CLERK_ACTYPS = [
+ 'SD',
+ 'CA',
+ 'GL'
+ ];
+
+ /**
+ * The account types this particular row may use.
+ *
+ * Every other row keeps the full list; only a clerk's DD row is narrowed, because that is the
+ * only row where the account type silently decides which funding flow runs.
+ */
+ acTypOptionsFor(row: TxnRow): string[] {
+ if (this.isClerk && row.typ === 'DD') {
+ return TransactionEntryComponent.DD_CLERK_ACTYPS;
+ }
+ return this.acTypOptions;
+ }
+
+ /** True while the selected row is filled from a token-driven request whose row.acTyp is a
+ * technical stand-in rather than the account's real type — a dividend claim or an account closure
+ * both set row.acTyp = 'SD' purely to route past the SHR-specific guards elsewhere on this screen
+ * (see onTokNoBlur()), not because the counter is paying against a savings account. The AC.TP
+ * dropdown must not be left editable there: it already shows the wrong value, and a cashier
+ * changing it further would desync the row from the request it was auto-filled from. A plain
+ * withdrawal-slip token is deliberately excluded — its row.acTyp is resolved to the account's real
+ * type there, so the normal editable control already shows it correctly and there's nothing to
+ * lock. */
+ get acTypLockedByToken(): boolean {
+ return !!(this.activeDividendClaimRequest || this.activeClosureRequest);
+ }
+
+ /** What to show in place of the AC.TP dropdown while it's locked — the same label the account
+ * summary strip above already uses for each of these token pools, so the two never contradict
+ * each other on screen. */
+ get acTypLockedLabel(): string {
+ if (this.activeClosureRequest) {
+ return 'Closure';
+ }
+ if (this.activeDividendClaimRequest) {
+ return 'Dividend';
+ }
+ return '';
+ }
+
dbCrOptions = [
'Dr',
'Cr'
@@ -904,6 +1813,43 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
'T'
];
+ private readonly clerkTypOptions: { value: string; label: string }[] = [
+ { value: 'Withdrawal Slip', label: 'Withdrawal Slip' },
+ { value: 'Cheque', label: 'Cheque' },
+ { value: 'Disbursement', label: 'Disbursement' },
+ // Mechanically identical to Disbursement (1 GL debit -> N account credits, same maker-checker
+ // double-entry flow) but labelled separately: "Disbursement" reads as a loan payout, which is
+ // wrong for crediting a GL amount into a savings/current account for any other reason (e.g.
+ // funding a newly-activated account's opening balance from a GL source).
+ { value: 'Fund Transfer', label: 'Fund Transfer' },
+ { value: 'Enter Bank Transfer', label: 'Enter Bank Transfer' },
+ { value: 'DD', label: 'DD (Demand Draft)' }
+ ];
+
+ /**
+ * A cashier takes cash in (Receipt), pays cash out (Payment), or collects the cash for a Demand
+ * Draft (DD). Cheque, Disbursement and Bank Transfer stay clerk/back-office instruments with their
+ * own approval flows and never appear at the cashier counter.
+ *
+ * DD belongs here because the whole DD-against-cash path is the cashier's: isDdCashGlRow() gates on
+ * a cash payment type against a GL account, onSave() makes the cashier count Face Value + Charges
+ * + GST exactly and enforces the admin DD limit at intake, and buildDepositPayload() persists that
+ * breakdown so the Sponsor DD issuance screen can read it back after approval. All of that was
+ * already written and reachable only by a cashier — it was just impossible to select.
+ */
+ private readonly cashierTypOptions: { value: string; label: string }[] = [
+ { value: 'Receipt', label: 'Receipt' },
+ { value: 'Payment', label: 'Payment' },
+ { value: 'DD', label: 'DD (Demand Draft)' }
+ ];
+
+ get typOptions(): { value: string; label: string }[] {
+ return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions;
+ }
+
+ sponsorBanks: any[] = [];
+ ddGlMapping: any = null;
+
// ── Denomination ──────────────────────────────────────────────────────────
notes: Denomination[] = [];
coins: Denomination[] = [];
@@ -933,7 +1879,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.cashierService
.getPaymentTypes()
.pipe(catchError(() => of([])))
- .subscribe((types: any[]) => (this.paymentTypes = types ?? []));
+ .subscribe((types: any[]) => {
+ this.paymentTypes = types ?? [];
+ // addRow() ran before this resolved, so the very first row's default TXN TP. (Cash, for
+ // Withdrawal Slip / Receipt / Payment) couldn't be looked up yet — backfill it now that
+ // the payment types are actually loaded. Only ever touches a row still sitting on the
+ // null it was created with, so this never overwrites anything already picked on screen.
+ for (const row of this.rows) {
+ if (!row.paymentTypeId && [
+ 'Withdrawal Slip',
+ 'Receipt',
+ 'Payment'
+ ].includes(row.typ)) {
+ row.paymentTypeId = this.findCashPaymentTypeId();
+ }
+ }
+ });
this.acNoInput$
.pipe(
@@ -949,6 +1910,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.loadCashVaultGl();
this.loadDdGstPercentage();
+ this.loadSponsorBanks();
+ this.cashierService
+ .getEntryTypeOptions()
+ .pipe(catchError(() => of([])))
+ .subscribe((options: any[]) => {
+ this.entryTypeOptions = options ?? [];
+ });
if (this.reviewMode) {
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
@@ -1039,12 +2007,85 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.getGlMappings()
.pipe(catchError(() => of([])))
.subscribe((mappings: any[]) => {
- const mapping = mappings && mappings.length ? mappings[0] : null;
- this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18);
- this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null;
+ this.ddGlMapping = mappings && mappings.length ? mappings[0] : null;
+ this.ddGstPercentage = Number(this.ddGlMapping?.gstPercentage ?? 18);
+ this.ddLimitAmount = this.ddGlMapping?.ddLimitAmount != null ? Number(this.ddGlMapping.ddLimitAmount) : null;
+ if (this.ddGlMapping?.ddPayableGlAccountId) {
+ if (this.ddGlMapping.ddPayableGlCode) {
+ this.applyDdGlMappingToDdRows();
+ } else {
+ this.http
+ .get(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
+ .pipe(catchError(() => of(null)))
+ .subscribe((glAcc: any) => {
+ if (glAcc) {
+ this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
+ this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
+ }
+ this.applyDdGlMappingToDdRows();
+ });
+ }
+ }
});
}
+ applyDdGlMappingToRow(row: TxnRow): void {
+ if (row.typ !== 'DD' || !this.ddGlMapping) return;
+ // Leaves an account-funded DD alone: its A/C NO. is the member's account the clerk typed, and
+ // overwriting it with the DD Payable GL would silently turn the row back into a cash draft.
+ if (this.isDdAccountDebitRow(row)) return;
+ row.acTyp = 'GL';
+ row.dbCr = 'Cr';
+
+ if (this.ddGlMapping.ddPayableGlCode) {
+ row.acNo = this.ddGlMapping.ddPayableGlCode;
+ this.selectedRow = row;
+ this.loadAccountDetails(row.acNo, 'GL');
+ return;
+ }
+
+ if (this.ddGlMapping.ddPayableGlAccountId) {
+ this.http
+ .get(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
+ .pipe(catchError(() => of(null)))
+ .subscribe((glAcc: any) => {
+ if (glAcc) {
+ this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
+ this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
+ row.acNo = this.ddGlMapping.ddPayableGlCode;
+ this.selectedRow = row;
+ this.loadAccountDetails(row.acNo, 'GL');
+ }
+ });
+ }
+ }
+
+ private applyDdGlMappingToDdRows(): void {
+ for (const row of this.rows) {
+ if (row.typ === 'DD') {
+ this.applyDdGlMappingToRow(row);
+ }
+ }
+ }
+
+ private loadSponsorBanks(): void {
+ this.sponsorDDService
+ .getSponsorBanks()
+ .pipe(catchError(() => of([])))
+ .subscribe((banks: any[]) => {
+ this.sponsorBanks = banks || [];
+ for (const row of this.rows) {
+ if (row.typ === 'DD' && !row.sponsorBankId && this.sponsorBanks.length > 0) {
+ row.sponsorBankId = this.sponsorBanks[0].id;
+ }
+ }
+ });
+ }
+
+ get isDdGlMappingConfigured(): boolean {
+ return !!(this.ddGlMapping && this.ddGlMapping.ddPayableGlAccountId && this.ddGlMapping.cashGlAccountId);
+ }
+
private loadDayStatus(): void {
this.cashierService
.getCashierDayStatus()
@@ -1224,8 +2265,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
addRow(): void {
const active = this.selectedRow;
- // Disbursement/Bank Transfer: auto-add mirror CR row when active row is DR
- if ((active?.typ === 'Disbursement' || active?.typ === 'Enter Bank Transfer') && active.dbCr === 'Dr') {
+ // Disbursement/Fund Transfer/Bank Transfer: auto-add mirror CR row when active row is DR
+ if (
+ (active?.typ === 'Disbursement' || active?.typ === 'Fund Transfer' || active?.typ === 'Enter Bank Transfer') &&
+ active.dbCr === 'Dr'
+ ) {
const mirror: TxnRow = {
typ: active.typ,
br: active.br ?? 1,
@@ -1251,20 +2295,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
return;
}
+ // Neither TYPE nor TXN TP. ever offers a blank/placeholder option any more, so a fresh row
+ // can't start in an unselected state either — it needs a real, valid TYPE from the moment it
+ // exists. Cashier: Receipt (money in) is the far more common starting point than Payment
+ // (money out, which needs an approved token first). Clerk: Withdrawal Slip, the plainest of
+ // the five original types — same TYPE the old blank-then-choose flow most often ended on
+ // anyway, just without the extra click to get there.
const row: TxnRow = {
- typ: '~',
+ typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
br: 1,
trTp: '',
acTyp: 'SD',
acNo: '',
ecsAcNo: '',
- dbCr: '',
+ dbCr: this.isClerk ? 'Dr' : 'Cr',
chequeNo: '',
cheqDate: '',
amount: null,
description: '',
tokNo: '',
- paymentTypeId: null,
+ paymentTypeId: this.findCashPaymentTypeId(),
bankName: '',
routingCode: '',
ddCharges: null
@@ -1281,11 +2331,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.chequeNo = '';
row.cheqDate = '';
break;
+ // A cheque clears through the bank rather than the drawer, so it opens on the transfer
+ // instrument. Cash is still on its dropdown — a cheque can be cashed over the counter — but
+ // it is the exception, not what the field should start on.
case 'Cheque':
row.dbCr = 'Dr';
- row.paymentTypeId = this.findCashPaymentTypeId();
+ row.paymentTypeId = this.findTransferPaymentTypeId();
break;
case 'Disbursement':
+ case 'Fund Transfer':
if (!row.dbCr) row.dbCr = 'Dr';
row.paymentTypeId = this.findTransferPaymentTypeId();
break;
@@ -1293,6 +2347,42 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
if (!row.dbCr) row.dbCr = 'Dr';
row.paymentTypeId = this.findTransferPaymentTypeId();
break;
+ // Cashier-only: Receipt = cash coming in (Cr), Payment = cash going out (Dr). The cashier
+ // TXN TP. dropdown only ever offers Cash (see paymentTypesForRow()), so that's the only
+ // sensible default here.
+ case 'Receipt':
+ row.dbCr = 'Cr';
+ row.paymentTypeId = this.findCashPaymentTypeId();
+ row.chequeNo = '';
+ row.cheqDate = '';
+ break;
+ case 'Payment':
+ row.dbCr = 'Dr';
+ row.paymentTypeId = this.findCashPaymentTypeId();
+ row.chequeNo = '';
+ row.cheqDate = '';
+ break;
+ // A DD is bought with cash: the purchaser hands over Face Value + Charges + GST, so it is
+ // money coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment
+ // type and AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST
+ // panel never appears and the row would post as an ordinary entry with the charges silently
+ // dropped.
+ //
+ // A clerk funds a draft by debiting a member's own account, so their row opens on savings and
+ // is debited, and GL is not on their AC.TP list at all (see acTypOptionsFor). Opening them on
+ // GL put the row on the cash path, which asks for a walk-in purchaser and a note-and-coin
+ // count that a clerk debiting an account does not have.
+ case 'DD':
+ row.dbCr = this.isClerk ? 'Dr' : 'Cr';
+ row.acTyp = this.isClerk ? 'SD' : 'GL';
+ row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
+ row.chequeNo = '';
+ row.cheqDate = '';
+ if (this.sponsorBanks.length > 0 && !row.sponsorBankId) {
+ row.sponsorBankId = this.sponsorBanks[0].id;
+ }
+ this.applyDdGlMappingToRow(row);
+ break;
}
}
@@ -1306,25 +2396,212 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return pt?.id ?? null;
}
- /** TYPE=DD + TXN TP=Cash + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */
+ /** TYPE=DD + TXN TP=Cash */
isDdCashGlRow(row: TxnRow): boolean {
+ if (this.isDdAccountDebitRow(row)) return false;
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
- return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL';
+ return row.typ === 'DD' && (row.acTyp === 'GL' || !row.acTyp || !!pt?.isCashPayment);
}
- /** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
+ /**
+ * TYPE=DD funded by debiting a member's savings/current account, or a GL account directly, rather
+ * than by cash.
+ *
+ * Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account/GL debit
+ * would let a draft be raised with no cash to reconcile it against — and the clerk flow is the one
+ * that carries a maker-checker step over the money moved. AC.TP = GL means something different for
+ * a cashier's own DD row (a walk-in cash/GL purchase, see isDdCashGlRow()) — this method is checked
+ * first there specifically so a clerk's GL row is claimed here instead.
+ */
+ isDdAccountDebitRow(row: TxnRow): boolean {
+ return (
+ this.isClerk &&
+ row.typ === 'DD' &&
+ (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
+ );
+ }
+
+ /**
+ * Any DD row the charges/GST panel applies to.
+ *
+ * Charges and GST come from the admin's DD configuration and are collected whichever way the
+ * draft is paid for — the difference is only where the money comes from, so the panel is the same
+ * for both and the label on the total changes rather than the arithmetic.
+ */
+ isDdRow(row: TxnRow): boolean {
+ return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row);
+ }
+
+ /**
+ * Whether this row needs an Entry Type tag — a credit into a GL account, which is exactly the
+ * case where the money's real purpose isn't otherwise recorded anywhere (see TxnRow.entryTypeCvId).
+ * A debit out of a GL account, or any entry against a savings/current/share account, carries no
+ * such ambiguity — the account itself says what it's for.
+ */
+ showsEntryType(row: TxnRow): boolean {
+ return row.acTyp === 'GL' && row.dbCr === 'Cr';
+ }
+
+ /**
+ * Entry Type choices for a GL credit — the "GLEntryType" code (Admin > System > Manage Codes),
+ * fetched once in ngOnInit(). A code value, not a free-text field, so the day-end "group by type"
+ * this exists for stays reliable; an admin extends the list there, not in this file.
+ */
+ entryTypeOptions: { id: number; name: string }[] = [];
+
+ /**
+ * Whether this row may raise more than one draft off a single debit.
+ *
+ * Any funding source the clerk debits: a member's savings/current account, or a GL account for an
+ * institutional slip. Cash is the exception the batch endpoint still refuses, and for a real
+ * reason rather than an arbitrary one — a slip is funded by one payment, and one counted pile of
+ * notes cannot be apportioned across drafts without the denomination total ceasing to prove what
+ * was actually taken at the counter.
+ */
+ canRowBeMultiDd(row: TxnRow): boolean {
+ return this.isDdAccountDebitRow(row);
+ }
+
+ /** Whether the clerk has asked this row for more than one draft. */
+ isMultiDdRow(row: TxnRow): boolean {
+ return this.canRowBeMultiDd(row) && (Number(row.ddCount) || 1) > 1;
+ }
+
+ /** How many drafts this row raises — always at least 1. */
+ ddCountFor(row: TxnRow): number {
+ const n = Number(row.ddCount) || 1;
+ return n > 1 ? n : 1;
+ }
+
+ /**
+ * Clamps a typed DD count into range and discards lines that no longer fit.
+ *
+ * Lowering the count after the dialog has been filled would otherwise leave lines beyond the new
+ * count still on the row: invisible to the clerk, but still sent at Save and still debited for.
+ */
+ onDdCountChange(row: TxnRow, value: number): void {
+ const n = Math.floor(Number(value) || 1);
+ const previous = this.ddCountFor(row);
+ row.ddCount = n < 1 ? 1 : n > this.maxDdSlipLines ? this.maxDdSlipLines : n;
+ if (row.ddLines && row.ddLines.length !== row.ddCount) {
+ this.clearDdCapture(row);
+ }
+ // Moving onto a slip empties the row's own amount and charge, because on a slip they mean
+ // nothing: each draft carries its own face value and its own charge, entered in the dialog, and
+ // these two fields become the total of those. Leaving a previously typed figure sitting here
+ // would read as the slip's total while describing only one draft — which is exactly what showed
+ // ₹1,012 against four drafts of ₹1,000.
+ if (row.ddCount > 1 && previous <= 1) {
+ row.amount = null;
+ row.ddCharges = null;
+ }
+ }
+
+ /** Mirrors MAX_SLIP_LINES on the server, so an over-long slip is refused here with a clear message. */
+ readonly maxDdSlipLines = 20;
+
+ /**
+ * The run of leaflet numbers a slip will probably draw, for display only.
+ *
+ * Counts up from the next-available number, preserving its width so 000123 reads as 000124 rather
+ * than 124. Indicative by nature — the binding allocation happens inside the server's submit
+ * transaction, and a slip filed after another clerk's gets the numbers following theirs — which is
+ * why the dialog labels these provisional and the confirmed numbers come back on submission.
+ *
+ * A number that is not a plain run of digits (a prefixed or hyphenated series) cannot be counted
+ * on safely, so nothing is shown rather than something wrong.
+ */
+ private previewDdNumbersFrom(firstDdNumber: string, count: number): string[] | undefined {
+ const first = (firstDdNumber || '').trim();
+ if (!/^\d+$/.test(first)) return undefined;
+ const width = first.length;
+ const start = Number(first);
+ if (!Number.isSafeInteger(start)) return undefined;
+ return Array.from({ length: count }, (_v, i) => String(start + i).padStart(width, '0'));
+ }
+
+ /** GST on a single charge figure, at the admin-configured rate. */
+ private ddGstOn(charges: number): number {
+ const c = Number(charges) || 0;
+ if (c <= 0) return 0;
+ return Math.round(c * this.ddGstPercentage) / 100;
+ }
+
+ /**
+ * GST on DD charges only (never on the DD face value), at the admin-configured rate.
+ *
+ * On a slip this is the sum of each line's own GST rather than GST on the summed charges. The two
+ * can differ by a paisa once rounding is applied per line, and it is the per-line figure that is
+ * stored against each draft and posted by the server — so the row must show that same total.
+ */
ddGstAmountFor(row: TxnRow): number {
- const charges = Number(row.ddCharges) || 0;
- if (charges <= 0) return 0;
- return Math.round(charges * this.ddGstPercentage) / 100;
+ if (row.ddLines?.length) {
+ return row.ddLines.reduce((sum, l) => sum + this.ddGstOn(l.chargesAmount), 0);
+ }
+ return this.ddGstOn(row.ddCharges ?? 0);
+ }
+
+ /** Face value of the row — the sum of every line once a slip has been captured. */
+ ddFaceValueFor(row: TxnRow): number {
+ if (row.ddLines?.length) {
+ return row.ddLines.reduce((sum, l) => sum + (Number(l.faceValue) || 0), 0);
+ }
+ return Number(row.amount) || 0;
+ }
+
+ /** Charges on the row — the sum of every line's own charge once a slip has been captured. */
+ ddChargesFor(row: TxnRow): number {
+ if (row.ddLines?.length) {
+ return row.ddLines.reduce((sum, l) => sum + (Number(l.chargesAmount) || 0), 0);
+ }
+ return Number(row.ddCharges) || 0;
}
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
ddTotalPayableFor(row: TxnRow): number {
- const amount = Number(row.amount) || 0;
- const charges = Number(row.ddCharges) || 0;
- const gst = this.ddGstAmountFor(row);
- return Math.ceil(amount + charges + gst - 1e-9);
+ return Math.ceil(this.ddFaceValueFor(row) + this.ddChargesFor(row) + this.ddGstAmountFor(row) - 1e-9);
+ }
+
+ /**
+ * What the TOTAL field shows — a figure, or a dash while a slip has nothing to total yet.
+ *
+ * A slip whose drafts have not been entered has no total: its face values and charges live on the
+ * lines, and there are no lines. Showing ₹0 there would read as a real total of zero rather than
+ * as "not yet known", so the field says so instead.
+ */
+ ddTotalDisplayFor(row: TxnRow): string {
+ if (this.isSlipAwaitingDetails(row)) {
+ return '—';
+ }
+ return String(this.ddTotalPayableFor(row));
+ }
+
+ /** GST field's text, dashed for the same reason as the total while a slip is still empty. */
+ ddGstDisplayFor(row: TxnRow): string {
+ if (this.isSlipAwaitingDetails(row)) {
+ return '—';
+ }
+ return this.ddGstAmountFor(row).toFixed(2);
+ }
+
+ /** A slip the clerk has asked for but not yet filled in — nothing to total from. */
+ isSlipAwaitingDetails(row: TxnRow): boolean {
+ return this.isMultiDdRow(row) && !row.ddLines?.length;
+ }
+
+ /**
+ * Forgets a row's captured draft details, leaving the row itself intact.
+ *
+ * Both shapes are cleared together on purpose: a row carrying a stale ddDetails alongside fresh
+ * ddLines (or the reverse) would be read by whichever of the two Save paths it reached first, and
+ * the clerk would have no way of seeing which one went.
+ */
+ private clearDdCapture(row: TxnRow): void {
+ row.ddDetails = undefined;
+ row.ddLines = undefined;
+ row.ddInventoryId = undefined;
+ row.ddFundingAccountId = undefined;
+ row.ddFundingClientId = undefined;
}
get isSelectedRowDdCashGl(): boolean {
@@ -1340,6 +2617,497 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
}
+ branchNameFor(branchId: number): string {
+ const map: Record = {
+ 1: '001 · Main',
+ 2: '002 · M.G. Road',
+ 14: '014 · Station Rd',
+ 27: '027 · Industrial'
+ };
+ return map[branchId] || `Branch ${branchId}`;
+ }
+
+ /**
+ * Cashier Walk-in DD creation flow:
+ * 1. Validates one-time persistent GL configuration
+ * 2. Auto-selects next available leaflet
+ * 3. Opens dialog to capture purchaser/beneficiary details
+ * 4. Submits issue request directly with physical denominations for manager approval
+ */
+ openIssueWalkinDd(row: TxnRow): void {
+ if (!this.isDdGlMappingConfigured) {
+ this.showAlert('error', 'DD GL Mapping Not Configured', [
+ 'DD GL mapping is not configured.',
+ 'Please ask an administrator to set up the GL accounts in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts.'
+ ]);
+ return;
+ }
+ // On a slip the face value lives on each line, typed in the dialog that is about to open — the
+ // row's own AMOUNT is the sum of those lines and cannot exist yet. Demanding it here would make
+ // a slip impossible to start.
+ const isSlip = this.isMultiDdRow(row);
+ const faceValue = Number(row.amount);
+ if (!isSlip && (!faceValue || faceValue <= 0)) {
+ this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
+ return;
+ }
+ if (isSlip && !this.isDdAccountDebitRow(row)) {
+ this.showAlert('error', 'Multiple DDs Not Available', [
+ 'More than one draft can only be raised against a member savings or current account.',
+ 'Set NO. OF DD back to 1 for this row.'
+ ]);
+ return;
+ }
+ const chargesAmount = Number(row.ddCharges) || 0;
+ const gstAmount = this.ddGstAmountFor(row);
+ const totalCashPayable = this.ddTotalPayableFor(row);
+ const accountDebit = this.isDdAccountDebitRow(row);
+
+ // An account-funded draft needs the account resolved to a Fineract id before it can be sent:
+ // the server takes a savings/current account id and a client id, not the typed number. Both are
+ // filled in by the A/C NO. lookup, so an unresolved account means the number was never found.
+ // A GL-funded draft is the exception: a GL account is never tied to a client at all (see
+ // fetchAllDetails() — GL rows never populate rowClientIds), so only the account id is required.
+ const isGlDebit = accountDebit && row.acTyp === 'GL';
+ let fundingAccountId: number | null = null;
+ let fundingClientId: number | null = null;
+ if (accountDebit) {
+ const acNo = (row.acNo || '').trim();
+ if (!acNo) {
+ this.showAlert('error', 'Account Required', [
+ isGlDebit
+ ? 'Enter the GL account the Demand Draft is funded from.'
+ : 'Enter the account number the Demand Draft is funded from.'
+ ]);
+ return;
+ }
+ fundingAccountId = this.rowAccountIds[acNo] ?? null;
+ fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null);
+ if (!fundingAccountId || (!isGlDebit && !fundingClientId)) {
+ this.showAlert('error', 'Account Not Found', [
+ `Account ${acNo} could not be resolved.`,
+ 'Check the account number and wait for its details to load before raising the draft.'
+ ]);
+ return;
+ }
+ }
+
+ // Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a
+ // denomination count left over from an earlier row must not be checked against it. A slip is
+ // always an account debit, and its total is not known until the dialog is filled in either.
+ if (
+ !accountDebit &&
+ !isSlip &&
+ this.denominationTotal > 0 &&
+ Math.abs(this.denominationTotal - totalCashPayable) > 0.005
+ ) {
+ this.showAlert('error', 'Denomination Mismatch', [
+ `Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`,
+ 'Please verify and match the physical cash denomination count.'
+ ]);
+ return;
+ }
+
+ this.saving = true;
+ this.sponsorDDService.getNextAvailableLeaflet(row.br, row.sponsorBankId ?? undefined).subscribe({
+ next: (leaflet) => {
+ this.saving = false;
+ if (!leaflet) {
+ this.showAlert('error', 'No Available Leaflets', [
+ 'No available Demand Draft leaflets found for this branch and sponsor bank.',
+ 'Please register a DD inventory series first.'
+ ]);
+ return;
+ }
+
+ const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId);
+ const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br;
+
+ // A slip is allocated by inventory, not leaflet: the server picks the whole run inside its
+ // own locked transaction. Without the inventory behind this branch's next-available leaflet
+ // there is nothing to allocate from, so the slip is refused here rather than at submit.
+ if (isSlip && !leaflet.inventoryId) {
+ this.showAlert('error', 'DD Inventory Not Identified', [
+ 'The DD inventory behind this branch and sponsor bank could not be identified, so multiple drafts cannot be allocated together.',
+ 'Set NO. OF DD back to 1, or ask an administrator to check the DD inventory registration.'
+ ]);
+ return;
+ }
+
+ const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
+ width: isSlip ? '760px' : '600px',
+ disableClose: true,
+ data: {
+ leaflet,
+ branchName,
+ sponsorBankName: selectedBank?.name || 'Sponsor Bank',
+ faceValue,
+ chargesAmount,
+ gstPercentage: this.ddGstPercentage,
+ gstAmount,
+ totalCashPayable,
+ paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const),
+ accountNo: accountDebit ? row.acNo : undefined,
+ accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined,
+ ddCount: isSlip ? this.ddCountFor(row) : undefined,
+ glFunded: isGlDebit,
+ previewDdNumbers: isSlip ? this.previewDdNumbersFrom(leaflet.ddNumber, this.ddCountFor(row)) : undefined
+ }
+ });
+
+ // Recorded on the row, not sent. Save is what commits the worksheet, and a DD row that
+ // submitted itself here left that button with nothing to do — so the clerk had no way to
+ // review the entered details, and no way to abandon them either.
+ dialogRef.afterClosed().subscribe((result) => {
+ if (!result) return;
+
+ // A slip records its lines instead of a single beneficiary, and takes the row's AMOUNT and
+ // CHARGES with it: from here on those two are the sum of the lines, shown read-only, so a
+ // clerk cannot leave a row total behind that disagrees with what the drafts add up to.
+ if (result.lines) {
+ this.clearDdCapture(row);
+ row.ddLines = result.lines;
+ row.ddInventoryId = leaflet.inventoryId;
+ row.ddFundingAccountId = fundingAccountId!;
+ // Null on a GL slip, which is raised against no member at all.
+ row.ddFundingClientId = fundingClientId ?? undefined;
+ row.amount = this.ddFaceValueFor(row);
+ row.ddCharges = this.ddChargesFor(row);
+ this.showAlert('success', 'Slip Details Captured', [
+ `${result.lines.length} demand drafts captured in favour of ${result.lines
+ .map((l: DdSlipLine) => l.beneficiaryName)
+ .join(', ')}.`,
+ `₹${this.ddTotalPayableFor(row)} will be debited from ${isGlDebit ? 'GL account ' : ''}${row.acNo} as a single ${
+ isGlDebit ? 'journal entry' : 'transaction'
+ } once a checker approves.`,
+ 'DD numbers are allocated on submission. Press Save to send this slip for approval.'
+ ]);
+ return;
+ }
+
+ row.ddDetails = {
+ leafletId: leaflet.id,
+ ddNumber: leaflet.ddNumber,
+ beneficiaryName: result.beneficiaryName,
+ payableAtBranch: result.payableAtBranch,
+ payableAtCity: result.payableAtCity,
+ purchaser: accountDebit
+ ? undefined
+ : {
+ name: result.walkinPurchaserName,
+ address: result.walkinPurchaserAddress,
+ aadhaar: result.walkinPurchaserAadhaar
+ },
+ fundingAccountId: accountDebit ? fundingAccountId! : undefined,
+ fundingClientId: accountDebit ? fundingClientId! : undefined
+ };
+ this.showAlert('success', 'DD Details Captured', [
+ `DD No. ${leaflet.ddNumber} · in favour of ${result.beneficiaryName}.`,
+ accountDebit
+ ? `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} once a checker approves.`
+ : `₹${this.ddTotalPayableFor(row)} to be collected at the counter.`,
+ 'Press Save to send this draft for approval.'
+ ]);
+ });
+ },
+ error: (err) => {
+ this.saving = false;
+ this.showAlert('error', 'Error Auto-Selecting DD Leaflet', [err.error?.message || err.message]);
+ }
+ });
+ }
+
+ /**
+ * Raises a Demand Draft funded by debiting a member's savings/current account, or a GL account
+ * directly.
+ *
+ * Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The
+ * account/GL is only touched when a checker approves, which for a member account is also when the
+ * balance is re-checked — so a draft raised against a sufficient balance can still be refused if
+ * the balance has since gone (a GL account carries no such balance check). No denominations are
+ * sent, because no cash is taken.
+ *
+ * The funding account goes in exactly one of savingsAccountId, currentAccountId or glAccountId: the
+ * server insists on exactly one, since which one decides what gets debited on approval.
+ */
+ private submitAccountDebitDd(
+ row: TxnRow,
+ leaflet: any,
+ beneficiaryData: any,
+ fundingAccountId: number,
+ fundingClientId: number | null
+ ): void {
+ const d = new Date();
+ const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+ const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
+ const isGl = row.acTyp === 'GL';
+
+ const payload = {
+ clientId: isGl ? undefined : (fundingClientId ?? undefined),
+ beneficiaryName: beneficiaryData.beneficiaryName,
+ payableAtBranch: beneficiaryData.payableAtBranch,
+ payableAtCity: beneficiaryData.payableAtCity,
+ faceValue: Number(row.amount),
+ chargesAmount: Number(row.ddCharges) || 0,
+ issuingBranchId: row.br,
+ issuedDate,
+ paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
+ savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
+ currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId,
+ glAccountId: isGl ? fundingAccountId : undefined
+ };
+
+ this.saving = true;
+ this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
+ next: () => {
+ this.saving = false;
+ this.showAlert('success', 'DD Issuance Request Submitted', [
+ `Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}) submitted successfully!`,
+ `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} on approval.`,
+ 'Sent to Manager / Head Cashier for maker-checker approval.'
+ ]);
+ row.amount = null;
+ row.ddCharges = null;
+ row.description = '';
+ // Cleared with the row: leaving the captured details behind would let a second Save raise
+ // the same draft again against a leaflet that has already been used.
+ row.ddDetails = undefined;
+ },
+ error: (err) => {
+ this.saving = false;
+ this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
+ }
+ });
+ }
+
+ /**
+ * Sends the drafts on the worksheet for approval.
+ *
+ * Each row carries the beneficiary details the dialog captured; a row that was never filled in is
+ * refused by name rather than skipped, because a clerk who pressed Save expecting one draft and
+ * got none has no way of telling which row was missing.
+ */
+ private saveDdRows(ddRows: TxnRow[]): void {
+ const unfilled = ddRows.filter((r) => !r.ddDetails && !r.ddLines?.length);
+ if (unfilled.length > 0) {
+ this.showAlert('error', 'DD Details Not Entered', [
+ unfilled.length === 1
+ ? 'One DD row has no beneficiary details yet.'
+ : `${unfilled.length} DD rows have no beneficiary details yet.`,
+ 'Use "Fill DD Details & Send for Approval" on each DD row first, then press Save.'
+ ]);
+ return;
+ }
+
+ for (const row of ddRows) {
+ // A slip goes to the batch endpoint, which allocates its leaflets and posts one debit for the
+ // whole run. Sending its lines one at a time through the single-DD endpoint would debit the
+ // member once per draft — the very thing a slip exists to avoid.
+ if (row.ddLines?.length) {
+ this.submitDdSlip(row);
+ continue;
+ }
+
+ const details = row.ddDetails!;
+ const leaflet = { id: details.leafletId, ddNumber: details.ddNumber };
+ if (this.isDdAccountDebitRow(row)) {
+ this.submitAccountDebitDd(
+ row,
+ leaflet,
+ {
+ beneficiaryName: details.beneficiaryName,
+ payableAtBranch: details.payableAtBranch,
+ payableAtCity: details.payableAtCity
+ },
+ details.fundingAccountId!,
+ details.fundingClientId!
+ );
+ } else {
+ this.submitWalkinCashDd(row, leaflet, {
+ beneficiaryName: details.beneficiaryName,
+ payableAtBranch: details.payableAtBranch,
+ payableAtCity: details.payableAtCity,
+ walkinPurchaserName: details.purchaser?.name,
+ walkinPurchaserAddress: details.purchaser?.address,
+ walkinPurchaserAadhaar: details.purchaser?.aadhaar
+ });
+ }
+ }
+ }
+
+ /**
+ * Submits a multi-DD slip: several drafts raised together against one debit of the member's
+ * account.
+ *
+ * Maker-checker like the single-draft flow, and nothing is debited here. On approval the server
+ * withdraws the combined face value, charges and GST of every line as ONE transaction and posts
+ * one journal entry against it, then issues each draft — so the member's passbook carries a single
+ * entry naming all the DD numbers rather than one entry per draft. The checker approves or rejects
+ * the slip whole; there is no approving four drafts out of five.
+ *
+ * The leaflets are not chosen here. The server allocates the run inside its own locked transaction
+ * and returns which number went to which line, which is the only point at which the numbers are
+ * real — two clerks submitting at the same moment can therefore never be handed the same one.
+ */
+ private submitDdSlip(row: TxnRow): void {
+ const lines = row.ddLines ?? [];
+ const inventoryId = row.ddInventoryId;
+ const fundingAccountId = row.ddFundingAccountId;
+ const fundingClientId = row.ddFundingClientId;
+
+ const isGl = row.acTyp === 'GL';
+
+ // Re-checked at Save, not just when the details were captured: the row's TYPE, AC.TP or account
+ // number can all be edited afterwards, and a slip that has drifted onto a cash row is one the
+ // batch endpoint refuses outright. A GL slip is raised against no member, so it carries no
+ // client id and none is required of it — the same asymmetry as the single-draft GL path.
+ if (!inventoryId || !fundingAccountId || (!isGl && !fundingClientId) || !this.canRowBeMultiDd(row)) {
+ this.showAlert('error', 'Slip Cannot Be Submitted', [
+ 'This row has changed since its drafts were captured, so the slip no longer matches it.',
+ 'Open "Fill DD Details" again and re-enter the drafts.'
+ ]);
+ return;
+ }
+
+ const d = new Date();
+ const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+ const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
+
+ // The funding source goes in exactly one of savingsAccountId, currentAccountId or glAccountId,
+ // as the single-draft path does: which one is set is what decides the debit leg on approval.
+ const payload = {
+ clientId: isGl ? undefined : fundingClientId,
+ issuingBranchId: row.br,
+ issuedDate,
+ paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
+ savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
+ currentAccountId: !isGl && isCurrent ? fundingAccountId : undefined,
+ glAccountId: isGl ? fundingAccountId : undefined,
+ lines: lines.map((l) => ({
+ beneficiaryName: l.beneficiaryName,
+ payableAtBranch: l.payableAtBranch,
+ payableAtCity: l.payableAtCity,
+ faceValue: Number(l.faceValue),
+ chargesAmount: Number(l.chargesAmount) || 0
+ }))
+ };
+
+ const totalDebited = this.ddTotalPayableFor(row);
+ const accountNo = row.acNo;
+
+ this.saving = true;
+ this.sponsorDDService.submitIssueRequestBatch(inventoryId, payload).subscribe({
+ next: (result: any) => {
+ this.saving = false;
+ // The allocated numbers, named back to the clerk. Until this response they were provisional,
+ // and these are what the customer's drafts will actually carry.
+ const allocated = (result?.lines ?? [])
+ .map((l: any) => `${l.ddNumber} · ${l.beneficiaryName} · ₹${l.faceValue}`)
+ .join('\n');
+ this.showAlert('success', 'DD Slip Submitted', [
+ `Slip of ${lines.length} demand drafts submitted successfully.`,
+ allocated || `${lines.length} drafts allocated.`,
+ `₹${totalDebited} will be debited from ${accountNo} as a single transaction on approval.`,
+ 'Sent to Manager / Head Cashier for maker-checker approval of the whole slip.'
+ ]);
+ row.amount = null;
+ row.ddCharges = null;
+ row.description = '';
+ row.ddCount = 1;
+ // Cleared with the row, as the single-draft paths do: leaving the lines behind would let a
+ // second Save raise the same slip again against leaflets already allocated.
+ this.clearDdCapture(row);
+ },
+ error: (err) => {
+ this.saving = false;
+ this.showAlert('error', 'DD Slip Submission Failed', [
+ err.error?.message || err.message,
+ 'No drafts were issued and nothing has been debited — the whole slip was refused together.'
+ ]);
+ }
+ });
+ }
+
+ private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
+ // Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
+ // right but matched nothing, so every cash DD went in with no breakdown at all and its money
+ // vanished from the closing report -- the notes were counted on screen and then dropped.
+ const denominationMap: { [key: string]: number } = {};
+ for (const d of [
+ ...this.notes,
+ ...this.coins
+ ]) {
+ const count = this.countFor(d);
+ if (count > 0) {
+ denominationMap[this.denomKey(d)] = count;
+ }
+ }
+
+ // The breakdown is the only record of which notes were taken, and the server insists it add up
+ // to the cash collected. Checked here so the cashier is told which figure is wrong while the
+ // counts are still on screen, rather than getting a rejection after the fact.
+ //
+ // ddTotalPayableFor rounds up to the whole rupee actually collected, which is what the notes
+ // can add to; the exact figure with paise is accepted too, for a draft that lands on a rupee.
+ const totalCollected = Number(this.ddTotalPayableFor(row));
+ const exactPayable = Number(row.amount || 0) + (Number(row.ddCharges) || 0) + this.ddGstAmountFor(row);
+ const countedTotal = Object.entries(denominationMap).reduce(
+ (sum, [
+ key,
+ count
+ ]) => sum + Number(key.split(':')[1]) * count,
+ 0
+ );
+ if (countedTotal !== totalCollected && countedTotal !== exactPayable) {
+ this.showAlert('error', 'Denomination Entry Does Not Match', [
+ countedTotal === 0
+ ? 'Enter the notes and coins taken from the purchaser before submitting.'
+ : `The notes and coins entered come to ₹${countedTotal.toFixed(2)}, but ₹${totalCollected.toFixed(2)} is collected (face value + charges + GST).`,
+ 'The breakdown must equal the cash taken — it is what the closing report and the drawer are checked against.'
+ ]);
+ return;
+ }
+
+ const d = new Date();
+ const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+
+ const payload = {
+ walkinPurchaserName: purchaserData.walkinPurchaserName,
+ walkinPurchaserAddress: purchaserData.walkinPurchaserAddress,
+ walkinPurchaserAadhaar: purchaserData.walkinPurchaserAadhaar,
+ beneficiaryName: purchaserData.beneficiaryName,
+ payableAtBranch: purchaserData.payableAtBranch,
+ payableAtCity: purchaserData.payableAtCity,
+ faceValue: Number(row.amount),
+ chargesAmount: Number(row.ddCharges) || 0,
+ issuingBranchId: row.br,
+ issuedDate,
+ paymentMethod: 'CASH' as const,
+ denominationData: Object.keys(denominationMap).length > 0 ? JSON.stringify(denominationMap) : undefined
+ };
+
+ this.saving = true;
+ this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
+ next: () => {
+ this.saving = false;
+ this.showAlert('success', 'DD Issuance Request Submitted', [
+ `Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}, Total Cash: ₹${this.ddTotalPayableFor(row)}) submitted successfully!`,
+ 'Sent to Manager / Head Cashier for maker-checker approval.'
+ ]);
+ row.amount = null;
+ row.ddCharges = null;
+ row.description = '';
+ // See the account-debit path: a second Save must not re-raise a draft already issued.
+ row.ddDetails = undefined;
+ this.clearDenominations();
+ },
+ error: (err) => {
+ this.saving = false;
+ this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
+ }
+ });
+ }
+
/**
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
@@ -1348,6 +3116,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private submitDisbursementDoubleEntry(): void {
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
+ // Captured now, not re-read from this.rows inside the async success callback below — the rows
+ // array gets reset to a fresh blank row as soon as that callback runs.
+ const isFundTransfer = this.rows[0]?.typ === 'Fund Transfer';
const isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL';
const isDisbursementMode = drRows.length === 1 && drRows[0].acTyp === 'GL';
@@ -1401,7 +3172,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
dbCr: 'Cr',
accountNo: cr.acNo,
accountName: this.rowAccountNames[cr.acNo] ?? cr.acNo,
- accountType: cr.acTyp === 'CA' || cr.acTyp === 'CB' ? 'CURRENT' : cr.acTyp === 'GL' ? 'GL' : 'SAVINGS',
+ accountType: this.creditEntryAccountType(cr.acTyp),
savingsAccountId: cr.acTyp !== 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null,
glAccountId: cr.acTyp === 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null,
amount: String(cr.amount),
@@ -1421,7 +3192,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
dbCr: r.dbCr,
accountNo: r.acNo,
accountName: this.rowAccountNames[r.acNo] ?? r.acNo,
- accountType: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : r.acTyp === 'GL' ? 'GL' : 'SAVINGS',
+ accountType: this.creditEntryAccountType(r.acTyp),
savingsAccountId: r.acTyp !== 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null,
glAccountId: r.acTyp === 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null,
amount: String(r.amount),
@@ -1440,7 +3211,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
paymentTypeId: firstRow?.paymentTypeId,
paymentTypeName: this.paymentTypes.find((p: any) => p.id === firstRow?.paymentTypeId)?.name ?? null,
description: firstRow?.description || null,
- creditEntries: JSON.stringify(entries)
+ creditEntries: JSON.stringify(entries),
+ // Without this the backend defaults every request from this screen to DISBURSEMENT — a
+ // Collection or Fund Transfer submitted here was being saved (and shown in the checker's
+ // inbox) as a plain Disbursement.
+ requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT',
+ // Same ambiguity as a cashier Receipt — a Collection credits a GL account picked by hand.
+ // See TxnRow.entryTypeCvId.
+ ...(isCollectionMode ? { entryTypeCvId: crRows[0]?.entryTypeCvId || null } : {})
};
this.saving = true;
@@ -1460,7 +3238,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.subscribe((res: any) => {
this.saving = false;
if (res) {
- const modeLabel = isCollectionMode ? 'Collection' : 'Disbursement';
+ const modeLabel = isCollectionMode ? 'Collection' : isFundTransfer ? 'Fund Transfer' : 'Disbursement';
const summaryLine = isCollectionMode
? `DR: ${drRows.length} account(s) → CR: GL ${crRows[0].acNo}`
: `DR: GL ${drRows[0].acNo} → CR: ${crRows.length} account(s)`;
@@ -1490,7 +3268,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
dbCr,
accountNo: r.acNo,
accountName: this.rowAccountNames[r.acNo] ?? r.acNo,
- accountType: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS',
+ accountType: this.creditEntryAccountType(r.acTyp),
savingsAccountId: this.rowAccountIds[r.acNo],
amount: String(r.amount),
description: r.description ?? null
@@ -1530,7 +3308,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.showAlert('success', 'Bank Transfer Submitted', [
`Voucher No: ${res.voucherNo}`,
`Total: ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })} · DR: ${drRows.length} account(s) → CR: ${crRows.length} account(s)`,
- 'Pending Manager approval. Track status in "My Disbursement Requests".'
+ 'Pending Manager approval. Track status in "Disbursements & Transfer".'
]);
this.rows = [];
this.rowAccountIds = {};
@@ -1563,7 +3341,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
if (settlementPaymentType?.isCashPayment) {
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
'Share redemption settlement is always a book-transfer to the linked savings account, never cash.',
- 'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).'
+ 'Select Transfer as the Payment Type on the CR row.'
]);
return;
}
@@ -1682,6 +3460,168 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
);
}
+ /**
+ * Share redemption settled to a GL account the clerk picks on the CR row, instead of the
+ * member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
+ * credit side is any GL account (e.g. a "DD Payable" holding account), not a fixed linked
+ * account, so there is no "must match the linked account" check here.
+ */
+ private submitShareGlSettlementTransfer(shareRow: TxnRow, glRow: TxnRow): void {
+ // Belt-and-braces: the TXN TP. dropdown already hides cash payment types for AC.TP = SHR (see
+ // paymentTypesForRow()), but re-check here too in case the payment type was set before that
+ // filter applied.
+ const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === glRow.paymentTypeId);
+ if (!glRow.paymentTypeId) {
+ this.showAlert('error', 'Settlement Payment Type Required', [
+ 'Select a Payment Type on the CR (GL account) row — it governs how the settlement credit posts.'
+ ]);
+ return;
+ }
+ if (settlementPaymentType?.isCashPayment) {
+ this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
+ 'Share redemption settlement is always a book-transfer, never cash.',
+ 'Select Transfer as the Payment Type on the CR row.'
+ ]);
+ return;
+ }
+
+ const shareAccountId = this.rowAccountIds[shareRow.acNo];
+ if (!shareAccountId) {
+ this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
+ return;
+ }
+ const settlementGlAccountId = this.rowAccountIds[glRow.acNo];
+ if (!settlementGlAccountId) {
+ this.showAlert('error', 'GL Account Not Loaded', [`Please load account details for: ${glRow.acNo}`]);
+ return;
+ }
+
+ const shares = Number(shareRow.amount) || 0;
+ if (shares <= 0) {
+ this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
+ return;
+ }
+ if (!shareRow.resolutionNumber?.trim()) {
+ this.showAlert('error', 'Board Resolution Number Required', [
+ 'A share withdrawal must be authorised by a Board Resolution.'
+ ]);
+ return;
+ }
+ if (!shareRow.meetingDate) {
+ this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
+ return;
+ }
+
+ const today = new Date();
+ const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
+
+ // Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
+ // for audit purposes, same as the linked-savings path above.
+ const settlementRowSnapshot = {
+ branch: glRow.br,
+ accountType: glRow.acTyp,
+ accountNo: glRow.acNo,
+ ecsAccountNo: glRow.ecsAcNo || null,
+ dbCr: glRow.dbCr,
+ amount: glRow.amount ?? null,
+ chequeNo: glRow.chequeNo || null,
+ chequeDate: glRow.cheqDate || null,
+ tokenNo: glRow.tokNo || null,
+ description: glRow.description || null,
+ bankName: glRow.bankName || null,
+ routingCode: glRow.routingCode || null,
+ paymentTypeId: glRow.paymentTypeId,
+ paymentTypeName: settlementPaymentType?.name ?? null
+ };
+
+ const payload = {
+ shareAccountId,
+ accountNo: shareRow.acNo,
+ clientId: this.rowClientIds[shareRow.acNo] ?? null,
+ clientName: this.rowAccountNames[shareRow.acNo] ?? null,
+ requestedShares: shares,
+ requestedDate,
+ resolutionNumber: shareRow.resolutionNumber.trim(),
+ meetingDate: shareRow.meetingDate,
+ description: shareRow.description || null,
+ settlementPaymentTypeId: glRow.paymentTypeId,
+ settlementRowSnapshot,
+ settlementMode: 'GL',
+ settlementGlAccountId
+ };
+
+ const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
+ const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0;
+ const remainingShares = currentHolding - shares;
+ const willCloseAccount = remainingShares <= 0;
+ const confirmLines = [
+ `Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
+ `Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
+ `Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`,
+ ...(willCloseAccount
+ ? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.']
+ : []),
+ `Board Resolution No.: ${payload.resolutionNumber}`,
+ `Date of Meeting: ${payload.meetingDate}`,
+ `Settlement Credit To (GL): ${glRow.acNo}`,
+ `Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
+ ...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
+ '',
+ willCloseAccount
+ ? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
+ : 'Pending Manager approval — redemption and settlement both post together on approval.'
+ ];
+ this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
+ this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
+ );
+ }
+
+ private doSubmitShareGlSettlementTransfer(
+ payload: Record,
+ shareRow: TxnRow,
+ shares: number,
+ glAcNo: string
+ ): void {
+ this.saving = true;
+ this.clerkShareWithdrawalService
+ .submit(payload)
+ .pipe(
+ catchError((err: any) => {
+ const msg =
+ err?.error?.errors?.[0]?.defaultUserMessage ??
+ err?.error?.developerMessage ??
+ err?.error?.defaultUserMessage ??
+ 'Share withdrawal submission failed. Please try again.';
+ this.showAlert('error', 'Submission Failed', [msg]);
+ this.saving = false;
+ return of(null);
+ })
+ )
+ .subscribe((res: any) => {
+ this.saving = false;
+ if (res) {
+ const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0;
+ this.showAlert('success', 'Share Withdrawal Submitted', [
+ `${shares} share(s) from ${shareRow.acNo} → settlement to GL ${glAcNo} on approval.`,
+ willCloseAccount
+ ? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".'
+ : 'Pending Manager approval. Track status in "My Share Requests".'
+ ]);
+ this.rows = [];
+ this.rowAccountIds = {};
+ this.rowAccountNames = {};
+ this.rowSharePrices = {};
+ this.rowClientIds = {};
+ this.rowLinkedSavingsAcNo = {};
+ this.rowShareHoldings = {};
+ this.addRow();
+ this.selectedRow = this.rows[0];
+ this.accountDetails = null;
+ this.clientDetails = null;
+ }
+ });
+ }
+
private doSubmitShareRedemptionTransfer(
payload: Record,
shareRow: TxnRow,
@@ -1735,25 +3675,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.loadingWithdrawal = true;
this.activeWithdrawalRequest = null;
this.activeDividendClaimRequest = null;
+ this.activeClosureRequest = null;
this.clerkWithdrawalService
.getByToken(token)
.pipe(
catchError((err: any) => {
// Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED,
- // which are legitimate matches with their own specific error) — try the dividend
- // cash-claim token pool before giving up, since both share the same TOK. NO. field.
+ // which are legitimate matches with their own specific error) — try the account-closure
+ // pool before giving up: both share the same TOK. NO. field at this counter. A dividend
+ // claim was once a third pool here, but a dividend never actually pays out in cash over
+ // this counter — it only ever settles through a Demand Draft, which is its own DD
+ // issuance request and its own token, so looking one up here could only ever 404.
if (err?.status === 404) {
- return this.dividendClaimRequestService.getByToken(token).pipe(
- catchError((divErr: any) => {
+ return this.closureRequestService.getByToken(token).pipe(
+ catchError((closeErr: any) => {
this.loadingWithdrawal = false;
const msg: string =
- divErr?.error?.message ??
- divErr?.error?.errors?.[0]?.defaultUserMessage ??
+ closeErr?.error?.message ??
+ closeErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
- map((divReq: any) => (divReq ? { __dividendClaim: true, ...divReq } : null))
+ map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
);
}
this.loadingWithdrawal = false;
@@ -1779,23 +3723,70 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientDetails = null;
this.accountError = '';
row.acNo = req.client_name ?? 'Dividend Claim #' + req.id;
+ // Marks this acNo as already resolved, the same way loadAccountDetails() itself would —
+ // without it, the next click into this row (onRowSelect) sees an acNo that was never
+ // "loaded" and re-searches for a real account matching this stub string, which can land
+ // on an unrelated real account whose number happens to share a suffix with it.
+ this.lastLoadedAcNo = row.acNo;
row.acTyp = 'SD';
+ // Every token-driven row is cash leaving the branch to a member, which is what the
+ // Payment register is for — not what this row's TYPE happened to default to before the
+ // token was entered.
+ row.typ = 'Payment';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
this.selectedRow = row;
return;
}
+ if (req.__closureRequest) {
+ this.activeClosureRequest = req;
+ // The account is already CLOSED at Fineract level by the time a cashier sees this token
+ // (the close command ran at manager approval), so its own detail response would show a
+ // closed account with a zero balance. What the cashier needs here is the rounded cash
+ // figure to count out, which the request itself carries.
+ this.accountDetails = {
+ id: null,
+ accountNo: req.account_no,
+ clientName: req.client_name
+ };
+ this.kycDetails = null;
+ this.clientDetails = null;
+ this.accountError = '';
+ row.acNo = req.account_no ?? '';
+ // See the same line in the dividend-claim branch above for why this is required.
+ this.lastLoadedAcNo = row.acNo;
+ row.acTyp = 'SD';
+ row.typ = 'Payment';
+ row.amount = Number(req.cashier_disbursed_amount) || null;
+ row.dbCr = 'Dr';
+ // A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the
+ // real cash payment type here so the cashier isn't asked to pick one and the
+ // "Payment Type Required" guard on the disbursement path is satisfied.
+ row.paymentTypeId = req.payment_type_id ?? this.findCashPaymentTypeId();
+ row.description = 'Account closure — ' + (req.client_name ?? req.account_no);
+ this.selectedRow = row;
+ return;
+ }
+
this.activeWithdrawalRequest = req;
+ // A plain withdrawal-slip request carries its own real account_type (SAVINGS, CURRENT,
+ // FIXED_DEPOSIT or RECURRING_DEPOSIT — see ClerkWithdrawalRequestApiResource.submit()), unlike
+ // the closure/dividend pools above, which are never anything but a savings-shaped stub.
+ // Hardcoding 'SD' here searched every non-savings account on the wrong endpoint (Current
+ // accounts live under /currentaccounts, not /savingsaccounts) and showed it mislabelled even
+ // when the lookup happened to succeed.
+ const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
row.acNo = req.account_no ?? '';
- row.acTyp = 'SD';
+ row.acTyp = acTyp;
+ row.typ = 'Payment';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
row.paymentTypeId = req.payment_type_id ?? null;
row.description = req.description ?? '';
this.selectedRow = row;
if (req.account_no) {
- this.loadAccountDetails(req.account_no.trim(), 'SD');
+ this.loadAccountDetails(req.account_no.trim(), acTyp);
}
});
}
@@ -1822,10 +3813,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
onAcNoBlur(row: TxnRow, index: number): void {
if (!row.acNo.trim()) return;
this.selectedRow = row;
+ // A token-driven row's account fields are already authoritative (see onTokNoBlur) -- merely
+ // clicking into and out of the A/C NO. field here must not re-search for them. Doing so used
+ // row.acTyp's internal stand-in value (e.g. 'SD' for a dividend claim, which is never the
+ // account's real type) against the account's real number, either finding nothing ("Account
+ // not found") or, worse, matching a completely unrelated real account that happened to share a
+ // number suffix.
+ if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
+ return;
+ }
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
onAcNoInput(row: TxnRow, index: number, value: string): void {
+ // Same reasoning as the guard at the top of onAcNoBlur() -- a token-driven row's account
+ // fields must not be edited or re-searched via this field at all.
+ if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
+ return;
+ }
row.acNo = value;
this.lastLoadedAcNo = '';
this.acNoInput$.next({ row, index, value });
@@ -1843,6 +3848,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
byName(/vault/i) ?? byName(/cash/i) ?? items.find((a: any) => a.glAccountData?.type?.value === 'ASSET');
if (found?.glAccountData?.id) {
this.cashVaultGlId = found.glAccountData.id;
+ this.cashVaultGlCode = found.glAccountData.glCode ?? null;
+ this.cashVaultGlName = found.glAccountData.name ?? found.financialActivityData?.name ?? null;
}
});
}
@@ -1896,6 +3903,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientPhotoUrl = null;
if (this.signatureUrl?.startsWith('blob:')) URL.revokeObjectURL(this.signatureUrl);
this.signatureUrl = null;
+ this.holdBreakdown = null;
this.accountNotes = [];
this.standingInstructions = [];
this.todayTransactions = [];
@@ -1910,7 +3918,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Step 1: search on the correct endpoint for this account type
this.http
.get(
- `${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}`
+ `${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}${cfg.extraListQueryParams ? `&${cfg.extraListQueryParams}` : ''}`
)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
@@ -1919,16 +3927,30 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const f = cfg.matchField;
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
- // FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
- // gated on "exactly one row came back" — it never would be for those types.
- const exactMatch = allItems.find((x: any) => x[f] === acNo);
+ const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
const partialMatch = !exactMatch
- ? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
+ ? (allItems.find((x: any) => String(x[f] ?? '').endsWith(acNo)) ??
+ allItems.find((x: any) => String(x[f] ?? '').includes(acNo)))
: null;
- const match = exactMatch ?? partialMatch ?? null;
+ const idMatch =
+ !exactMatch && !partialMatch && acTyp === 'GL'
+ ? allItems.find((x: any) => String(x.id) === String(acNo))
+ : null;
+ const match = exactMatch ?? partialMatch ?? idMatch ?? null;
if (match?.id) {
+ const matchedGlCode = match[f] ?? acNo;
+ // Any row resolved via the partial/endsWith/id fallback above (not an exact code match)
+ // loads and displays the right account, but the row itself still carries whatever shorter
+ // string the cashier actually typed to search with -- and that's what gets submitted and
+ // shown everywhere downstream (vouchers, My Work, credit_entries), not the account's real
+ // full number. Correct the row to the account's actual code whenever the match wasn't
+ // already exact, for every account type -- not just the DD/GL cases this was previously
+ // limited to.
+ if (this.selectedRow && !exactMatch && matchedGlCode) {
+ this.selectedRow.acNo = matchedGlCode;
+ }
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
- this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp, clientIdFromSearch);
+ this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
return;
}
this.loadingAccount = false;
@@ -1937,6 +3959,36 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
/** Maps UI account type → { listEndpoint, detailEndpoint, nomineeType, searchResource } */
+ /**
+ * The account-type label carried on a disbursement request's credit/debit entry.
+ *
+ * The backend (ClerkDisbursementRequestApiResource) only ever branches on 'GL' and 'CURRENT'; every
+ * other value takes the same path, and in the disbursement flow it resolves current-vs-savings from
+ * the database rather than from this string. So naming a fixed or recurring deposit accurately here
+ * changes only what the approving checker sees, never what gets posted. Previously anything that
+ * wasn't current or GL was labelled 'SAVINGS', which showed a checker an FD as a savings account --
+ * exactly the wrong information to approve a disbursement against.
+ */
+ private creditEntryAccountType(acTyp: string | undefined | null): string {
+ switch ((acTyp ?? '').toUpperCase()) {
+ case 'GL':
+ return 'GL';
+ case 'CA':
+ case 'CB':
+ return 'CURRENT';
+ case 'FD':
+ return 'FIXED_DEPOSIT';
+ case 'RD':
+ return 'RECURRING_DEPOSIT';
+ case 'SHR':
+ return 'SHARE';
+ case 'LA':
+ return 'LOAN';
+ default:
+ return 'SAVINGS';
+ }
+ }
+
private accountTypeConfig(acTyp: string): {
list: string;
detail: string;
@@ -1962,6 +4014,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
matchField: string;
detailParams: string;
supportsPagination: boolean;
+ /** Extra fixed query params (e.g. "usage=1") appended to every list-search request for this
+ * account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
+ * for a transaction; a Header can never receive a posting, so it should never be offered. */
+ extraListQueryParams?: string;
} {
const t = acTyp?.toUpperCase();
if (t === 'LA') {
@@ -2015,7 +4071,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
listQueryParam: 'searchParam',
matchField: 'glCode',
detailParams: '?fetchRunningBalance=true',
- supportsPagination: false
+ supportsPagination: false,
+ // Detail-only: a Header account is a pure grouping node and can never receive a
+ // transaction, so it must never be a selectable match here in the first place.
+ extraListQueryParams: 'usage=1'
};
}
if (t === 'CA' || t === 'CB') {
@@ -2126,18 +4185,35 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return false;
}
const returnedId = acc.glCode ?? acc.accountNo;
- if (returnedId && returnedId !== acNo) {
+ if (returnedId && returnedId !== acNo && String(acc.id) !== acNo) {
this.loadingAccount = false;
this.accountError = 'Account mismatch. Please re-enter account number and try again.';
return false;
}
this.accountDetails = acc;
this.jointHolders = acc.jointHolders ?? [];
+ // Cash Hold / Clearing Hold breakdown only exists for plain savings accounts (m_savings_account)
+ // — FD/RD/Current/Share/GL have no such row, so there's nothing to ask for.
+ if (acTyp?.toUpperCase() === 'SD' && acc.id) {
+ this.http
+ .get(`/savingsaccounts/${acc.id}/holdbreakdown`)
+ .pipe(catchError(() => of(null)))
+ .subscribe((hb: any) => {
+ if (token !== this.searchToken) return;
+ this.holdBreakdown = hb
+ ? { cashHold: Number(hb.cashHold) || 0, clearingHold: Number(hb.clearingHold) || 0 }
+ : null;
+ });
+ }
// Track per-row account id/name for multi-row disbursement
if (this.selectedRow?.acNo) {
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
+ // clientName must be in this chain: a deposit account's detail response carries the holder
+ // under clientName, not name/displayName (only GL accounts have `name`). Without it every FD
+ // and RD fell through to accountNo, so a disbursement request showed the checker an account
+ // number where the account holder's name belongs.
this.rowAccountNames[this.selectedRow.acNo] =
- acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
+ acc.name ?? acc.displayName ?? acc.clientName ?? acc.glAccountName ?? acc.accountNo ?? '';
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
}
if (this.selectedRow) {
@@ -2317,8 +4393,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
alerts.push({ type: 'danger', message: 'Sanctions Match — DO NOT process' });
if (isYes(this.kycDetails?.willfulDefaulter))
alerts.push({ type: 'danger', message: 'Willful Defaulter — Refer to Manager' });
- if (this.kycDetails?.amlCheckStatus === 'FLAGGED')
- alerts.push({ type: 'danger', message: 'AML Flag — Suspicious Activity' });
+ // No AML-suspicion banner here by design. Sanctions and willful-defaulter above are legal
+ // prohibitions the cashier has to act on at the counter; AML suspicion is not — showing it
+ // risks tipping off the customer. It stays in the AML review queue and the STR/CTR reports.
return alerts;
}
@@ -2354,7 +4431,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
{ label: 'NEFT Facility', active: !!f.neftFacility },
{ label: 'RTGS Facility', active: !!f.rtgsFacility },
{ label: 'Inter-Bank Cap', active: !!f.interBankTotalCapFacility },
- { label: 'Withdrawal', active: !!f.withdrawalFacility },
{ label: 'Deposit (KYC)', active: !!f.depositFacilityKyc },
{ label: 'Virtual Debit Card', active: !!f.virtualDebitCard },
{ label: 'OD', active: !!this.accountDetails?.allowOverdraft }
@@ -2441,6 +4517,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return this.accountDetails?.savingsAmountOnHold ?? 0;
}
+ /** A manual, staff-placed lien — the same underlying figure "Transfer Hold" already shows
+ * ({@link freezeAmount}); Fineract has one bucket for this, not two, so both fields legitimately
+ * read the same number rather than one of them being invented data. */
+ get cashHold(): number {
+ return this.holdBreakdown?.cashHold ?? this.freezeAmount;
+ }
+
+ /** Money still moving through the clearing process — a cheque/DD deposit not yet realized, or a
+ * presented cheque not yet settled. Only populated for a plain savings account; 0 elsewhere. */
+ get clearingHold(): number {
+ return this.holdBreakdown?.clearingHold ?? 0;
+ }
+
get withdrawableAmount(): number {
const avail = this.accountDetails?.summary?.availableBalance ?? this.currentBalance;
const acTyp = this.selectedRow?.acTyp;
@@ -2747,6 +4836,204 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
}
+ /** Only meaningful for a plain savings account — the transactions array a Statement is built from
+ * is embedded on that account type's own detail response (?associations=all), never on
+ * FD/RD/Current/Share/GL. */
+ get canPrintStatement(): boolean {
+ return this.selectedRow?.acTyp === 'SD' && !!this.accountDetails?.id;
+ }
+
+ printStatement(): void {
+ if (!this.canPrintStatement) return;
+ this.dialog.open(AccountStatementDialogComponent, {
+ data: { account: this.accountDetails },
+ width: '820px',
+ maxWidth: '95vw',
+ autoFocus: false
+ });
+ }
+
+ // ── Account Actions (clerk): close this account ──────────────────────────────
+
+ /** The Action button only makes sense once a real, ACTIVE savings account is on screen — a
+ * closure request needs an actual account id to submit against, and only a clerk is the maker
+ * in this flow (the cashier's side is the token disbursement, not the request). */
+ get canShowAccountActions(): boolean {
+ return (
+ this.isClerk &&
+ !this.reviewMode &&
+ !this.editMode &&
+ this.selectedRow?.acTyp === 'SD' &&
+ !!this.accountDetails?.id &&
+ this.accountDetails?.status?.value === 'Active'
+ );
+ }
+
+ openAccountActions(): void {
+ this.closeAccountChecked = this.pendingClosure;
+ this.closurePreviewError = '';
+ if (!this.pendingClosure) {
+ this.closurePreview = null;
+ }
+ this.showAccountActions = true;
+ }
+
+ closeAccountActions(): void {
+ this.showAccountActions = false;
+ }
+
+ /** Ticking "Close this account" posts every rupee of interest the member has earned to date onto
+ * the account for real — the same posting close() itself performs — and shows the resulting real
+ * balance. This is not an estimate: the interest is credited the moment the box is ticked, exactly
+ * as the user asked for ("jaise FD close/maturity ke time interest post hota tha"). Approval later
+ * finds this same posting already sitting at the matching period end and adds nothing more. */
+ onCloseAccountToggle(): void {
+ if (!this.closeAccountChecked) {
+ this.closurePreview = null;
+ this.closurePreviewError = '';
+ return;
+ }
+ const accountId = this.accountDetails?.id;
+ if (!accountId) return;
+
+ this.closurePreviewLoading = true;
+ this.closurePreviewError = '';
+ this.closureRequestService
+ .postClosureInterest(accountId)
+ .pipe(
+ catchError((err: any) => {
+ this.closurePreviewError =
+ err?.error?.message ??
+ err?.error?.errors?.[0]?.defaultUserMessage ??
+ 'Could not post interest to the account. Please try again.';
+ return of(null);
+ })
+ )
+ .subscribe((res: any) => {
+ this.closurePreviewLoading = false;
+ if (!res) return;
+ this.closurePreview = {
+ balance: Number(res.balance ?? 0),
+ interest: Number(res.accruedInterest ?? 0),
+ total: Number(res.total ?? 0),
+ upToDate: res.interestUpToDate !== false,
+ asOfDate: res.asOfDate ?? null
+ };
+ });
+ }
+
+ /**
+ * Applies the closure to the slip on screen — it does NOT submit anything. The calculated payout
+ * drops into the row's AMOUNT, the row is marked as a closure, and the clerk then fills TOK. NO.
+ * and DESCRIPTION on the slip itself and presses Save like any other entry. Save is what actually
+ * raises the request (see submitClosureRequest()).
+ */
+ applyCloseAccount(): void {
+ if (!this.closeAccountChecked) {
+ // Unticking is how the clerk backs out of a closure they had applied.
+ this.clearPendingClosure();
+ this.showAccountActions = false;
+ return;
+ }
+ if (!this.closurePreview) return;
+
+ // Fall back to the first row: the popup is opened from the footer, so a stray click can leave
+ // selectedRow unset even though a row is plainly on screen — and Save reads selectedRow too.
+ const row = this.selectedRow ?? this.rows[0] ?? null;
+ if (!row) {
+ this.showAlert('error', 'No Transaction Row', ['Add a transaction row before closing the account.']);
+ return;
+ }
+ this.selectedRow = row;
+ row.amount = this.closurePreview.total;
+ row.dbCr = 'Dr';
+ if (!row.description) {
+ row.description = 'Account closure — full balance withdrawal';
+ }
+ this.pendingClosure = true;
+ this.showAccountActions = false;
+ }
+
+ /** Drops closure mode and the amount it filled in, so the slip goes back to an ordinary entry. */
+ clearPendingClosure(): void {
+ this.pendingClosure = false;
+ this.closeAccountChecked = false;
+ this.closurePreview = null;
+ this.closurePreviewError = '';
+ const row = this.selectedRow;
+ if (row && row.description === 'Account closure — full balance withdrawal') {
+ row.description = '';
+ }
+ if (row) {
+ row.amount = null;
+ }
+ }
+
+ /**
+ * Raises the closure request from the slip's own fields. Reached from confirmSubmit() when the
+ * clerk presses Save with closure mode applied, instead of the ordinary withdrawal-request path.
+ */
+ private submitClosureRequest(row: TxnRow): void {
+ const accountId = this.accountDetails?.id;
+ if (!accountId) return;
+
+ this.saving = true;
+ this.closureRequestService
+ .submit({
+ savingsAccountId: accountId,
+ paymentTypeId: null as number | null,
+ paymentTypeName: 'Cash',
+ description: row.description || null,
+ tokNo: row.tokNo?.trim() || null
+ })
+ .pipe(
+ catchError((err: any) => {
+ const msg =
+ err?.error?.message ??
+ err?.error?.errors?.[0]?.defaultUserMessage ??
+ 'Could not submit the closure request. Please try again.';
+ this.showAlert('error', 'Closure Request Failed', [msg]);
+ this.saving = false;
+ return of(null);
+ })
+ )
+ .subscribe((res: any) => {
+ this.saving = false;
+ if (!res) return;
+
+ const preview = this.closurePreview;
+ // The server has now POSTED the interest and returns the account's real resulting balance.
+ // That, not the popup's estimate, is what the closure pays out — so report it, and say so
+ // outright if the two ever disagree rather than letting a paise drift pass unnoticed.
+ const posted = res.payableAmount != null ? Number(res.payableAmount) : null;
+ const rupees = (v: number) => `₹${v.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`;
+ const drifted = posted != null && preview != null && Math.abs(posted - preview.total) >= 0.005;
+
+ this.showAlert(
+ 'success',
+ 'Closure Request Submitted',
+ [
+ `Account ${this.accountDetails?.accountNo} has been sent to the Bank Manager for closure approval.`,
+ posted != null
+ ? `Interest has been posted to the account. Payout: ${rupees(posted)}.`
+ : preview
+ ? `Payout: ${rupees(preview.total)}.`
+ : '',
+ drifted && preview
+ ? `Note: the posted figure ${rupees(posted!)} differs from the ${rupees(preview.total)} shown a moment ago — the posted figure is the one that will be paid.`
+ : '',
+ `Token No: ${row.tokNo?.trim()} — the cashier will hand over the cash against this token once approved.`,
+ 'Cash is handed over rounded to whole rupees; the paise difference goes to the Round Off Account.'
+ ].filter(Boolean)
+ );
+
+ this.pendingClosure = false;
+ this.closeAccountChecked = false;
+ this.closurePreview = null;
+ this.onCancel();
+ });
+ }
+
// ── Same-day compliance indicators (copied from SavingsAccountTransactionsComponent) ──
private computeSameDayNeftRtgs(transactions: any[] | undefined): void {
@@ -2817,8 +5104,54 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
onSave(): void {
- // Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution
- if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Enter Bank Transfer')) {
+ // Demand drafts commit here like everything else on the worksheet. Checked first because a DD
+ // row shares its shape with the ordinary entries below — without this it would fall through and
+ // post as a plain transaction, with the draft never raised and the charges silently dropped.
+ const ddRows = this.rows.filter((r) => this.isDdRow(r));
+ if (ddRows.length > 0) {
+ this.saveDdRows(ddRows);
+ return;
+ }
+
+ // Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution. 'Fund Transfer'
+ // is the same flow under a second TYPE label — onTypChange() already defaults it identically to
+ // 'Enter Bank Transfer', so submission has to recognise both or a share redemption or bank
+ // transfer raised under this label silently falls through to the wrong branch below.
+ //
+ // But 'Fund Transfer' rows that include a GL leg are NOT this flow — they are the
+ // Disbursement/Collection double-entry shape (see the TYPE options comment), just under the
+ // friendlier 'Fund Transfer' label so a non-loan GL movement doesn't have to be called a
+ // "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this
+ // branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund
+ // Transfer' and at least one of them is GL.
+ // Share redemption settled to a GL account instead of the linked savings account: DR=Share
+ // account, CR=any GL account the clerk picks (e.g. a "DD Payable" holding account, ahead of a
+ // second, independent manual entry that later moves the value out to an actual draft). Checked
+ // ahead of both branches below — the Enter Bank Transfer/Fund Transfer branch would otherwise
+ // treat a Fund Transfer carrying a GL leg as the Disbursement/Collection shape (see
+ // fundTransferRowsHaveGl below), and a Share account can never be debited by that path at all —
+ // no such Fineract command exists — so it would either be silently mis-posted or rejected late.
+ if (
+ this.isClerk &&
+ this.rows.length === 2 &&
+ this.rows.every((r) => r.typ === 'Fund Transfer' || r.typ === 'Enter Bank Transfer') &&
+ this.rows.some((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR') &&
+ this.rows.some((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')
+ ) {
+ const shareRow = this.rows.find((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR')!;
+ const glRow = this.rows.find((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')!;
+ this.submitShareGlSettlementTransfer(shareRow, glRow);
+ return;
+ }
+
+ const fundTransferRowsHaveGl =
+ this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL');
+ if (
+ this.isClerk &&
+ this.rows.length >= 2 &&
+ this.rows.every((r) => r.typ === 'Enter Bank Transfer' || r.typ === 'Fund Transfer') &&
+ !fundTransferRowsHaveGl
+ ) {
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
@@ -2908,8 +5241,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
- // Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete)
- if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Disbursement')) {
+ // Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete).
+ // Fund Transfer rides the exact same double-entry flow as Disbursement (see the TYPE options
+ // comment above) — only the label differs.
+ if (
+ this.isClerk &&
+ this.rows.length >= 2 &&
+ this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
+ ) {
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
@@ -2951,7 +5290,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
- const title = isCollectionMode ? 'Confirm Collection Request' : 'Confirm Disbursement Request';
+ const isFundTransfer = this.rows[0]?.typ === 'Fund Transfer';
+ const title = isCollectionMode
+ ? 'Confirm Collection Request'
+ : isFundTransfer
+ ? 'Confirm Fund Transfer Request'
+ : 'Confirm Disbursement Request';
const confirmLines = isCollectionMode ? [
`DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''} to collect):`,
...drRows.map(
@@ -2976,20 +5320,37 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
const row = this.selectedRow;
- if (!row || !row.acNo.trim() || !this.accountDetails) return;
+ // Never fail silently here: a Save that does literally nothing is indistinguishable from a
+ // broken button, and this guard is the one path in onSave() that used to return without saying
+ // anything.
+ if (!row || !row.acNo.trim() || !this.accountDetails) {
+ this.showAlert('error', 'Nothing to Save', [
+ !row
+ ? 'No transaction row is selected. Click into a row first.'
+ : !row.acNo.trim()
+ ? 'Enter an account number on the row.'
+ : 'Account details are not loaded yet. Re-enter the account number and wait for it to load.'
+ ]);
+ return;
+ }
const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCash = !!selectedPtEarly?.isCashPayment;
const isDdCashGl = this.isDdCashGlRow(row);
// Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination
// DD + Cash + GL: amount is DD face value + charges + GST (rounded up), not the counted denomination
+ // A closure's amount is the calculated payout on the row (balance + interest to date), never the
+ // counted denomination: the clerk raising the request counts no cash — the cashier does that
+ // later, at hand-over. Keyed off pendingClosure rather than clerkMode because clerkMode is only
+ // set by the Clerk Dashboard wrapper, so a clerk reaching this screen by any other route would
+ // otherwise have the payout silently replaced by a zero denomination total.
const amount = isDdCashGl
? this.ddTotalPayableFor(row)
- : isCash && !this.clerkMode
+ : isCash && !this.clerkMode && !this.pendingClosure
? this.denominationTotal
: (row.amount ?? this.denominationTotal);
- if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl) {
+ if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl && !this.pendingClosure) {
this.showAlert('error', 'Denomination Required', [
'Cash transaction: Please enter denomination (notes/coins count) first.'
]);
@@ -3056,7 +5417,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
!this.reviewMode &&
!isDeposit &&
!this.activeWithdrawalRequest &&
- !this.activeDividendClaimRequest
+ !this.activeDividendClaimRequest &&
+ !this.activeClosureRequest
) {
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
'Withdrawals cannot be processed directly by the cashier.',
@@ -3066,9 +5428,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
- // Approved withdrawal / dividend cash claim: amount/denomination must exactly match
- if (!this.isClerk && (this.activeWithdrawalRequest || this.activeDividendClaimRequest) && !isDeposit) {
- const rawApprovedAmt = Number(this.activeWithdrawalRequest?.amount ?? this.activeDividendClaimRequest?.amount);
+ // Approved withdrawal / dividend cash claim / account closure: amount/denomination must match
+ if (
+ !this.isClerk &&
+ (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) &&
+ !isDeposit
+ ) {
+ const rawApprovedAmt = Number(
+ this.activeWithdrawalRequest?.amount ??
+ this.activeDividendClaimRequest?.amount ??
+ this.activeClosureRequest?.cashier_disbursed_amount
+ );
// A dividend cash claim's liability is stored with paise precision (a proportional profit
// share), but cash can only be handed over in whole-rupee notes/coins -- compare against
// the floored amount so an exact denomination match is actually achievable. Ordinary
@@ -3171,7 +5541,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Main Vault) already posted at claim-approval time — there is no real savings/current
// account behind this row, so skip balance/min-balance/KYC/dormant/facility checks entirely
// (none apply to a GL-only liability payout) and go straight to the confirm dialog.
- if (this.activeDividendClaimRequest) {
+ // An approved account closure is the same situation: close() already posted the interest and the
+ // real withdrawal at manager approval, and the account is CLOSED by the time a cashier scans the
+ // token — so accountDetails here is a stub with no balance and the balance/min-balance/facility
+ // checks below would reject the hand-over with "Withdrawable Amount: ₹0.00". Only the cash-drawer
+ // checks still matter, and they are the ones kept.
+ if (this.activeDividendClaimRequest || this.activeClosureRequest) {
if (!row.paymentTypeId) {
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
return;
@@ -3191,6 +5566,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
+ // Clerk raising a closure request: nothing moves yet, so the ordinary withdrawal gauntlet below
+ // does not apply and actively rejects a valid closure —
+ // · Balance: the popup already posted the accrued interest onto the account before filling this
+ // amount in (see onCloseAccountToggle -> postClosureInterest), so the account's own withdrawable
+ // balance already covers it -- this bypass exists for the checks below it, not this one.
+ // · Min balance: meaningless for an account that is being closed to zero.
+ // · Withdrawal facility: a member closing their account is not making an ordinary withdrawal.
+ // · Denomination: counted by the cashier at hand-over, not by the clerk here.
+ // Everything that genuinely matters (token number, account loaded, ACTIVE status) is already
+ // checked in onSave() before this point, and the backend re-validates on submit.
+ if (this.pendingClosure && this.isClerk && isWithdrawal) {
+ this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
+ return;
+ }
+
// GL transactions post as journal entries — skip account-level facility/balance checks
if (isGlType) {
if (!this.cashVaultGlId) {
@@ -3220,21 +5610,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
- const AML_GL = 200000;
- if (amount > AML_GL && isCash) {
- this.showConfirmAlert(
- 'warning',
- 'AML Alert — Large Cash Transaction',
- [
- `Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
- 'This transaction requires manager approval.',
- 'Have you obtained approval to proceed?'
- ],
- 'Yes, Proceed',
- () => this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly)
- );
- return;
- }
+ // No AML prompt on the GL path either — see the note on the savings/current path below.
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
return;
}
@@ -3270,7 +5646,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// and posted the real withdrawal (money already left the account). Re-running the balance/
// min-balance checks here would compare the post-deduction balance against the same
// already-approved amount again, which fails for almost any real withdrawal.
- const isTokenDisbursement = !this.isClerk && !!this.activeWithdrawalRequest;
+ const isTokenDisbursement = !this.isClerk && (!!this.activeWithdrawalRequest || !!this.activeClosureRequest);
if (!isTokenDisbursement) {
// 3. Balance check
@@ -3314,30 +5690,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
}
- // 6. AML large cash
- const AML_THRESHOLD = 200000;
- if (amount > AML_THRESHOLD && selectedPt?.isCashPayment) {
- this.showConfirmAlert(
- 'warning',
- 'AML Alert — Large Cash Transaction',
- [
- `Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
- 'This transaction requires manager approval.',
- 'Have you obtained approval to proceed?'
- ],
- 'Yes, Proceed',
- () => this.openConfirmDialog(row, amount, isDeposit, selectedPt)
- );
- return;
- }
-
+ // AML monitoring is deliberately invisible here. Every posted transaction is evaluated
+ // server-side against the AML rule catalog (AmlRuleEvaluationService) after it succeeds, and
+ // anything it flags surfaces only in the AML reports — alerts, STR cases and CTR returns.
+ // The cashier is never prompted or warned at the counter: tipping off the customer defeats
+ // the purpose of suspicious-transaction monitoring, and a hardcoded client-side threshold
+ // duplicated none of the real rule set anyway.
this.openConfirmDialog(row, amount, isDeposit, selectedPt);
}
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
// Token-based cashier disbursement: balance was already deducted at manager approval
const isDisbursement =
- !isDeposit && !this.isClerk && (!!this.activeWithdrawalRequest || !!this.activeDividendClaimRequest);
+ !isDeposit &&
+ !this.isClerk &&
+ (!!this.activeWithdrawalRequest || !!this.activeDividendClaimRequest || !!this.activeClosureRequest);
// Cashier deposit (savings/current/GL) goes via maker-checker: balance credits only after manager approval
const isDepositRequest =
isDeposit &&
@@ -3357,7 +5724,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'DEPOSIT REQUEST'
: isDeposit
? 'DEPOSIT'
- : 'WITHDRAWAL',
+ : this.pendingClosure && this.isClerk
+ ? 'ACCOUNT CLOSURE'
+ : 'WITHDRAWAL',
isDisbursement,
isDepositRequest,
accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo,
@@ -3368,7 +5737,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
paymentType: selectedPt?.name ?? 'Cash',
chequeNo: row.chequeNo || null,
symbol: this.currencySymbol,
- tokNo: this.activeWithdrawalRequest?.tok_no ?? this.activeDividendClaimRequest?.tok_no ?? null,
+ tokNo:
+ this.activeWithdrawalRequest?.tok_no ??
+ this.activeDividendClaimRequest?.tok_no ??
+ this.activeClosureRequest?.tok_no ??
+ null,
isDdCashGl: this.isDdCashGlRow(row),
ddFaceValue: row.amount ?? 0,
ddCharges: row.ddCharges ?? 0,
@@ -3381,6 +5754,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
confirmSubmit(): void {
this.showConfirmDialog = false;
const row = this.selectedRow!;
+ if (row.typ === 'DD') {
+ this.openIssueWalkinDd(row);
+ return;
+ }
const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal);
const isDeposit = row.dbCr === 'Cr';
const command = isDeposit ? 'deposit' : 'withdrawal';
@@ -3412,6 +5789,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
+ // Clerk applied "Close this account" from Action → raise a closure request rather than an
+ // ordinary withdrawal request. Must come first: a closure IS a full-balance withdrawal, so the
+ // plain clerk-withdrawal branch below would otherwise swallow it and the account would never
+ // actually close.
+ if (this.pendingClosure && this.isClerk && !isDeposit) {
+ this.submitClosureRequest(row);
+ return;
+ }
+
// Clerk mode + withdrawal → route through maker-checker (create PENDING request, no direct deduction)
if (this.clerkMode && !isDeposit) {
this.submitClerkWithdrawalRequest(row, amount);
@@ -3467,6 +5853,46 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
+ // Approved account closure token → the close command (interest, TDS and the real GL
+ // withdrawal) already ran at manager approval. Nothing new is posted here either — this only
+ // records the physical hand-over of the rounded cash figure, mirroring the two branches above.
+ if (!this.isClerk && this.activeClosureRequest && !isDeposit) {
+ this.saving = true;
+ const selectedPtDisb = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
+ const denomJson =
+ selectedPtDisb?.isCashPayment && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null;
+ const req = this.activeClosureRequest;
+ this.closureRequestService
+ .disburse(req.id, denomJson, amount)
+ .pipe(
+ catchError((err: any) => {
+ const msg =
+ err?.error?.message ??
+ err?.error?.errors?.[0]?.defaultUserMessage ??
+ 'Disbursement recording failed. Please try again.';
+ this.showAlert('error', 'Disbursement Failed', [msg]);
+ this.saving = false;
+ return of(null);
+ })
+ )
+ .subscribe((res: any) => {
+ this.saving = false;
+ if (res) {
+ const closureVoucher = res.voucherNo || req.voucher_no || '—';
+ this.showAlert('success', 'Account Closed & Cash Disbursed', [
+ `₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} handed over to the customer.`,
+ `Account ${req.account_no} is now CLOSED.`,
+ `Token No: ${req.tok_no} · Client: ${req.client_name}`,
+ `Voucher No: ${closureVoucher}`
+ ]);
+ this.activeClosureRequest = null;
+ this.clearDenominations();
+ this.loadOpeningPosition();
+ }
+ });
+ return;
+ }
+
// Hard block: cashier direct withdrawal never reaches here — caught in onSave()
// Extra safety net in case confirmSubmit() is ever called directly
if (
@@ -3474,7 +5900,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
!this.reviewMode &&
!isDeposit &&
!this.activeWithdrawalRequest &&
- !this.activeDividendClaimRequest
+ !this.activeDividendClaimRequest &&
+ !this.activeClosureRequest
) {
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
'Withdrawals must go through the maker-checker approval process.',
@@ -3720,7 +6147,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return {
accountNo: this.accountDetails.accountNo,
- accountType: 'SAVINGS',
+ accountType: this.creditEntryAccountType(row.acTyp),
savingsAccountId: this.accountDetails.id,
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
@@ -3829,7 +6256,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const isGl = row.acTyp === 'GL';
return {
accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo,
- accountType: isGl ? 'GL' : row.acTyp === 'CA' || row.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS',
+ accountType: this.creditEntryAccountType(row.acTyp),
savingsAccountId: isGl ? null : this.accountDetails.id,
// GL-specific fields — ignored by backend for non-GL types
glAccountId: isGl ? this.accountDetails.id : null,
@@ -3848,6 +6275,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null,
+ // What this GL credit is actually for — see TxnRow.entryTypeCvId. Sent for every GL deposit
+ // (the backend defaults to "General" when the counter didn't pick one), so Share Advance
+ // Collection and Sponsor DD issuance can tell whose voucher this really is before adopting it.
+ ...(isGl ? { entryTypeCvId: row.entryTypeCvId || null } : {}),
// DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face
// value/charges/GST back once this request is approved, instead of retyping them.
...(this.isDdCashGlRow(row)
@@ -3986,11 +6417,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
count: this.countFor(d),
subtotal: this.subtotalFor(d)
}));
+ // Filed against the Cash Vault GL, not the account the teller selected on screen: that
+ // selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and
+ // never the one whose physical notes actually changed hands. Tagging the breakdown to it
+ // instead of the vault is why this cash never reached the cashier/vault position.
this.http
.post('/cashier/gl-denomination', {
txnId: res.transactionId ?? res.resourceId,
- accountNo: this.accountDetails?.glCode ?? row.acNo,
- accountName: this.accountDetails?.name ?? '',
+ accountNo: this.cashVaultGlCode ?? row.acNo,
+ accountName: this.cashVaultGlName ?? 'Cash at Vault',
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
txnDate: txnDateIso,
paymentTypeName: selectedPt?.name ?? 'Cash',
diff --git a/src/app/cashier/vault-position/vault-position.component.html b/src/app/cashier/vault-position/vault-position.component.html
new file mode 100644
index 000000000..4d2337845
--- /dev/null
+++ b/src/app/cashier/vault-position/vault-position.component.html
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+ @if (tellers.length === 1) {
+
+ Vault
+
+
+ } @else {
+
+ Vault (optional)
+
+ All Vaults
+ @for (t of tellers; track t.id) {
+ {{ t.name }}
+ }
+
+
+ }
+
+
+ As of Date
+
+
+
+
+ bar_chart Load Position
+
+
+
+ @if (errorMsg) {
+
{{ errorMsg }}
+ }
+
+ @if (loading) {
+
Loading…
+ }
+
+ @for (pos of results; track pos.tellerId) {
+
+
+
+ @if (!pos.hasBreakdown) {
+
+ 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.
+
+ } @else {
+ @if (pos.anchorDate && pos.anchorDate !== pos.asOfDate) {
+
+ Carried forward from the opening breakdown recorded on {{ pos.anchorDate }}.
+
+ }
+
+ @if (pos.denominations.length === 0) {
+
No stock — everything opened for this vault has been handed out.
+ }
+
+ @if (notes(pos).length > 0) {
+
Notes
+
+
+
+ Denomination
+ Current Count
+ Amount
+
+
+
+ @for (d of notes(pos); track d.denominationId) {
+
+
+ ₹{{ d.denominationValue }}
+
+ {{ d.count }}
+ ₹{{ d.amount | number: '1.2-2' }}
+
+ }
+
+
+ }
+
+ @if (coins(pos).length > 0) {
+
Coins
+
+
+
+ Denomination
+ Current Count
+ Amount
+
+
+
+ @for (d of coins(pos); track d.denominationId) {
+
+
+ ₹{{ d.denominationValue }}
+
+ {{ d.count }}
+ ₹{{ d.amount | number: '1.2-2' }}
+
+ }
+
+
+ }
+ }
+
+ }
+
+ @if (!loading && results.length === 0 && !errorMsg) {
+
Select filters and click "Load Position".
+ }
+
diff --git a/src/app/cashier/vault-position/vault-position.component.ts b/src/app/cashier/vault-position/vault-position.component.ts
new file mode 100644
index 000000000..d91cbdb2d
--- /dev/null
+++ b/src/app/cashier/vault-position/vault-position.component.ts
@@ -0,0 +1,115 @@
+/**
+ * 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');
+ }
+}
diff --git a/src/app/cashier/voucher-report/voucher-report.component.html b/src/app/cashier/voucher-report/voucher-report.component.html
new file mode 100644
index 000000000..73ac94478
--- /dev/null
+++ b/src/app/cashier/voucher-report/voucher-report.component.html
@@ -0,0 +1,237 @@
+
+
+
+
+
+ @if (error) {
+
{{ error }}
+ }
+
+ @if (loading) {
+
Loading…
+ }
+
+ @if (report && !loading) {
+
+ {{ report.from | date: 'dd MMM yyyy' }}
+ @if (report.from !== report.to) {
+ — {{ report.to | date: 'dd MMM yyyy' }}
+ }
+ · {{ count }} {{ noun }}{{ count === 1 ? '' : 's' }} · ₹{{ totalAmount | number: '1.2-2' }}
+ @if (report.username) {
+ · {{ report.username }}
+ }
+
+ @if (!report.canSeeAllUsers) {
+ Your own vouchers
+ }
+
+ @if (cashOnly) {
+ Cash only
+ }
+
+
+
+ @if (unclassifiedCount > 0) {
+
+ {{ unclassifiedCount }} voucher{{ unclassifiedCount === 1 ? '' : 's' }} in this period
+ {{ unclassifiedCount === 1 ? 'carries' : 'carry' }} no CR or DR prefix, so
+ {{ unclassifiedCount === 1 ? 'it appears' : 'they appear' }} on neither the Received nor the Payment report.
+
+ }
+
+
+ @if (uncountedCount > 0) {
+
+ {{ uncountedCount }} of these {{ noun }}s {{ uncountedCount === 1 ? 'has' : 'have' }} no denomination breakdown
+ recorded, totalling ₹{{ uncountedTotal | number: '1.2-2' }}. Some registers pay out without counting notes
+ against the voucher, so these are still cash — ₹{{ countedTotal | number: '1.2-2' }} of the total has a
+ breakdown behind it.
+
+ }
+
+
+ @if (missingSidesCount > 0) {
+
+ {{ missingSidesCount }} of these {{ noun }}s {{ missingSidesCount === 1 ? 'has' : 'have' }} no debit or credit
+ recorded. The sides are attached when a request is approved, so vouchers raised before that step was added keep
+ their number and amount but show no accounts. Newly raised {{ noun }}s carry both sides.
+
+ }
+
+ @if (count === 0) {
+
No {{ noun }}s were raised in this period.
+ } @else {
+
+
+
By type
+
+
+
+ Type
+ Count
+ Amount
+
+
+
+ @for (row of byType; track row.type) {
+
+ {{ row.type }}
+ {{ row.count }}
+ {{ row.total | number: '1.2-2' }}
+
+ }
+
+
+
+
+
+ @if (report.canSeeAllUsers) {
+
+
By user
+
+
+
+ User
+ Count
+ Amount
+
+
+
+ @for (row of byUser; track row.username) {
+
+ {{ row.username }}
+ {{ row.count }}
+ {{ row.total | number: '1.2-2' }}
+
+ }
+
+
+
+ }
+
+
+
+
{{ isReceived ? 'Receipts' : 'Payments' }}
+
+
+
+ Voucher No
+ Type
+ Debit
+ Credit
+ Amount
+ User
+
+ @if (systemOnly) {
+ Ledger date
+ }
+ Time
+
+
+
+ @for (row of rows; track row.voucherNo) {
+
+
+ {{ row.voucherNo }}
+ @if (row.verificationStatus === 'EXCEPTION') {
+ error_outline
+ }
+
+ {{ row.voucherType || '—' }}
+
+ @if (!hasSides(row)) {
+ Sides not recorded
+ } @else {
+
+ {{ row.debitParty || '—' }}
+ @if (row.debitAccount) {
+ {{ row.debitAccount }}
+ }
+
+
+ {{ row.creditParty || '—' }}
+ @if (row.creditAccount) {
+ {{ row.creditAccount }}
+ }
+
+ @if (row.extraLines > 0) {
+ +{{ row.extraLines }} more on voucher
+ }
+
+ }
+ {{ row.amount | number: '1.2-2' }}
+ {{ row.username || '—' }}
+ @if (systemOnly) {
+
+ {{ row.ledgerDate ? (row.ledgerDate | date: 'dd MMM yyyy') : '—' }}
+ @if (row.postedAfterClose) {
+ after close
+ }
+
+ }
+ {{ row.createdAt | date: 'dd MMM, HH:mm' }}
+
+ }
+
+
+
+ }
+ }
+
diff --git a/src/app/cashier/voucher-report/voucher-report.component.scss b/src/app/cashier/voucher-report/voucher-report.component.scss
new file mode 100644
index 000000000..1fe7a3b97
--- /dev/null
+++ b/src/app/cashier/voucher-report/voucher-report.component.scss
@@ -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/.
+ */
+
+/* Page chrome, cards and tables come from cashier-shared.scss. Only the register itself is here. */
+
+.vr-period {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 14px;
+ font-size: 13px;
+ color: #374151;
+}
+
+.vr-scope-note {
+ padding: 2px 8px;
+ background: #eff6ff;
+ border: 1px solid #bfdbfe;
+ border-radius: 999px;
+ color: #1e40af;
+ font-size: 11px;
+}
+
+/* The two summaries sit side by side where there is room and stack where there is not, so a
+ manager reads the totals before the rows on any screen. */
+.vr-summaries {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 14px;
+ margin-bottom: 14px;
+}
+
+.vr-rows {
+ .vr-flag {
+ margin-left: 4px;
+ font-size: 15px;
+ width: 15px;
+ height: 15px;
+ color: #b91c1c;
+ vertical-align: text-bottom;
+ }
+
+ .vr-party {
+ color: #111827;
+ }
+
+ .vr-acct,
+ .vr-more {
+ margin-top: 2px;
+ font-size: 11px;
+ color: #6b7280;
+ }
+
+ // Spans the Debit and Credit cells for a voucher raised before its sides were recorded.
+ .vr-nosides {
+ font-size: 11px;
+ font-style: italic;
+ color: #9ca3af;
+ }
+}
+
+/* Printing a report should put the register on paper — the filters are how you reached it. */
+@media print {
+ .vr-no-print {
+ display: none !important;
+ }
+}
+
+/* The ledger date on the system register. Marked when it differs from the day the voucher was
+ written: that is the whole reason the column exists, and a date the reader has to compare against
+ the Time column by eye is a date they will misread. */
+.vr-ledgerdate {
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.vr-ledgerdate--prior {
+ color: #5b21b6;
+}
+
+.vr-priorflag {
+ display: block;
+ font-size: 9.5px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #7c3aed;
+}
diff --git a/src/app/cashier/voucher-report/voucher-report.component.ts b/src/app/cashier/voucher-report/voucher-report.component.ts
new file mode 100644
index 000000000..8444b2813
--- /dev/null
+++ b/src/app/cashier/voucher-report/voucher-report.component.ts
@@ -0,0 +1,351 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { MatIconModule } from '@angular/material/icon';
+import { catchError, of } from 'rxjs';
+import { CashierService } from '../cashier.service';
+
+/**
+ * Which side of the register a screen shows.
+ *
+ * 'CR' is the Received report (money in), 'DR' the Payment report (money out). Set from the route,
+ * so the two screens are the same component pointed at opposite sides.
+ */
+export type VoucherSide = 'CR' | 'DR' | 'ALL';
+
+/** One voucher as the register shows it: the number, the two sides, and who raised it. */
+interface VoucherRow {
+ voucherNo: string;
+ voucherType: string | null;
+ amount: number | null;
+ createdAt: string | null;
+ /** The business date the posting belongs to, which is not always the day it was written. */
+ ledgerDate?: string | null;
+ /** True when the voucher was written after the day it posts against had already closed. */
+ postedAfterClose?: boolean;
+ userId: number | null;
+ username: string | null;
+ branchName: string | null;
+ verificationStatus: string | null;
+ debitParty: string | null;
+ debitAccount: string | null;
+ creditParty: string | null;
+ creditAccount: string | null;
+ /** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
+ extraLines: number;
+ /**
+ * Whether a denomination breakdown was recorded against this voucher.
+ *
+ * True proves cash was counted. False does not prove the opposite: several registers pay real
+ * money out without recording a breakdown, so this marks a row as unconfirmed, not as non-cash.
+ */
+ denominationCounted: boolean;
+}
+
+interface VoucherReport {
+ from: string;
+ to: string;
+ username: string | null;
+ /** Set by the server from the caller's roles; a cashier is never offered the user filter. */
+ canSeeAllUsers: boolean;
+ /** Whether the server restricted the register to cash movements. */
+ cashOnly: boolean;
+ vouchers: VoucherRow[];
+ count: number;
+ totalAmount: number;
+}
+
+/**
+ * The vouchers raised over a day or a month, one row each.
+ *
+ * A cashier sees their own work; a clerk, head cashier or manager sees everyone's and can narrow to
+ * one user. That is decided by the server, not here — this screen only shows or hides the filter.
+ */
+@Component({
+ selector: 'mifosx-voucher-report',
+ templateUrl: './voucher-report.component.html',
+ styleUrls: [
+ '../cashier-shared.scss',
+ './voucher-report.component.scss'
+ ],
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatIconModule
+ ]
+})
+export class VoucherReportComponent implements OnInit {
+ private service = inject(CashierService);
+ private route = inject(ActivatedRoute);
+
+ /** Day is the working default; a month is what you ask for at a close. */
+ period: 'DAY' | 'MONTH' = 'DAY';
+ /** Anchor date — the day itself, or any day inside the month wanted. */
+ anchor = this.todayIso();
+ usernameFilter = '';
+ typeFilter = '';
+
+ /** Which side this screen is: Received (CR) or Payment (DR). Comes from the route's data. */
+ side: VoucherSide = 'CR';
+
+ /**
+ * Whether this screen is the cash register rather than the whole voucher register.
+ *
+ * A cashier handles notes and coins, so entries that cannot be cash — an interest posting, a
+ * clearing settlement, a cheque book fee — are left out, and everything that could have crossed
+ * the counter stays in. The filter errs towards keeping a row: a book entry on the register can
+ * be seen and recognised, whereas cash that never appears is invisible until it turns up as an
+ * unexplained difference at the end of the day.
+ */
+ cashOnly = false;
+
+ /**
+ * Whether this register is the counter's own — only the people who hold a drawer.
+ *
+ * These two screens reconcile the counter: what came in over it and what went out. Everyone
+ * else's work is their own to account for, and each person reads it on My Vouchers. Mixed in here
+ * it buried the cashier rows the registers exist for and left the totals matching no drawer.
+ */
+ cashiersOnly = false;
+
+ /**
+ * Whether this register is the system's own postings rather than anybody's work.
+ *
+ * Interest a nightly run credited, a charge taken from a balance, an escheat, a journal keyed
+ * straight to the ledger. These belong to no member of staff, so My Vouchers cannot show them —
+ * it is scoped to the reader — and the two counter registers deliberately exclude them. That left
+ * no screen answering "what did the system post last night".
+ */
+ systemOnly = false;
+
+ report: VoucherReport | null = null;
+ loading = false;
+ error: string | null = null;
+
+ ngOnInit(): void {
+ this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR';
+ this.cashOnly = this.route.snapshot.data['cashOnly'] === true;
+ this.cashiersOnly = this.route.snapshot.data['cashiersOnly'] === true;
+ this.systemOnly = this.route.snapshot.data['systemOnly'] === true;
+ this.load();
+ }
+
+ get isReceived(): boolean {
+ return this.side === 'CR';
+ }
+
+ get title(): string {
+ if (this.systemOnly) {
+ return 'System Vouchers';
+ }
+ return this.isReceived ? 'Received Report' : 'Payment Report';
+ }
+
+ get subtitle(): string {
+ if (this.systemOnly) {
+ return 'What the system posted on its own — interest, charges, escheats and journals. Nobody raised these, so they appear on no one’s own list.';
+ }
+ // Said plainly, because a register that silently omits the reader's own entries is worse than
+ // one that explains why: their work is on My Vouchers, and the line points them there.
+ if (this.cashiersOnly) {
+ return this.isReceived
+ ? 'Cash taken in at the counter by each cashier. Other staff read their own work on My Vouchers.'
+ : 'Cash paid out at the counter by each cashier. Other staff read their own work on My Vouchers.';
+ }
+ if (this.cashOnly) {
+ return this.isReceived
+ ? 'Cash taken in over a day or a month, and who took it — book entries left out.'
+ : 'Cash paid out over a day or a month, and who paid it — book entries left out.';
+ }
+ return this.isReceived
+ ? 'Every receipt voucher raised over a day or a month — money taken in, and who took it.'
+ : 'Every payment voucher raised over a day or a month — money paid out, and who paid it.';
+ }
+
+ /** The word for one row on this side, for counts and the empty state. */
+ get noun(): string {
+ if (this.systemOnly) {
+ return 'posting';
+ }
+ return this.isReceived ? 'receipt' : 'payment';
+ }
+
+ load(): void {
+ this.loading = true;
+ this.error = null;
+
+ const { from, to } = this.range();
+ this.service
+ .voucherReport(
+ from,
+ to,
+ this.usernameFilter.trim() || undefined,
+ this.typeFilter.trim() || undefined,
+ this.cashOnly,
+ this.cashiersOnly,
+ this.systemOnly
+ )
+ .pipe(
+ catchError((err: any) => {
+ this.error =
+ err?.error?.errors?.[0]?.defaultUserMessage ??
+ err?.error?.developerMessage ??
+ 'The voucher report could not be loaded.';
+ return of(null);
+ })
+ )
+ .subscribe((res: VoucherReport | null) => {
+ this.loading = false;
+ this.report = res;
+ });
+ }
+
+ /** The period the anchor date names: that day, or the whole calendar month around it. */
+ private range(): { from: string; to: string } {
+ if (this.period === 'DAY') {
+ return { from: this.anchor, to: this.anchor };
+ }
+ const [
+ year,
+ month
+ ] = this.anchor.split('-').map(Number);
+ // Day 0 of the next month is the last day of this one, which is how a 28/30/31-day month is
+ // ended correctly without a table of month lengths.
+ const lastDay = new Date(year, month, 0).getDate();
+ const mm = String(month).padStart(2, '0');
+ return { from: `${year}-${mm}-01`, to: `${year}-${mm}-${String(lastDay).padStart(2, '0')}` };
+ }
+
+ private todayIso(): string {
+ const now = new Date();
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
+ }
+
+ /**
+ * The rows belonging to this screen's side.
+ *
+ * The side is read off the first segment of the voucher number — `CR/001/2608/0003` — which the
+ * server stamps when it raises the voucher: a deposit credits the member and gets CR, everything
+ * that takes money out gets DR. That prefix is the authoritative record of the side, so it is
+ * matched directly rather than re-deriving the side from the voucher type here — a new type added
+ * on the server then lands on the correct report with no change needed on this screen.
+ */
+ get rows(): VoucherRow[] {
+ const all = this.report?.vouchers ?? [];
+ return this.side === 'ALL' ? all : all.filter((row) => this.sideOf(row) === this.side);
+ }
+
+ /**
+ * Whether a voucher has its two sides recorded against it.
+ *
+ * The registers raise a voucher when the request is made and only attach its sides once it is
+ * approved and posted, so a voucher raised before that step existed carries a number but no
+ * lines. Those rows are labelled rather than shown as an empty dash, which reads like the money
+ * went nowhere.
+ */
+ hasSides(row: VoucherRow): boolean {
+ return !!(row.debitParty || row.creditParty);
+ }
+
+ /** How many rows on this side are missing their debit/credit lines. */
+ get missingSidesCount(): number {
+ return this.rows.filter((row) => !this.hasSides(row)).length;
+ }
+
+ /** A voucher's side, from its number's prefix. Null when the number is missing or unrecognised. */
+ private sideOf(row: VoucherRow): VoucherSide | null {
+ const no = (row.voucherNo ?? '').toUpperCase();
+ if (no.startsWith('CR/')) {
+ return 'CR';
+ }
+ if (no.startsWith('DR/')) {
+ return 'DR';
+ }
+ return null;
+ }
+
+ /** How many rows on this side — not the server's count, which spans both. */
+ get count(): number {
+ return this.rows.length;
+ }
+
+ /** Total for this side only. */
+ get totalAmount(): number {
+ return this.rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
+ }
+
+ /**
+ * The part of this side's total whose notes and coins were actually counted.
+ *
+ * Always at most `totalAmount`: a voucher can be real cash and still be missing its breakdown,
+ * because the register that raised it records none. So this is the confirmed part of the cash,
+ * not the whole of it, and the gap is reported rather than treated as book entries.
+ */
+ get countedTotal(): number {
+ return this.rows.filter((row) => row.denominationCounted).reduce((sum, row) => sum + (row.amount ?? 0), 0);
+ }
+
+ /** Rows carrying no denomination breakdown — unconfirmed against the drawer, not non-cash. */
+ get uncountedCount(): number {
+ return this.rows.filter((row) => !row.denominationCounted).length;
+ }
+
+ /** How much of this side's total has no note-and-coin breakdown behind it. */
+ get uncountedTotal(): number {
+ return this.totalAmount - this.countedTotal;
+ }
+
+ /**
+ * Vouchers whose number carries neither prefix, so they appear on neither report.
+ *
+ * Counted and shown rather than silently dropped: a voucher that vanishes from both sides of the
+ * register is exactly the kind of thing a cashier must be told about, not left to discover when
+ * the two reports do not add up to the day.
+ */
+ get unclassifiedCount(): number {
+ const all = this.report?.vouchers ?? [];
+ return all.filter((row) => this.sideOf(row) === null).length;
+ }
+
+ /** Totals per user, for the summary a manager reads before the rows. */
+ get byUser(): { username: string; count: number; total: number }[] {
+ const rows = this.rows;
+ const tally = new Map();
+ for (const row of rows) {
+ const key = row.username ?? '—';
+ const entry = tally.get(key) ?? { username: key, count: 0, total: 0 };
+ entry.count += 1;
+ entry.total += row.amount ?? 0;
+ tally.set(key, entry);
+ }
+ return [...tally.values()].sort((a, b) => b.total - a.total);
+ }
+
+ /** Totals per voucher type — deposits against withdrawals against journals. */
+ get byType(): { type: string; count: number; total: number }[] {
+ const rows = this.rows;
+ const tally = new Map();
+ 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();
+ }
+}
diff --git a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html
index cf657e658..57a1fd6b3 100644
--- a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html
+++ b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html
@@ -9,7 +9,12 @@
diff --git a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts
index 2a95702db..684e01f2d 100644
--- a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts
+++ b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts
@@ -20,6 +20,7 @@ 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';
@@ -85,6 +86,11 @@ 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();
diff --git a/src/app/chequebook/cheque-book-detail/cheque-issuance-slip-pdf.ts b/src/app/chequebook/cheque-book-detail/cheque-issuance-slip-pdf.ts
new file mode 100644
index 000000000..ba1b5fc67
--- /dev/null
+++ b/src/app/chequebook/cheque-book-detail/cheque-issuance-slip-pdf.ts
@@ -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/.
+ */
+
+/**
+ * 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;
+}
diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.html b/src/app/clerk/disbursement-review/disbursement-review.component.html
index b62e16e3a..7ad308fd8 100644
--- a/src/app/clerk/disbursement-review/disbursement-review.component.html
+++ b/src/app/clerk/disbursement-review/disbursement-review.component.html
@@ -6,16 +6,21 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
+
+
-
-
-
-
- {{ isBankTransferMode ? 'Bank Transfer Review' : isCollectionMode ? 'Collection Review' : 'Disbursement Review' }}
-
- ← Back to Inbox
+
+ {{ isDialog ? '✕ Close' : '← Back to Inbox' }}
@@ -159,7 +164,7 @@
{{ entry.accountNo }}
{{ entry.accountName }}
- {{ entry.accountType }}
+ {{ accountTypeLabel(entry.accountType) }}
₹{{ entry.amount | number: '1.2-2' }}
@@ -197,7 +202,7 @@
{{ entry.accountNo }}
{{ entry.accountName }}
- {{ entry.accountType }}
+ {{ accountTypeLabel(entry.accountType) }}
₹{{ entry.amount | number: '1.2-2' }}
@@ -233,7 +238,7 @@
{{ entry.accountNo }}
{{ entry.accountName }}
- {{ entry.accountType }}
+ {{ accountTypeLabel(entry.accountType) }}
₹{{ entry.amount | number: '1.2-2' }}
diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.scss b/src/app/clerk/disbursement-review/disbursement-review.component.scss
index c3a42906e..a95e0293a 100644
--- a/src/app/clerk/disbursement-review/disbursement-review.component.scss
+++ b/src/app/clerk/disbursement-review/disbursement-review.component.scss
@@ -36,6 +36,16 @@ $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 ────────────────────────────────────────────────────────────────
@@ -48,29 +58,6 @@ $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;
@@ -88,6 +75,12 @@ $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;
diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.ts b/src/app/clerk/disbursement-review/disbursement-review.component.ts
index b37ba7f94..82d96762f 100644
--- a/src/app/clerk/disbursement-review/disbursement-review.component.ts
+++ b/src/app/clerk/disbursement-review/disbursement-review.component.ts
@@ -12,6 +12,7 @@ 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';
@@ -33,7 +34,8 @@ interface CreditEntry {
CommonModule,
FormsModule,
MatSnackBarModule,
- MatIconModule
+ MatIconModule,
+ MatDialogModule
],
templateUrl: './disbursement-review.component.html',
styleUrl: './disbursement-review.component.scss'
@@ -43,6 +45,11 @@ 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, { optional: true });
+ private dialogData = inject<{ requestId: number } | null>(MAT_DIALOG_DATA, { optional: true });
requestId: number | null = null;
request: any = null;
@@ -53,9 +60,17 @@ export class DisbursementReviewComponent implements OnInit {
showRejectForm = false;
rejectionReason = '';
+ get isDialog(): boolean {
+ return !!this.dialogRef;
+ }
+
ngOnInit(): void {
- const id = this.route.snapshot.params['id'];
- this.requestId = id != null ? Number(id) : null;
+ if (this.dialogData?.requestId) {
+ this.requestId = this.dialogData.requestId;
+ } else {
+ const id = this.route.snapshot.params['id'];
+ this.requestId = id != null ? Number(id) : null;
+ }
if (this.requestId) this.loadRequest();
}
@@ -93,9 +108,11 @@ export class DisbursementReviewComponent implements OnInit {
} else {
const msg = this.isBankTransferMode
? 'Bank transfer approved successfully.'
- : 'Disbursement approved successfully.';
+ : this.isFundTransferMode
+ ? 'Fund transfer approved successfully.'
+ : 'Disbursement approved successfully.';
this.snackBar.open(msg, 'Close', { duration: 3000 });
- this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
+ this.finish();
}
});
}
@@ -112,15 +129,31 @@ export class DisbursementReviewComponent implements OnInit {
this.snackBar.open(res.error, 'Close', { duration: 5000 });
} else {
this.snackBar.open('Request rejected.', 'Close', { duration: 3000 });
- this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
+ this.finish();
}
});
}
+ /** "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');
@@ -143,6 +176,24 @@ 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 = {
+ 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 = {
PENDING: 'dri-status--pending',
@@ -171,6 +222,10 @@ export class DisbursementReviewComponent implements OnInit {
}
backToInbox(): void {
- this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
+ if (this.dialogRef) {
+ this.dialogRef.close();
+ } else {
+ this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']);
+ }
}
}
diff --git a/src/app/clerk/dividend-claim-request.service.ts b/src/app/clerk/dividend-claim-request.service.ts
index 2f87ead92..9edfb2740 100644
--- a/src/app/clerk/dividend-claim-request.service.ts
+++ b/src/app/clerk/dividend-claim-request.service.ts
@@ -20,37 +20,36 @@ 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,
- breakdown: {
- loanId?: number;
- loanApplyAmount?: number;
- savingsAccountId?: number;
- savingsApplyAmount?: number;
- cashApplyAmount?: number;
- tokNo?: string;
+ 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;
}
): Observable {
- 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);
+ 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
+ });
}
// Silenced: onTokNoBlur() in Transaction Entry probes this only after the withdrawal-request
@@ -68,10 +67,6 @@ export class DividendClaimRequestService {
});
}
- getOverdueLoans(clientId: number): Observable {
- return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`);
- }
-
list(status = 'PENDING'): Observable {
return this.http.get('/clerk/dividend-claim-requests', { params: { status } });
}
diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html
index 9871b11eb..71c82c206 100644
--- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html
+++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html
@@ -46,14 +46,69 @@
}
-
+
+
+
+ Declarations
+ {{ declarations.length }}
+
+
+ Overdue EMI
+ 0">{{ rows.length }}
+
+
+ Lock History
+ {{ locks.length }}
+
+
+
+
Dividend Declarations — Every Dividend Ever Declared, Tracked to Completion
-
-
+
Dividend Dashboard — Locked Dividends for Members with Overdue EMI
-
-
+
All Dividend Locks — Full 3-Year Lock History
+
+
+ @if (lockYears.length > 0) {
+
+
Financial year
+
+
+ All years
+
+ @for (year of lockYears; track year) {
+
+ FY {{ year }}
+
+ }
+
+
+ {{ visibleLocks.length }} lock{{ visibleLocks.length === 1 ? '' : 's' }} ·
+ {{ visibleLocksTotal | number: '1.2-2' }}
+
+
+ }
Client
- Savings Account
+
+ Dividend
Amount
Locked Date
Release Due
Status
Applied Loan
-
+ Demand Draft
@@ -218,16 +349,34 @@
Loading…
- } @else if (locksLoaded && locks.length === 0) {
+ } @else if (locksLoaded && visibleLocks.length === 0) {
- No dividend locks match this filter.
+
+ @if (lockSearch) {
+ No dividend lock matches “{{ lockSearch }}”.
+ } @else {
+ No dividend locks match this filter.
+ }
+
} @else {
- @for (l of locks; track l.lockId) {
+ @for (l of visibleLocks; track l.lockId) {
- {{ l.clientName }}
- {{ l.savingsAccountNo }}
+ {{ l.clientName }}
+ @if (l.shareAccountNo) {
+ {{ l.shareAccountNo }}
+ }
+
+
+ @if (l.financialYear) {
+ FY {{ l.financialYear }}
+
+ {{ l.productName }} · {{ l.periodStartDate }} → {{ l.periodEndDate }}
+
+ } @else {
+ —
+ }
{{ l.amount | number: '1.2-2' }}
{{ l.lockedDate }}
@@ -236,179 +385,21 @@
{{ l.status | titlecase }}
{{ l.appliedLoanAccountNo || '—' }}
+
- @if (l.status === 'UNCLAIMED') {
-
- {{ expandedClaimLockId === l.lockId ? 'Cancel' : 'Claim' }}
+ @if (l.claimDdNumber && l.claimStatus === 'APPROVED') {
+ {{ l.claimDdNumber }}
+ } @else if (l.claimStatus === 'PENDING') {
+ DD {{ l.claimDdNumber }} — awaiting approval
+ } @else if (l.status === 'UNCLAIMED') {
+
+ Claim by DD
}
- @if (expandedClaimLockId === l.lockId) {
-
-
-
-
- Unclaimed amount: {{ l.amount | number: '1.2-2' }} — split it below (loan, savings, cash)
-
- @if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) {
-
- Apply to Overdue Loan
-
- @for (loan of claimOverdueLoans[l.lockId]; track loan.loanId) {
-
- {{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
-
- }
-
-
-
- Loan Amount
-
-
- } @else {
-
No overdue loan for this client.
- }
-
- Credit to Savings
-
-
- @if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) {
-
- Savings Account ID (no linked account — enter one to credit)
-
-
- }
-
- Cash
-
-
- @if (claimCashAmount[l.lockId] > 0) {
-
- Token No. (for cashier hand-over)
-
-
- }
-
- Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }}
-
- @if (claimError) {
-
{{ claimError }}
- }
-
- {{ claimSubmitting ? 'Submitting…' : 'Submit Claim Request' }}
-
-
-
-
- }
- }
- }
-
-
-
-
-
-
Dividend Claim Requests — Pending Review
-
-
-
- Refresh
-
-
-
-
-
-
- Client
- Savings Account
- To Loan
- To Savings
- Cash
- Unclaimed Balance
- Client Loan Status
-
-
-
-
- @if (claimRequestsLoading) {
-
- Loading…
-
- } @else if (claimRequestsLoaded && claimRequests.length === 0) {
-
- No dividend claim requests pending review.
-
- } @else {
- @for (req of claimRequests; track req.id) {
-
- {{ req.client_name }}
-
- {{ req.savings_account_no }}
-
-
- {{
- req.loan_apply_amount > 0
- ? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')'
- : '—'
- }}
-
- {{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }}
-
- {{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}
- @if (req.cash_apply_amount > 0 && req.tok_no) {
- Tok: {{ req.tok_no }}
- }
-
- {{ req.locked_amount | number: '1.2-2' }}
-
- @if (req.has_overdue_loan) {
- Has Overdue EMI
- } @else {
- No Overdue Loan
- }
-
-
-
- {{ claimActioningId === req.id ? 'Approving…' : 'Approve' }}
-
-
- {{ expandedRejectClaimId === req.id ? 'Cancel' : 'Reject' }}
-
- @if (claimActionError[req.id]) {
- {{ claimActionError[req.id] }}
- }
-
-
- @if (expandedRejectClaimId === req.id) {
-
-
-
-
- Rejection Reason
-
-
-
- {{ claimActioningId === req.id ? 'Rejecting…' : 'Confirm Reject' }}
-
-
-
-
- }
}
}
diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss
index e2a2e511a..a452f8856 100644
--- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss
+++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss
@@ -169,3 +169,236 @@
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;
+}
diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts
index 5ddb98147..1c5ea3642 100644
--- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts
+++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts
@@ -9,17 +9,21 @@
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, 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).
+ * 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.
*/
@Component({
selector: 'mifosx-dividend-dashboard',
@@ -37,7 +41,20 @@ import { DividendClaimRequestService } from '../dividend-claim-request.service';
})
export class DividendDashboardComponent implements OnInit {
private service = inject(DividendDashboardService);
- private claimRequestService = inject(DividendClaimRequestService);
+ 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';
rows: any[] = [];
loading = false;
@@ -56,181 +73,72 @@ 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';
- expandedClaimLockId: number | null = null;
- claimOverdueLoans: Record = {};
- claimLoanId: Record = {};
- claimLoanAmount: Record = {};
- claimSavingsAmount: Record = {};
- claimSavingsAccountOverride: Record = {};
- claimCashAmount: Record = {};
- claimTokNo: Record = {};
- claimSubmitting = false;
- claimError: string | null = null;
-
- claimRequests: any[] = [];
- claimRequestsLoading = false;
- claimRequestsLoaded = false;
- claimActioningId: number | null = null;
- claimActionError: Record = {};
- expandedRejectClaimId: number | null = null;
- rejectReason: Record = {};
+ /**
+ * 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';
ngOnInit(): void {
this.loadSummary();
this.loadDeclarations();
this.load();
this.loadLocks();
- this.loadClaimRequests();
}
- 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));
+ /**
+ * 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 ?? ''
}
- });
- }
-
- 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
@@ -254,6 +162,33 @@ 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';
}
@@ -269,16 +204,54 @@ 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':
diff --git a/src/app/clerk/my-closure-requests/my-closure-requests.component.html b/src/app/clerk/my-closure-requests/my-closure-requests.component.html
new file mode 100644
index 000000000..e24e2b230
--- /dev/null
+++ b/src/app/clerk/my-closure-requests/my-closure-requests.component.html
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+ Status
+
+ @for (opt of statusOptions; track opt.value) {
+ {{ opt.label }}
+ }
+
+
+
+
+
+
+ Account No
+ Client
+ Payout Mode
+ Token No
+ Closing Amount
+ Submitted At
+ Status
+
+
+
+ @if (loading) {
+
+ Loading…
+
+ } @else if (loaded && requests.length === 0) {
+
+ No closure requests found.
+
+ } @else {
+ @for (r of requests; track r.id) {
+
+
+ {{ r.account_no }}
+
+ {{ r.client_name }}
+ {{ r.payment_type_name || 'Cash' }}
+ {{ r.tok_no || '—' }}
+
+ @if (r.closing_amount != null) {
+ ₹{{ r.closing_amount | number: '1.2-2' }}
+ } @else {
+ —
+ }
+
+ {{ formatDateTime(r.submitted_at) }}
+
+ {{ statusLabel(r.status) }}
+
+
+ }
+ }
+
+
+
+
diff --git a/src/app/clerk/my-closure-requests/my-closure-requests.component.ts b/src/app/clerk/my-closure-requests/my-closure-requests.component.ts
new file mode 100644
index 000000000..6f7693fe1
--- /dev/null
+++ b/src/app/clerk/my-closure-requests/my-closure-requests.component.ts
@@ -0,0 +1,112 @@
+/**
+ * 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 = {
+ 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 = {
+ 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);
+ }
+ }
+}
diff --git a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html
index a5dc03299..634cdd3ba 100644
--- a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html
+++ b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html
@@ -12,13 +12,15 @@
add
- New Disbursement
+ New Request
@@ -99,7 +101,7 @@
You have not submitted any disbursement requests yet.
add
- New Disbursement
+ New Request
} @else {
@@ -170,7 +172,9 @@
{{ ce.accountNo || '—' }}
{{ ce.accountName || '—' }}
- {{ ce.accountType || '—' }}
+ {{
+ ce.accountType ? accountTypeLabel(ce.accountType) : '—'
+ }}
{{ ce.amount | number: '1.2-2' }}
diff --git a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts
index c75d6cac9..9f57f9280 100644
--- a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts
+++ b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts
@@ -177,7 +177,29 @@ export class MyDisbursementRequestsComponent implements OnInit {
getCreditEntries(req: any): any[] {
try {
const arr = typeof req.credit_entries === 'string' ? JSON.parse(req.credit_entries) : req.credit_entries;
- return Array.isArray(arr) ? arr.map((e: any) => ({ ...e, amount: parseFloat(e.amount) || 0 })) : [];
+ const entries: any[] = Array.isArray(arr)
+ ? arr.map((e: any) => ({ ...e, amount: parseFloat(e.amount) || 0 }))
+ : [];
+ // Collection embeds its GL leg inside credit_entries (it can collect from several
+ // accounts into the one GL, so the GL has to be one row among them). Disbursement and
+ // Fund Transfer instead name their GL as the request's own top-level source — dr_amount
+ // is its amount — because there is exactly one, so it never needed a row of its own. That
+ // meant the DR/CR breakdown here only ever showed the credit side for those two types,
+ // looking like half the slip was missing. Adding it back as the first row here, rather
+ // than in credit_entries itself, keeps the stored JSON exactly what approve() posts from.
+ if ((req.request_type === 'DISBURSEMENT' || req.request_type === 'FUND_TRANSFER') && req.gl_account_no) {
+ return [
+ {
+ dbCr: 'Dr',
+ accountNo: req.gl_account_no,
+ accountName: req.gl_account_name,
+ accountType: 'GL',
+ amount: parseFloat(req.dr_amount) || 0
+ },
+ ...entries
+ ];
+ }
+ return entries;
} catch {
return [];
}
@@ -187,6 +209,23 @@ 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 = {
+ 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 = {
PENDING: 'mdr-status--pending',
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
index f5ad0590e..dbe9d8b20 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
@@ -263,7 +263,6 @@
{{ 'labels.inputs.Last Name' | translate }}
person
- <<<<<<< Updated upstream
- =======
-
- >>>>>>> Stashed changes
@if (createClientForm.controls.lastname.hasError('required')) {
Last name is required
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
@@ -398,7 +394,7 @@
}
-
+
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
@@ -973,6 +969,11 @@
createClientForm.get('alternateMobileNo')?.touched
) {
Enter valid 10-digit number (starts with 6-9)
+ } @else if (
+ createClientForm.get('alternateMobileNo')?.hasError('sameAsMobile') &&
+ createClientForm.get('alternateMobileNo')?.touched
+ ) {
+ Alternate number can't be the same as Mobile No
}
@@ -983,7 +984,11 @@
color="primary"
type="button"
class="verify-btn"
- [disabled]="readOnly || createClientForm.get('alternateMobileNo')?.value?.length !== 10"
+ [disabled]="
+ readOnly ||
+ createClientForm.get('alternateMobileNo')?.value?.length !== 10 ||
+ createClientForm.get('alternateMobileNo')?.hasError('sameAsMobile')
+ "
(click)="verifyAlternateDIP()"
>
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
index 8ea5bf5e5..ed53470c4 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
@@ -25,7 +25,10 @@ import {
UntypedFormGroup,
Validators,
UntypedFormControl,
- ReactiveFormsModule
+ ReactiveFormsModule,
+ ValidatorFn,
+ AbstractControl,
+ ValidationErrors
} from '@angular/forms';
import { Subject, Subscription, merge } from 'rxjs';
import { filter, switchMap, takeUntil } from 'rxjs/operators';
@@ -56,6 +59,12 @@ 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
@@ -299,10 +308,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
];
/** Calculated age detail string (Y/M/D) */
calculatedAgeDetail: string | null = null;
- /** Whether client is a minor (age < 18) */
+ /** Whether client is a minor — threshold from the shared age util, derived from Date of Birth */
isMinor = false;
- /** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */
+ /** Whether client is a senior citizen — threshold from the shared age util, derived from DOB */
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 };
@@ -913,6 +925,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
}
+ /** Alternate Mobile No must differ from Mobile No — the same number saved in both fields is
+ * meaningless (there's no second contact route) and the DIP checks would just repeat. */
+ private notSameAsMobileNoValidator(): ValidatorFn {
+ return (control: AbstractControl): ValidationErrors | null => {
+ const value = control.value;
+ const mobileNo = control.parent?.get('mobileNo')?.value;
+ return value && mobileNo && value === mobileNo ? { sameAsMobile: true } : null;
+ };
+ }
+
/**
* Creates the client form.
*/
@@ -952,7 +974,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
'',
[
Validators.pattern(/^[6-9]\d{9}$/),
- Validators.maxLength(10)
+ Validators.maxLength(10),
+ this.notSameAsMobileNoValidator()
]
],
emailAddress: [
@@ -1237,8 +1260,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 = years < 18;
- this.isSeniorCitizen = years >= 60;
+ this.isMinor = isMinorAge(years);
+ this.isSeniorCitizen = isSeniorCitizenAge(years);
// Add/remove guardian form group based on minor status
if (this.isMinor && !wasMinor) {
this.createClientForm.addControl(
@@ -1325,6 +1348,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// invalid) and its own reset never had a chance to run.
this.mobileOtpVerified = false;
this.mobileNewOtpId = null;
+ // Alternate Mobile No's "not same as Mobile No" validator reads mobileNo's current
+ // value, so re-run it here too — otherwise fixing mobileNo away from a clash leaves
+ // the alternate field's error stuck until the user also edits that field.
+ this.createClientForm.get('alternateMobileNo')?.updateValueAndValidity({ emitEvent: false });
});
this.createClientForm
@@ -2423,6 +2450,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
{ upTo: null, category: 3, label: 'above ₹10 crore' }
];
+ /**
+ * A numeric rule bound counts as configured only when it is present and above zero.
+ *
+ * Migration 0448 replaced every unset income/turnover NULL with 0.0, so "absent" now arrives as
+ * either. Every comparison in calculatedRisk already pairs its check with `> 0` for that reason;
+ * this keeps the criteria-less test reading the columns the same way.
+ */
+ private static isConfigured(bound: number | string | null | undefined): boolean {
+ return bound != null && parseFloat(String(bound)) > 0;
+ }
+
/** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */
riskCategoryLabel(category: number): string {
if (category === 3) {
@@ -2509,17 +2547,32 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Evaluate rules from DB (ordered by orderPosition ASC)
if (this.riskRules && this.riskRules.length > 0) {
for (const rule of this.riskRules) {
- // Skip transaction/AML rules that have no KYC criteria
+ // Only KYC profile rules categorise a customer at onboarding. The same table also holds
+ // ~221 AML transaction-monitoring scenarios ("Large cash transaction", "Additional RFIs
+ // Rule - 91", ...) which describe behaviour and may only fire against posted transactions,
+ // as AML alerts — RBI Master Direction on KYC separates risk categorisation (Section 12)
+ // from transaction monitoring (Section 13). ruleKind, set on the row, is what tells them
+ // apart; it replaced an inference from "this rule has no criteria set", which migration
+ // 0448 silently broke by writing 0.0 over those NULLs. That let rule 91 match a salaried
+ // client on an income band it was never meant to carry and pre-flag them for AML.
+ if (rule.ruleKind === 'AML') {
+ continue;
+ }
+
+ // Backstop for a rule row predating rule_kind: a rule naming no occupation, no legal form
+ // and no income or turnover band constrains nothing, so it would match every customer.
+ // Treat 0 as unset — that is how the comparisons below read it, and what 0448 left behind.
if (
+ rule.ruleKind == null &&
rule.legalFormId == null &&
rule.occupationCategoryCvId == null &&
rule.occupationCvId == null &&
- rule.minMonthlyIncome == null &&
- rule.maxMonthlyIncome == null &&
- rule.minAnnualIncome == null &&
- rule.maxAnnualIncome == null &&
- rule.minAnnualTurnover == null &&
- rule.maxAnnualTurnover == null
+ !ClientGeneralStepComponent.isConfigured(rule.minMonthlyIncome) &&
+ !ClientGeneralStepComponent.isConfigured(rule.maxMonthlyIncome) &&
+ !ClientGeneralStepComponent.isConfigured(rule.minAnnualIncome) &&
+ !ClientGeneralStepComponent.isConfigured(rule.maxAnnualIncome) &&
+ !ClientGeneralStepComponent.isConfigured(rule.minAnnualTurnover) &&
+ !ClientGeneralStepComponent.isConfigured(rule.maxAnnualTurnover)
) {
continue;
}
@@ -2567,7 +2620,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
return {
category: rule.riskCategory,
reason: rule.reason,
- amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'),
+ // A profile rule sets the risk category; it does not raise an AML flag. High Risk means
+ // enhanced due diligence and a 2-yearly KYC refresh (RBI MD Sections 12 and 38), not
+ // suspicion — an alert belongs to observed transaction behaviour (Section 13), so a
+ // customer who has transacted nothing yet stays CLEAR. A bank that genuinely wants a
+ // rule to flag on match can still say so explicitly via the rule's own amlCheckStatus.
+ amlCheckStatus: rule.amlCheckStatus || 'CLEAR',
bannedEntityMatch: rule.bannedEntityMatch || 'NO',
willfulDefaulter: rule.willfulDefaulter || 'NO',
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
@@ -2590,28 +2648,6 @@ 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 });
- }
- }
}
/**
diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html
index c1922f22c..4daf63941 100644
--- a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html
+++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html
@@ -221,6 +221,22 @@
}
+
+ Expiry date (optional)
+ event_busy
+
+
+
+
@@ -238,14 +254,18 @@
@if (existingAddressProofDocuments.length > 0 && !addrFiles.front && readOnly) {
- @if (addrMergedPreview) {
-
+ @if (existingAddrFrontSlotPreview) {
+
} @else {
description
}