- Add Balance Sheet report and disabled-features accounting config - Point menu-access tenant header and env.js/environment.ts tenant identifier at env config instead of hardcoded 'default'/'lincpay' literals - Surface the real backend error (not a generic "no reference id") when an OTP send fails in contact-otp-block - Fix branch-day "₹null" display bug (null vs undefined) and add a guard blocking a branch's first-ever day open before cash denomination migration - Dynamically size the denomination count input and block re-opening an already-open business day from Migrate Cash Denomination - Lock the denomination-managed cash GL row in Migrate Opening Balances (read-only, with a link back to Migrate Cash Denomination) - Remove duplicate Cancel/Submit action bars on the Share Product and Savings Account creation preview steps; align Previous into the same row - Hide "Apply Collection" on a share account once its advance collection is already settled (further purchases go through Apply Additional Shares) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
718 lines
26 KiB
TypeScript
718 lines
26 KiB
TypeScript
/**
|
|
* Copyright since 2025 Mifos Initiative
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
*/
|
|
|
|
import { Component, OnInit, inject } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
|
import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
|
|
import { CashierService } from '../cashier.service';
|
|
|
|
interface Denom {
|
|
id: number;
|
|
value: number;
|
|
kind: 'note' | 'coin';
|
|
label: string;
|
|
}
|
|
|
|
interface BranchDay {
|
|
id: number;
|
|
officeId: number;
|
|
officeName: string | null;
|
|
businessDate: string;
|
|
openedByName: string | null;
|
|
openedAt: string | null;
|
|
openingCash: number | null;
|
|
closedByName: string | null;
|
|
closedAt: string | null;
|
|
closingCash: number | null;
|
|
bookCash: number | null;
|
|
cashDifference: number | null;
|
|
forceClosed: boolean;
|
|
forceCloseReason: string | null;
|
|
remarks: string | null;
|
|
status: 'OPEN' | 'CLOSED';
|
|
/** Only present on the missed-close list. */
|
|
daysOpen?: number;
|
|
}
|
|
|
|
/**
|
|
* Branch day register — opening and closing the branch's business day, and the list of days that
|
|
* were opened and never closed.
|
|
*
|
|
* A day left open is not a tidiness problem: its closing balance never carries forward, a
|
|
* back-dated entry can still land in it, and any cash difference on that date goes unnoticed.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-branch-day',
|
|
templateUrl: './branch-day.component.html',
|
|
styleUrls: [
|
|
'../cashier-shared.scss',
|
|
'../day-book/day-book.component.scss',
|
|
'./branch-day.component.scss'
|
|
],
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
MatIconModule,
|
|
RouterLink
|
|
]
|
|
})
|
|
export class BranchDayComponent implements OnInit {
|
|
private service = inject(CashierService);
|
|
private route = inject(ActivatedRoute);
|
|
|
|
/**
|
|
* Set from the route: the missed-close register is the same data as the day register, shown with
|
|
* only the never-closed days. Two menu entries used to point at one screen, so choosing "Missed
|
|
* Close Register" looked like nothing had happened whenever the branch was up to date.
|
|
*/
|
|
missedOnly = false;
|
|
|
|
days: BranchDay[] = [];
|
|
missed: BranchDay[] = [];
|
|
openDay: any = null;
|
|
today = '';
|
|
/** Set by the backend from the caller's roles — a cashier may read the register but not act on it. */
|
|
canOperate = false;
|
|
loading = false;
|
|
loaded = false;
|
|
errorMsg = '';
|
|
successMsg = '';
|
|
/** True only for a branch that has never opened a business day and whose Cash at Vault / Cash at
|
|
* Teller GL account has never had anything posted to it — Open Day here would let day-to-day cash
|
|
* tracking begin while the ledger silently stays wherever it already was. The backend enforces
|
|
* this independently too (see BranchDayRegisterApiResource#requireCashMigrationBeforeFirstEverOpen);
|
|
* this flag only steers the user to the right screen before they hit that rejection. */
|
|
firstOpenNeedsCashMigration = false;
|
|
|
|
// Open-day form
|
|
showOpenForm = false;
|
|
openDate = '';
|
|
openingCash: number | null = null;
|
|
/** Required only when the counted/typed opening figure differs from yesterday's own closing
|
|
* figure — the backend rejects the open without it in that case. */
|
|
openDifferenceReason = '';
|
|
opening = false;
|
|
|
|
// Close-day form. The date is fixed by which day is being closed, never typed.
|
|
closeTarget: BranchDay | null = null;
|
|
closingCash: number | null = null;
|
|
closeRemarks = '';
|
|
forceReason = '';
|
|
closing = false;
|
|
|
|
// Denomination breakdown, shared by both the open and close forms — a physical note/coin count
|
|
// is the true source of the lump-sum figure, not a typed-in guess. Counted separately per form
|
|
// since opening and closing are two independent physical counts.
|
|
notes: Denom[] = [];
|
|
coins: Denom[] = [];
|
|
openCounts: Record<number, number> = {};
|
|
closeCounts: Record<number, number> = {};
|
|
/** What yesterday's own close counted, note by note — the open form starts from this instead of
|
|
* zero. The backend already carries this figure forward on its own when nothing is submitted for
|
|
* the day being opened; showing it here is only about letting whoever opens the day see and
|
|
* correct it, not a second place the carry-forward logic lives. */
|
|
openCountsAuto: Record<number, number> = {};
|
|
/** What the vault + every cashier's live position added up to when the close form opened — the
|
|
* physical-count table starts from this instead of zero, since the EOD summary already fetched
|
|
* it. Kept separately from closeCounts so a manual recount can be told apart from it and undone. */
|
|
closeCountsAuto: Record<number, number> = {};
|
|
|
|
get allDenoms(): Denom[] {
|
|
return [
|
|
...this.notes,
|
|
...this.coins
|
|
];
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
this.missedOnly = !!this.route.snapshot.data['missedOnly'];
|
|
this.load();
|
|
this.service.getActiveDenominations().subscribe({
|
|
next: (list) => {
|
|
const rows: Denom[] = (list || []).map((x: any) => ({
|
|
id: x.id,
|
|
value: x.denomination,
|
|
kind: x.kind,
|
|
label: '₹' + x.denomination
|
|
}));
|
|
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
|
|
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
|
|
},
|
|
error: () => {
|
|
this.notes = [];
|
|
this.coins = [];
|
|
}
|
|
});
|
|
}
|
|
|
|
countFor(counts: Record<number, number>, d: Denom): number {
|
|
return counts[d.id] || 0;
|
|
}
|
|
|
|
subtotalFor(counts: Record<number, number>, d: Denom): number {
|
|
return this.countFor(counts, d) * d.value;
|
|
}
|
|
|
|
onCountChange(counts: Record<number, number>, d: Denom, raw: any): void {
|
|
counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
|
|
}
|
|
|
|
bumpCount(counts: Record<number, number>, d: Denom, delta: number): void {
|
|
counts[d.id] = Math.max(0, (counts[d.id] || 0) + delta);
|
|
}
|
|
|
|
totalOf(counts: Record<number, number>): number {
|
|
return this.allDenoms.reduce((s, d) => s + this.subtotalFor(counts, d), 0);
|
|
}
|
|
|
|
/** Denominations with a positive count, shaped for the API payload. */
|
|
private denominationPayload(counts: Record<number, number>) {
|
|
return this.allDenoms
|
|
.filter((d) => this.countFor(counts, d) > 0)
|
|
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(counts, d) }));
|
|
}
|
|
|
|
get openDenomTotal(): number {
|
|
return this.totalOf(this.openCounts);
|
|
}
|
|
|
|
get closeDenomTotal(): number {
|
|
return this.totalOf(this.closeCounts);
|
|
}
|
|
|
|
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<number, number> = {};
|
|
for (const d of closing) {
|
|
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
|
|
}
|
|
this.openCountsAuto = totals;
|
|
// Only overwrite the table if nothing has been typed/counted since the form opened.
|
|
if (this.openDenomTotal === 0 && this.openingCash == null) {
|
|
this.openCounts = { ...totals };
|
|
}
|
|
});
|
|
}
|
|
|
|
get hasAutoFilledOpenCounts(): boolean {
|
|
return Object.keys(this.openCountsAuto).length > 0;
|
|
}
|
|
|
|
autoOpenCountFor(d: Denom): number {
|
|
return this.openCountsAuto[d.id] || 0;
|
|
}
|
|
|
|
resetOpenToAutoFill(): void {
|
|
this.openCounts = { ...this.openCountsAuto };
|
|
}
|
|
|
|
get carriedForwardTotal(): number {
|
|
return this.totalOf(this.openCountsAuto);
|
|
}
|
|
|
|
/** The figure the form would actually submit right now — the denomination total when any note/coin
|
|
* has been counted, otherwise the plain typed-in amount. Mirrors {@link effectiveClosingFigure}. */
|
|
get effectiveOpeningFigure(): number | null {
|
|
return this.openDenomTotal > 0 ? this.openDenomTotal : this.openingCash;
|
|
}
|
|
|
|
/** Difference between what is about to be opened with and what yesterday actually closed with —
|
|
* visible before saving, the same way the close form shows a live difference against the book
|
|
* figure, so a short/excess opening count isn't discovered only after the day is already running. */
|
|
get openLiveDifference(): number | null {
|
|
if (!this.hasAutoFilledOpenCounts || this.effectiveOpeningFigure == null) return null;
|
|
return this.effectiveOpeningFigure - this.carriedForwardTotal;
|
|
}
|
|
|
|
/** True once a reason is needed but hasn't been typed — the same "explain the gap before it's
|
|
* allowed" gate the close form already applies to a late close. */
|
|
get needsOpenDifferenceReason(): boolean {
|
|
return this.openLiveDifference != null && this.openLiveDifference !== 0 && !this.openDifferenceReason.trim();
|
|
}
|
|
|
|
get canSubmitOpen(): boolean {
|
|
return !!this.openDate && !this.opening && !this.needsOpenDifferenceReason;
|
|
}
|
|
|
|
submitOpen(): void {
|
|
if (!this.openDate || this.needsOpenDifferenceReason) return;
|
|
this.opening = true;
|
|
this.errorMsg = '';
|
|
const denominations = this.denominationPayload(this.openCounts);
|
|
this.service
|
|
.openBranchDay(
|
|
this.openDate,
|
|
this.openingCash ?? undefined,
|
|
undefined,
|
|
denominations,
|
|
this.openDifferenceReason.trim() || undefined
|
|
)
|
|
.pipe(
|
|
catchError((err: any) => {
|
|
this.errorMsg =
|
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not open the day.';
|
|
return of(null);
|
|
})
|
|
)
|
|
.subscribe((res: any) => {
|
|
this.opening = false;
|
|
if (res) {
|
|
this.showOpenForm = false;
|
|
this.successMsg = `Business day ${res.businessDate} opened.`;
|
|
this.load();
|
|
}
|
|
});
|
|
}
|
|
|
|
/** The day's expected figure and the working behind it, fetched when the close form opens. */
|
|
preview: any = null;
|
|
previewLoading = false;
|
|
|
|
startClose(day: BranchDay): void {
|
|
this.closeTarget = day;
|
|
this.closingCash = null;
|
|
this.closeCounts = {};
|
|
this.closeCountsAuto = {};
|
|
this.closeRemarks = '';
|
|
this.forceReason = '';
|
|
this.successMsg = '';
|
|
this.errorMsg = '';
|
|
this.preview = null;
|
|
|
|
// Pre-fill the counted figure with what the books expect. It stays editable — the day is still
|
|
// closed on what was physically counted — but in the ordinary case where the drawer tallies
|
|
// there is nothing to type, and no chance of a slip of the keyboard inventing a difference.
|
|
this.previewLoading = true;
|
|
this.service
|
|
.branchDayClosePreview(day.businessDate)
|
|
.pipe(catchError(() => of(null)))
|
|
.subscribe((res: any) => {
|
|
this.previewLoading = false;
|
|
this.preview = res;
|
|
if (res?.expectedCash != null && this.closingCash == null) {
|
|
this.closingCash = Number(res.expectedCash);
|
|
}
|
|
});
|
|
|
|
this.loadEodSummary(day);
|
|
}
|
|
|
|
// ── EOD denomination summary: what's in the vault, and what every cashier under it is
|
|
// currently holding, fetched automatically so closing the day doesn't require separately
|
|
// checking Vault Position and every cashier's own Cashier Position screen by hand. ──
|
|
|
|
eodLoading = false;
|
|
eodTellers: any[] = [];
|
|
|
|
/**
|
|
* Vault + every cashier's current holding, added up across the whole branch and checked against
|
|
* the book figure — the question a manager actually needs answered at close: does the BRANCH
|
|
* tally, not just the vault alone. Computed on read rather than stored, so it never has to be
|
|
* kept in sync across the two independent loads (the book preview and the EOD summary) that feed
|
|
* it.
|
|
*/
|
|
get branchTotalSummary(): {
|
|
vaultTotal: number;
|
|
cashierTotal: number;
|
|
grandTotal: number;
|
|
expected: number;
|
|
difference: number;
|
|
} | null {
|
|
if (!this.preview || this.eodTellers.length === 0) return null;
|
|
let vaultTotal = 0;
|
|
let cashierTotal = 0;
|
|
for (const t of this.eodTellers) {
|
|
vaultTotal += Number(t.vault?.totalAmount ?? 0);
|
|
for (const c of t.cashiers) {
|
|
cashierTotal += Number(c.currentTotal ?? 0);
|
|
}
|
|
}
|
|
const grandTotal = vaultTotal + cashierTotal;
|
|
const expected = Number(this.preview.expectedCash ?? 0);
|
|
return { vaultTotal, cashierTotal, grandTotal, expected, difference: grandTotal - expected };
|
|
}
|
|
|
|
private loadEodSummary(day: BranchDay): void {
|
|
this.eodLoading = true;
|
|
this.eodTellers = [];
|
|
this.service
|
|
.getTellers(day.officeId)
|
|
.pipe(
|
|
switchMap((tellers) => {
|
|
const list = tellers || [];
|
|
if (!list.length) return of([]);
|
|
return forkJoin(list.map((t: any) => this.loadTellerSummary(t, day.businessDate)));
|
|
}),
|
|
catchError(() => of([]))
|
|
)
|
|
.subscribe((results: any[]) => {
|
|
this.eodLoading = false;
|
|
this.eodTellers = results;
|
|
this.autoFillCloseCounts();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Seeds the physical-count table from what the vault and every cashier are already shown to be
|
|
* holding, instead of leaving it at zero and making whoever closes the day re-key numbers that
|
|
* are already on screen above. It stays a starting point, not a lock — any row can still be
|
|
* corrected against what was actually counted, and {@link resetToAutoFill} undoes a correction.
|
|
*/
|
|
private autoFillCloseCounts(): void {
|
|
const totals: Record<number, number> = {};
|
|
for (const t of this.eodTellers) {
|
|
for (const d of t.vault?.denominations || []) {
|
|
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
|
|
}
|
|
for (const c of t.cashiers || []) {
|
|
for (const d of c.denominations || []) {
|
|
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(totals).length === 0) return;
|
|
this.closeCountsAuto = totals;
|
|
this.closeCounts = { ...totals };
|
|
}
|
|
|
|
get hasAutoFilledCounts(): boolean {
|
|
return Object.keys(this.closeCountsAuto).length > 0;
|
|
}
|
|
|
|
/** The auto-fetched count for one denomination, so a row that was hand-edited away from it can
|
|
* still show what the vault + cashier positions said. */
|
|
autoCountFor(d: Denom): number {
|
|
return this.closeCountsAuto[d.id] || 0;
|
|
}
|
|
|
|
resetToAutoFill(): void {
|
|
this.closeCounts = { ...this.closeCountsAuto };
|
|
}
|
|
|
|
private loadTellerSummary(teller: any, date: string) {
|
|
return forkJoin({
|
|
vault: this.service.vaultPosition(teller.id, date).pipe(catchError(() => of(null))),
|
|
cashiers: this.service.getCashiersForTeller(teller.id).pipe(catchError(() => of({ cashiers: [] })))
|
|
}).pipe(
|
|
switchMap(({ vault, cashiers }) => {
|
|
const cashierList = cashiers?.cashiers || [];
|
|
if (!cashierList.length) {
|
|
return of({ tellerId: teller.id, tellerName: teller.name, vault, cashiers: [] });
|
|
}
|
|
return forkJoin(cashierList.map((c: any) => this.loadCashierSummary(c, date))).pipe(
|
|
map((cashierSummaries: any[]) => ({
|
|
tellerId: teller.id,
|
|
tellerName: teller.name,
|
|
vault,
|
|
cashiers: cashierSummaries
|
|
}))
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
private loadCashierSummary(cashier: any, date: string) {
|
|
return forkJoin({
|
|
position: this.service.cashierPositionReport(cashier.id, date).pipe(catchError(() => of(null))),
|
|
morning: this.service.getCashierMorningBalance(cashier.id, date).pipe(catchError(() => of(null))),
|
|
reconciliation: this.service.checkReconciliation(cashier.id, date).pipe(catchError(() => of(null))),
|
|
settlement: this.service.checkSettlement(cashier.id, date).pipe(catchError(() => of(null))),
|
|
glEntries: this.service.getGlEntries(date, cashier.id, undefined, 0, 1).pipe(catchError(() => of(null)))
|
|
}).pipe(
|
|
map(({ position, morning, reconciliation, settlement, glEntries }: any) => {
|
|
const morningMap = new Map<number, number>();
|
|
(morning?.denominations || []).forEach((d: any) => morningMap.set(d.denominationId, d.morningCount ?? 0));
|
|
return {
|
|
cashierId: cashier.id,
|
|
cashierName: cashier.staffName,
|
|
morningTotal: Number(morning?.totalMorning ?? 0),
|
|
currentTotal: Number(position?.totalAmount ?? 0),
|
|
reconciled: reconciliation?.reconciled ?? null,
|
|
alreadySettled: settlement?.alreadySettled ?? null,
|
|
txnCount: glEntries?.totalFilteredRecords ?? null,
|
|
denominations: ((position?.denominations || []) as any[]).map((d: any) => ({
|
|
...d,
|
|
morningCount: morningMap.get(d.denominationId) ?? 0,
|
|
delta: d.count - (morningMap.get(d.denominationId) ?? 0)
|
|
}))
|
|
};
|
|
})
|
|
);
|
|
}
|
|
|
|
/** The counted figure the form would submit right now — the denomination total when any note/coin
|
|
* has been counted, otherwise the plain typed-in amount. */
|
|
get effectiveClosingFigure(): number | null {
|
|
return this.closeDenomTotal > 0 ? this.closeDenomTotal : this.closingCash;
|
|
}
|
|
|
|
/** Difference the form would record right now, so it is visible before saving. */
|
|
get liveDifference(): number | null {
|
|
if (!this.preview || this.effectiveClosingFigure == null) return null;
|
|
return Number(this.effectiveClosingFigure) - Number(this.preview.expectedCash ?? 0);
|
|
}
|
|
|
|
/** Puts the expected figure back after an edit. */
|
|
useExpected(): void {
|
|
if (this.preview?.expectedCash != null) {
|
|
this.closingCash = Number(this.preview.expectedCash);
|
|
}
|
|
}
|
|
|
|
cancelClose(): void {
|
|
this.closeTarget = null;
|
|
this.eodTellers = [];
|
|
this.closeCountsAuto = {};
|
|
}
|
|
|
|
/** A date earlier than today is a late close, and has to say why it was left open. */
|
|
get isLateClose(): boolean {
|
|
return !!this.closeTarget && this.closeTarget.businessDate < this.today;
|
|
}
|
|
|
|
/**
|
|
* True when any cashier who was given cash (currentTotal > 0) is still NOT reconciled
|
|
* or NOT settled — meaning outstanding cash hasn't been accounted for yet.
|
|
*/
|
|
get hasUnreconciledOrUnsettledCashiers(): boolean {
|
|
for (const t of this.eodTellers) {
|
|
for (const c of t.cashiers) {
|
|
if (c.currentTotal > 0 && (c.reconciled === false || c.alreadySettled === false)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Names of cashiers blocking the close, for the error message. */
|
|
get unreconciledOrUnsettledCashierDetails(): { name: string; notReconciled: boolean; notSettled: boolean }[] {
|
|
const result: { name: string; notReconciled: boolean; notSettled: boolean }[] = [];
|
|
for (const t of this.eodTellers) {
|
|
for (const c of t.cashiers) {
|
|
if (c.currentTotal > 0 && (c.reconciled === false || c.alreadySettled === false)) {
|
|
result.push({
|
|
name: c.cashierName,
|
|
notReconciled: c.reconciled === false,
|
|
notSettled: c.alreadySettled === false
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Whether a reason has to be given before the day can close.
|
|
*
|
|
* Only for a late close. Cash still sitting with a cashier is not something a reason excuses —
|
|
* see canSubmitClose.
|
|
*/
|
|
get needsForceReason(): boolean {
|
|
return this.isLateClose;
|
|
}
|
|
|
|
get canSubmitClose(): boolean {
|
|
const hasFigure = this.closingCash != null || this.closeDenomTotal > 0;
|
|
if (!this.closeTarget || !hasFigure || this.closing) return false;
|
|
// Block while cashier/vault data is still loading — the reconciliation check can't run yet.
|
|
if (this.eodLoading || this.previewLoading) return false;
|
|
// An unreconciled cashier is a reason-required condition, not an absolute block: the backend
|
|
// accepts the close once a reason is recorded (and appends the cashiers' names to it), and
|
|
// refusing outright left a day that could never be closed at all — a cashier who reconciled on
|
|
// the wrong date, went on leave, or left mid-shift would strand that day and every day after it.
|
|
if (this.isLateClose || this.hasUnreconciledOrUnsettledCashiers) {
|
|
return !!this.forceReason.trim();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
submitClose(): void {
|
|
if (this.eodLoading || this.previewLoading) {
|
|
this.errorMsg = 'Still loading the cashier and vault positions — try again in a moment.';
|
|
return;
|
|
}
|
|
// Safety net mirroring canSubmitClose: never send a close that the backend will refuse for a
|
|
// missing override reason.
|
|
if ((this.isLateClose || this.hasUnreconciledOrUnsettledCashiers) && !this.forceReason.trim()) {
|
|
this.errorMsg = 'Record why this day is being closed before submitting.';
|
|
return;
|
|
}
|
|
const day = this.closeTarget;
|
|
const denominations = this.denominationPayload(this.closeCounts);
|
|
const figure = denominations.length ? this.closeDenomTotal : this.closingCash;
|
|
if (!day || figure == null) return;
|
|
|
|
this.closing = true;
|
|
this.errorMsg = '';
|
|
this.service
|
|
.closeBranchDay(
|
|
day.businessDate,
|
|
Number(figure),
|
|
this.closeRemarks.trim() || undefined,
|
|
// Sent whenever one was typed, not only on a late close: it is also the override the backend
|
|
// needs for a same-day close over an unreconciled cashier, and gating it on isLateClose
|
|
// dropped it on exactly the request that could not succeed without it.
|
|
this.forceReason.trim() || undefined,
|
|
undefined,
|
|
denominations
|
|
)
|
|
.pipe(
|
|
catchError((err: any) => {
|
|
this.errorMsg =
|
|
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the day.';
|
|
return of(null);
|
|
})
|
|
)
|
|
.subscribe((res: any) => {
|
|
this.closing = false;
|
|
if (res) {
|
|
this.closeTarget = null;
|
|
const diff = Number(res.cashDifference ?? 0);
|
|
this.successMsg =
|
|
diff === 0
|
|
? `Business day ${res.businessDate} closed — cash tallied.`
|
|
: `Business day ${res.businessDate} closed with a difference of ₹${diff.toFixed(2)}.`;
|
|
this.load();
|
|
}
|
|
});
|
|
}
|
|
|
|
absValue(n: number): number {
|
|
return Math.abs(n);
|
|
}
|
|
|
|
/** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */
|
|
differenceLabel(d: number | null): string {
|
|
if (d == null) return '—';
|
|
const n = Number(d);
|
|
if (n === 0) return 'Tallied';
|
|
return n > 0 ? `Excess ₹${Math.abs(n).toFixed(2)}` : `Short ₹${Math.abs(n).toFixed(2)}`;
|
|
}
|
|
|
|
stamp(iso: string | null): string {
|
|
if (!iso) return '—';
|
|
const [
|
|
d,
|
|
t
|
|
] = iso.split('T');
|
|
return t ? `${d} ${t.slice(0, 5)}` : d;
|
|
}
|
|
|
|
timeOnly(iso: string | null): string {
|
|
if (!iso) return '—';
|
|
const t = iso.split('T')[1];
|
|
return t ? t.slice(0, 5) : '—';
|
|
}
|
|
}
|