/** * 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 = { 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 = { 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); } }