Files
cbsnexora/src/app/accounting/search-journal-entry/search-journal-entry.component.ts
T
niteshandClaude Sonnet 5 7b77166b19 fix: tenant-config fixes, cashier/share/savings UI bugs, and menu-access hardcoding
- 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>
2026-09-13 15:19:21 +05:30

629 lines
22 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, OnDestroy, ViewChild, AfterViewInit, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import { UntypedFormControl } from '@angular/forms';
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';
import { MatAutocompleteTrigger, MatAutocomplete } from '@angular/material/autocomplete';
import { GlAccountSelectorComponent } from '../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { AsyncPipe, NgClass, NgIf, NgFor, DecimalPipe, CurrencyPipe } from '@angular/common';
import {
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatNoDataRow
} from '@angular/material/table';
import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { DatetimeFormatPipe } from '../../pipes/datetime-format.pipe';
import { FormatNumberPipe } from '../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatIconModule } from '@angular/material/icon';
const STATE_KEY = 'gl_ledger_filter_state';
@Component({
selector: 'mifosx-search-journal-entry',
standalone: true,
templateUrl: './search-journal-entry.component.html',
styleUrls: ['./search-journal-entry.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatAutocompleteTrigger,
GlAccountSelectorComponent,
MatAutocomplete,
MatTable,
MatSort,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatSortHeader,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatNoDataRow,
MatPaginator,
AsyncPipe,
NgClass,
NgIf,
NgFor,
DateFormatPipe,
DatetimeFormatPipe,
FormatNumberPipe,
FaIconComponent,
MatCardModule,
MatTooltipModule,
MatIconModule,
RouterLink
]
})
export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterViewInit {
private accountingService = inject(AccountingService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
// ── 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();
officeData: any;
filteredOfficeData: any;
glAccount = new UntypedFormControl();
glAccountData: any;
entryTypeFilter = new UntypedFormControl('');
transactionDateFrom = new UntypedFormControl(new Date(new Date().setMonth(new Date().getMonth() - 1)));
transactionDateTo = new UntypedFormControl(new Date());
transactionIdCtrl = new UntypedFormControl();
submittedOnDateFrom = new UntypedFormControl();
submittedOnDateTo = new UntypedFormControl();
referenceNumber = new UntypedFormControl();
entryTypeFilterData = [
{ option: 'All Entries', value: '' },
{ option: 'Manual Entries', value: 'true' },
{ option: 'System Entries', value: 'false' }
];
// ── Table columns ─────────────────────────────────────────────────────────
displayedColumns: string[] = [
'id',
'transactionDate',
'transactionId',
'savingsAccountNo',
'officeName',
'glAccountCode',
'glAccountName',
'glAccountType',
'entryType',
'debit',
'credit',
'runningBalance',
'manualEntry',
'createdByUserName',
'submittedOnDate',
'actions'
];
dayWiseColumns = [
'date',
'openingBalance',
'totalDebit',
'totalCredit',
'netMovement',
'closingBalance',
'entryCount'
];
dataSource: JournalEntriesDataSource;
// ── Ledger summary (from backend /ledger endpoint) ────────────────────────
ledgerData: any = null;
ledgerLoading = false;
openingBalance = 0;
closingBalance = 0;
totalDebit = 0;
totalCredit = 0;
netMovement = 0;
totalEntries = 0;
isDebitNormal = true;
dayWiseData: any[] = [];
glAccountInfo: any = null;
// ── UI state ──────────────────────────────────────────────────────────────
activeQuickFilter = '';
activeFiltersCount = 0;
showAdvancedFilters = false;
selectedGlAccountId: number | null = null;
// ── Quick date filter presets ─────────────────────────────────────────────
quickFilters = [
{ label: 'Today', icon: 'calendar-day', type: 'day' },
{ label: 'Yesterday', icon: 'calendar-alt', type: 'prev' },
{ label: 'This Week', icon: 'calendar-week', type: 'week' },
{ label: 'Last Week', icon: 'calendar-week', type: 'lastweek' },
{ label: 'This Month', icon: 'calendar-alt', type: 'month' },
{ label: 'Last Month', icon: 'calendar-alt', type: 'lastmonth' },
{ label: 'Last 3 Mo', icon: 'chart-area', type: 'range', days: 90 },
{ label: 'Last 6 Mo', icon: 'chart-area', type: 'range', days: 180 },
{ label: 'This Year', icon: 'calendar-check', type: 'year' }
];
filterJournalEntriesBy = [
{ type: 'officeId', value: '' },
{ type: 'glAccountId', value: '' },
{ type: 'manualEntriesOnly', value: '' },
{ type: 'transactionId', value: '' },
{
type: 'fromDate',
value: this.dateUtils.formatDate(
new Date(new Date().setMonth(new Date().getMonth() - 1)),
this.settingsService.dateFormat
)
},
{ type: 'toDate', value: this.dateUtils.formatDate(new Date(), this.settingsService.dateFormat) },
{ type: 'submittedOnDateFrom', value: '' },
{ type: 'submittedOnDateTo', value: '' },
{ type: 'dateFormat', value: this.settingsService.dateFormat },
{ type: 'locale', value: this.settingsService.language.code }
];
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
constructor() {
this.route.data.subscribe((data: { offices: any; glAccounts: any }) => {
this.officeData = data.offices;
this.glAccountData = data.glAccounts;
});
}
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.paginator.pageSize = 10;
this.restoreState();
this.setFilteredOffices();
this.getJournalEntries();
this.loadLedgerSummary();
}
ngOnDestroy() {
this.saveState();
}
private saveState() {
const fromFilter = this.filterJournalEntriesBy.find((f) => f.type === 'fromDate');
const toFilter = this.filterJournalEntriesBy.find((f) => f.type === 'toDate');
const state = {
fromDateFilter: fromFilter?.value || '',
toDateFilter: toFilter?.value || '',
fromDateIso: this.transactionDateFrom.value ? new Date(this.transactionDateFrom.value).toISOString() : '',
toDateIso: this.transactionDateTo.value ? new Date(this.transactionDateTo.value).toISOString() : '',
glAccountId: this.selectedGlAccountId,
glAccountValue: this.glAccount.value,
entryType: this.entryTypeFilter.value,
activeQuickFilter: this.activeQuickFilter,
pageIndex: this.paginator.pageIndex,
pageSize: this.paginator.pageSize
};
sessionStorage.setItem(STATE_KEY, JSON.stringify(state));
}
private restoreState() {
try {
const raw = sessionStorage.getItem(STATE_KEY);
if (!raw) {
return;
}
const state = JSON.parse(raw);
// Restore date pickers and filter strings
if (state.fromDateIso) {
this.transactionDateFrom.setValue(new Date(state.fromDateIso), { emitEvent: false });
const idx = this.filterJournalEntriesBy.findIndex((f) => f.type === 'fromDate');
if (idx >= 0) {
this.filterJournalEntriesBy[idx].value = state.fromDateFilter;
}
}
if (state.toDateIso) {
this.transactionDateTo.setValue(new Date(state.toDateIso), { emitEvent: false });
const idx = this.filterJournalEntriesBy.findIndex((f) => f.type === 'toDate');
if (idx >= 0) {
this.filterJournalEntriesBy[idx].value = state.toDateFilter;
}
}
// Restore GL account — set the form value so the selector shows it
if (state.glAccountId) {
this.selectedGlAccountId = state.glAccountId;
this.glAccount.setValue(state.glAccountValue, { emitEvent: false });
const idx = this.filterJournalEntriesBy.findIndex((f) => f.type === 'glAccountId');
if (idx >= 0) {
this.filterJournalEntriesBy[idx].value = String(state.glAccountId);
}
}
// Restore entry type filter
if (state.entryType !== undefined && state.entryType !== null) {
this.entryTypeFilter.setValue(state.entryType, { emitEvent: false });
const idx = this.filterJournalEntriesBy.findIndex((f) => f.type === 'manualEntriesOnly');
if (idx >= 0) {
this.filterJournalEntriesBy[idx].value = state.entryType;
}
}
if (state.activeQuickFilter) {
this.activeQuickFilter = state.activeQuickFilter;
}
if (state.pageIndex != null) {
this.paginator.pageIndex = state.pageIndex;
}
if (state.pageSize != null) {
this.paginator.pageSize = state.pageSize;
}
} catch (_) {
/* ignore corrupt state */
}
}
ngAfterViewInit() {
this.officeName.valueChanges
.pipe(
map((v) => (v?.id ? String(v.id) : '')),
debounceTime(500),
distinctUntilChanged(),
tap((val) => this.applyFilter(val, 'officeId'))
)
.subscribe();
this.glAccount.valueChanges
.pipe(
debounceTime(500),
distinctUntilChanged(),
tap((val) => {
// GlAccountSelectorComponent emits the account id (number) directly
const id = val != null && val !== '' ? String(val) : '';
this.selectedGlAccountId = id ? Number(id) : null;
this.applyFilter(id, 'glAccountId');
})
)
.subscribe();
this.entryTypeFilter.valueChanges
.pipe(
debounceTime(300),
distinctUntilChanged(),
tap((val) => this.applyFilter(val ?? '', 'manualEntriesOnly'))
)
.subscribe();
this.transactionIdCtrl.valueChanges
.pipe(
debounceTime(600),
distinctUntilChanged(),
tap((val) => this.applyFilter(val ?? '', 'transactionId'))
)
.subscribe();
this.transactionDateFrom.valueChanges
.pipe(
debounceTime(500),
distinctUntilChanged(),
tap((val) => this.applyFilter(this.dateUtils.formatDate(val, this.settingsService.dateFormat), 'fromDate'))
)
.subscribe();
this.transactionDateTo.valueChanges
.pipe(
debounceTime(500),
distinctUntilChanged(),
tap((val) => this.applyFilter(this.dateUtils.formatDate(val, this.settingsService.dateFormat), 'toDate'))
)
.subscribe();
this.submittedOnDateFrom.valueChanges
.pipe(
debounceTime(500),
distinctUntilChanged(),
tap((val) =>
this.applyFilter(this.dateUtils.formatDate(val, this.settingsService.dateFormat), 'submittedOnDateFrom')
)
)
.subscribe();
this.submittedOnDateTo.valueChanges
.pipe(
debounceTime(500),
distinctUntilChanged(),
tap((val) =>
this.applyFilter(this.dateUtils.formatDate(val, this.settingsService.dateFormat), 'submittedOnDateTo')
)
)
.subscribe();
this.sort.sortChange.subscribe(() => (this.paginator.pageIndex = 0));
merge(this.sort.sortChange, this.paginator.page)
.pipe(tap(() => this.loadJournalEntriesPage()))
.subscribe();
}
// ── Ledger Summary (backend-computed) ─────────────────────────────────────
loadLedgerSummary() {
this.ledgerLoading = true;
const fromFilter = this.filterJournalEntriesBy.find((f) => f.type === 'fromDate');
const toFilter = this.filterJournalEntriesBy.find((f) => f.type === 'toDate');
const offFilter = this.filterJournalEntriesBy.find((f) => f.type === 'officeId');
const manFilter = this.filterJournalEntriesBy.find((f) => f.type === 'manualEntriesOnly');
const txnFilter = this.filterJournalEntriesBy.find((f) => f.type === 'transactionId');
this.accountingService
.getGLAccountLedger({
glAccountId: this.selectedGlAccountId ?? undefined,
officeId: offFilter?.value ? Number(offFilter.value) : undefined,
fromDate: fromFilter?.value || undefined,
toDate: toFilter?.value || undefined,
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code,
manualEntriesOnly: manFilter?.value === 'true' ? true : manFilter?.value === 'false' ? false : undefined,
transactionId: txnFilter?.value || undefined,
limit: 0, // summary only — no entries page needed from this call
offset: 0
})
.subscribe({
next: (data: any) => {
this.ledgerData = data;
this.glAccountInfo = data.glAccount;
this.openingBalance = Number(data.openingBalance ?? 0);
this.closingBalance = Number(data.closingBalance ?? 0);
this.totalDebit = Number(data.totalDebit ?? 0);
this.totalCredit = Number(data.totalCredit ?? 0);
this.netMovement = Number(data.netMovement ?? 0);
this.totalEntries = Number(data.totalEntries ?? 0);
this.isDebitNormal = data.debitNormal !== false;
this.dayWiseData = (data.dayWiseSummary || []).map((d: any) => ({
date: d.date,
openingBalance: Number(d.openingBalance ?? 0),
totalDebit: Number(d.totalDebit ?? 0),
totalCredit: Number(d.totalCredit ?? 0),
netMovement: Number(d.netMovement ?? 0),
closingBalance: Number(d.closingBalance ?? 0),
entryCount: d.entryCount || 0
}));
this.updateActiveFiltersCount();
this.ledgerLoading = false;
},
error: (err: any) => {
console.error('[LedgerSummary] API error:', err);
this.ledgerLoading = false;
}
});
}
// ── Quick filter ──────────────────────────────────────────────────────────
applyQuickFilter(q: any) {
this.activeQuickFilter = q.label;
const today = new Date();
today.setHours(0, 0, 0, 0);
let from: Date, to: Date;
switch (q.type) {
case 'day':
from = new Date(today);
to = new Date(today);
break;
case 'prev':
from = new Date(today);
from.setDate(from.getDate() - 1);
to = new Date(from);
break;
case 'week': {
const dow = today.getDay();
from = new Date(today);
from.setDate(today.getDate() - dow);
to = new Date(today);
break;
}
case 'lastweek': {
const dow = today.getDay();
to = new Date(today);
to.setDate(today.getDate() - dow - 1);
from = new Date(to);
from.setDate(to.getDate() - 6);
break;
}
case 'month':
from = new Date(today.getFullYear(), today.getMonth(), 1);
to = new Date(today);
break;
case 'lastmonth':
from = new Date(today.getFullYear(), today.getMonth() - 1, 1);
to = new Date(today.getFullYear(), today.getMonth(), 0);
break;
case 'year':
from = new Date(today.getFullYear(), 0, 1);
to = new Date(today);
break;
default:
from = new Date(today);
from.setDate(today.getDate() - (q.days || 90));
to = new Date(today);
}
this.transactionDateFrom.setValue(from, { emitEvent: false });
this.transactionDateTo.setValue(to, { emitEvent: false });
this.applyFilter(this.dateUtils.formatDate(from, this.settingsService.dateFormat), 'fromDate');
this.applyFilter(this.dateUtils.formatDate(to, this.settingsService.dateFormat), 'toDate');
}
clearQuickFilter() {
this.activeQuickFilter = '';
const dfrom = new Date(new Date().setMonth(new Date().getMonth() - 1));
const dto = new Date();
this.transactionDateFrom.setValue(dfrom, { emitEvent: false });
this.transactionDateTo.setValue(dto, { emitEvent: false });
this.applyFilter(this.dateUtils.formatDate(dfrom, this.settingsService.dateFormat), 'fromDate');
this.applyFilter(this.dateUtils.formatDate(dto, this.settingsService.dateFormat), 'toDate');
}
clearFilters() {
sessionStorage.removeItem(STATE_KEY);
this.officeName.setValue('', { emitEvent: false });
this.glAccount.setValue('', { emitEvent: false });
this.entryTypeFilter.setValue('', { emitEvent: false });
this.transactionIdCtrl.setValue('', { emitEvent: false });
this.submittedOnDateFrom.setValue('', { emitEvent: false });
this.submittedOnDateTo.setValue('', { emitEvent: false });
this.referenceNumber.setValue('', { emitEvent: false });
this.selectedGlAccountId = null;
this.filterJournalEntriesBy.forEach((f) => {
if (![
'fromDate',
'toDate',
'dateFormat',
'locale'
].includes(f.type)) f.value = '';
});
this.clearQuickFilter();
this.loadLedgerSummary();
this.loadJournalEntriesPage();
}
// ── Table & pagination ────────────────────────────────────────────────────
loadJournalEntriesPage() {
if (!this.sort.direction) {
delete (this.sort as any).active;
}
this.dataSource.getJournalEntries(
this.filterJournalEntriesBy,
this.sort.active,
this.sort.direction,
this.paginator.pageIndex,
this.paginator.pageSize || 10
);
}
applyFilter(filterValue: string, property: string) {
this.paginator.pageIndex = 0;
const idx = this.filterJournalEntriesBy.findIndex((f) => f.type === property);
if (idx >= 0) this.filterJournalEntriesBy[idx].value = filterValue;
this.loadJournalEntriesPage();
this.loadLedgerSummary();
}
getJournalEntries() {
this.dataSource = new JournalEntriesDataSource(this.accountingService);
this.dataSource.getJournalEntries(
this.filterJournalEntriesBy,
this.sort.active,
this.sort.direction,
this.paginator.pageIndex,
this.paginator.pageSize || 10
);
}
// ── Helper getters ────────────────────────────────────────────────────────
get hasGlAccountFilter(): boolean {
return !!this.selectedGlAccountId;
}
get netMovementPositive(): boolean {
return this.netMovement >= 0;
}
getRunningBalance(entry: any): number {
return Number(entry.officeRunningBalance ?? entry.organizationRunningBalance ?? 0);
}
getAccountTypeBadgeClass(type: string): string {
const m: Record<string, string> = {
ASSET: 'gl-badge--asset',
LIABILITY: 'gl-badge--liability',
INCOME: 'gl-badge--income',
EXPENSE: 'gl-badge--expense',
EQUITY: 'gl-badge--equity'
};
return m[type] || 'gl-badge--default';
}
getAccountTypeIcon(type: string): string {
const m: Record<string, string> = {
ASSET: 'building-columns',
LIABILITY: 'file-lines',
INCOME: 'arrow-down',
EXPENSE: 'arrow-up',
EQUITY: 'scale-balanced'
};
return m[type] || 'tag';
}
getEntryTypeClass(entry: any): string {
return entry.entryType?.value === 'DEBIT' ? 'gl-row--dr' : 'gl-row--cr';
}
getDayNetClass(net: number): string {
return net >= 0 ? 'gl-positive' : 'gl-negative';
}
private updateActiveFiltersCount() {
this.activeFiltersCount = this.filterJournalEntriesBy.filter(
(f) => ![
'dateFormat',
'locale',
'fromDate',
'toDate'
].includes(f.type) && f.value !== ''
).length;
}
displayOfficeName(office?: any): string | undefined {
return office ? office.name : undefined;
}
setFilteredOffices() {
this.filteredOfficeData = this.officeName.valueChanges.pipe(
startWith(''),
map((o: any) => (typeof o === 'string' ? o : o?.name || '')),
map((n: string) =>
n ? this.officeData?.filter((o: any) => o.name.toLowerCase().includes(n.toLowerCase())) : this.officeData
)
);
}
formatLedgerDate(dateArr: any): string {
if (!dateArr) return '—';
if (Array.isArray(dateArr)) {
return `${dateArr[0]}-${String(dateArr[1]).padStart(2, '0')}-${String(dateArr[2]).padStart(2, '0')}`;
}
return String(dateArr).substring(0, 10);
}
}