From 7c910da0b4abe4cdde8ec394f12a8a27802f82bf Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 19 May 2026 10:45:37 +0530 Subject: [PATCH] update accounting journal entry UI and API response --- src/app/accounting/accounting.service.ts | 34 + .../journal-entry.datasource.ts | 86 +- .../search-journal-entry.component.html | 880 ++++++++-- .../search-journal-entry.component.scss | 1483 ++++++++++++++++- .../search-journal-entry.component.ts | 642 ++++--- ...w-journal-entry-transaction.component.html | 380 +++-- ...w-journal-entry-transaction.component.scss | 492 +++++- ...iew-journal-entry-transaction.component.ts | 70 +- src/app/shared/icons.module.ts | 40 +- 9 files changed, 3477 insertions(+), 630 deletions(-) diff --git a/src/app/accounting/accounting.service.ts b/src/app/accounting/accounting.service.ts index bb600e875..dcd199783 100644 --- a/src/app/accounting/accounting.service.ts +++ b/src/app/accounting/accounting.service.ts @@ -106,6 +106,40 @@ export class AccountingService { return this.http.get('/journalentries', { params: httpParams }); } + /** + * CBS GL Account Ledger Report: returns opening balance, day-wise summary, totals, and paginated entries. + */ + getGLAccountLedger(params: { + glAccountId?: number; + officeId?: number; + fromDate?: string; + toDate?: string; + dateFormat?: string; + locale?: string; + manualEntriesOnly?: boolean; + transactionId?: string; + offset?: number; + limit?: number; + orderBy?: string; + sortOrder?: string; + }): Observable { + let httpParams = new HttpParams(); + if (params.glAccountId) httpParams = httpParams.set('glAccountId', params.glAccountId.toString()); + if (params.officeId) httpParams = httpParams.set('officeId', params.officeId.toString()); + if (params.fromDate) httpParams = httpParams.set('fromDate', params.fromDate); + if (params.toDate) httpParams = httpParams.set('toDate', params.toDate); + if (params.dateFormat) httpParams = httpParams.set('dateFormat', params.dateFormat); + if (params.locale) httpParams = httpParams.set('locale', params.locale); + if (params.manualEntriesOnly != null) + httpParams = httpParams.set('manualEntriesOnly', params.manualEntriesOnly.toString()); + if (params.transactionId) httpParams = httpParams.set('transactionId', params.transactionId); + if (params.offset != null) httpParams = httpParams.set('offset', params.offset.toString()); + if (params.limit != null) httpParams = httpParams.set('limit', params.limit.toString()); + if (params.orderBy) httpParams = httpParams.set('orderBy', params.orderBy); + if (params.sortOrder) httpParams = httpParams.set('sortOrder', params.sortOrder); + return this.http.get('/journalentries/ledger', { params: httpParams }); + } + /** * @param {string} transactionId Transaction ID to be reverted. * @param {string} comments Any comments. diff --git a/src/app/accounting/search-journal-entry/journal-entry.datasource.ts b/src/app/accounting/search-journal-entry/journal-entry.datasource.ts index e65308e09..edc15fae6 100644 --- a/src/app/accounting/search-journal-entry/journal-entry.datasource.ts +++ b/src/app/accounting/search-journal-entry/journal-entry.datasource.ts @@ -6,68 +6,74 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** Angular Imports */ import { CollectionViewer, DataSource } from '@angular/cdk/collections'; - -/** rxjs Imports */ import { Observable, BehaviorSubject } from 'rxjs'; - -/** Custom Services */ import { AccountingService } from '../accounting.service'; -/** - * Journal entries custom data source to implement server side filtering, pagination and sorting. - */ export class JournalEntriesDataSource implements DataSource { - /** Journal entries behavior subject to represent loaded journal entries page. */ private journalEntriesSubject = new BehaviorSubject([]); - /** Records subject to represent total number of filtered journal entry records. */ + private filteredSubject = new BehaviorSubject([]); private recordsSubject = new BehaviorSubject(0); - /** Records observable which can be subscribed to get the value of total number of filtered journal entry records. */ - public records$ = this.recordsSubject.asObservable(); - /** - * @param {AccountingService} accountingService Accounting Service - */ + public records$ = this.recordsSubject.asObservable(); + public entries$ = this.filteredSubject.asObservable(); + + private accountTypeFilter = ''; + constructor(private accountingService: AccountingService) {} - /** - * Gets journal entries on the basis of provided parameters and emits the value. - * @param {any} filterBy Properties by which entries should be filtered. - * @param {string} orderBy Property by which entries should be sorted. - * @param {string} sortOrder Sort order: ascending or descending. - * @param {number} pageIndex Page number. - * @param {number} limit Number of entries within the page. - */ - getJournalEntries( - filterBy: any, - orderBy: string = '', - sortOrder: string = '', - pageIndex: number = 0, - limit: number = 10 - ) { + getJournalEntries(filterBy: any, orderBy = '', sortOrder = '', pageIndex = 0, limit = 10) { this.journalEntriesSubject.next([]); + this.filteredSubject.next([]); + + // Extract client-side glAccountClassification filter — not sent to server + const classificationFilter = filterBy.find((f: any) => f.type === 'glAccountClassification'); + this.accountTypeFilter = classificationFilter?.value || ''; + const serverFilters = filterBy.filter((f: any) => f.type !== 'glAccountClassification'); + orderBy = orderBy === 'debit' || orderBy === 'credit' ? 'amount' : orderBy; + + // Always request running balance and transaction details for the entries table + const runningBalanceFilter = { type: 'runningBalance', value: 'true' }; + const txnDetailsFilter = { type: 'transactionDetails', value: 'true' }; + const filtersWithExtras = [ + ...serverFilters, + runningBalanceFilter, + txnDetailsFilter + ]; + this.accountingService - .getJournalEntries(filterBy, orderBy, sortOrder, pageIndex * limit, limit) + .getJournalEntries(filtersWithExtras, orderBy, sortOrder, pageIndex * limit, limit) .subscribe((journalEntries: any) => { - this.recordsSubject.next(journalEntries.totalFilteredRecords); - this.journalEntriesSubject.next(journalEntries.pageItems); + const all: any[] = journalEntries.pageItems || []; + this.journalEntriesSubject.next(all); + + // Client-side account type filter + const filtered = this.accountTypeFilter + ? all.filter((e) => e.glAccountType?.value === this.accountTypeFilter) + : all; + + this.recordsSubject.next(this.accountTypeFilter ? filtered.length : journalEntries.totalFilteredRecords); + this.filteredSubject.next(filtered); }); } - /** - * @param {CollectionViewer} collectionViewer - */ + /** Apply account type filter on already-loaded data without re-fetching */ + applyAccountTypeFilter(type: string) { + this.accountTypeFilter = type; + const all = this.journalEntriesSubject.getValue(); + const filtered = type ? all.filter((e) => e.glAccountType?.value === type) : all; + this.recordsSubject.next(filtered.length || (type ? 0 : this.recordsSubject.getValue())); + this.filteredSubject.next(filtered); + } + connect(collectionViewer: CollectionViewer): Observable { - return this.journalEntriesSubject.asObservable(); + return this.filteredSubject.asObservable(); } - /** - * @param {CollectionViewer} collectionViewer - */ disconnect(collectionViewer: CollectionViewer): void { this.journalEntriesSubject.complete(); + this.filteredSubject.complete(); this.recordsSubject.complete(); } } diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.html b/src/app/accounting/search-journal-entry/search-journal-entry.component.html index 6a6dbdfbd..e2a190b5a 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.html +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.html @@ -6,179 +6,715 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- - {{ 'labels.inputs.Office Name' | translate }} - - + +
+
+
+
+
+ +
+
+
+
+ + Accounting + + General Ledger +
+

General Ledger

+

+ Full double-entry journal — view, filter & analyse all accounting entries +

+
+ + + {{ glAccountInfo.type?.value }} + + {{ glAccountInfo.glCode }} + · + {{ glAccountInfo.name }} +
+
+
- - - - - {{ 'labels.inputs.Filter' | translate }} - - @for (filter of entryTypeFilterData; track filter) { - - {{ filter.option | translate }} - - } - - - - - {{ 'labels.inputs.Transaction Date From' | translate }} - - - - - - - {{ 'labels.inputs.Transaction Date To' | translate }} - - - - - - - {{ 'labels.inputs.Transaction ID' | translate }} - - - - - {{ 'labels.inputs.Submitted on Date From' | translate }} - - - - - - - {{ 'labels.inputs.Submitted on Date To' | translate }} - - - - +
+
+ + {{ formatLedgerDate(ledgerData?.fromDate) }} → {{ formatLedgerDate(ledgerData?.toDate) }} +
+ + +
+
- - - @for (office of filteredOfficeData | async; track office) { - - {{ office.name }} - - } - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Entry ID' | translate }}{{ journalEntry.id }}{{ 'labels.inputs.Office' | translate }}{{ journalEntry.officeName }}{{ 'labels.inputs.Transaction ID' | translate }}{{ journalEntry.transactionId }}{{ 'labels.inputs.Transaction Date' | translate }}{{ journalEntry.transactionDate | dateFormat }}{{ 'labels.inputs.Type' | translate }}{{ journalEntry.glAccountType.value }}{{ 'labels.inputs.Created By' | translate }}{{ journalEntry.createdByUserName }}{{ 'labels.inputs.Submitted on date' | translate }}{{ journalEntry.submittedOnDate | datetimeFormat }}{{ 'labels.inputs.Account Code' | translate }}{{ journalEntry.glAccountCode }}{{ 'labels.inputs.Account Name' | translate }}{{ journalEntry.glAccountName }}{{ 'labels.inputs.Currency' | translate }}{{ journalEntry.currency.code }}{{ 'labels.inputs.Debit' | translate }} - @if (journalEntry.entryType.value === 'DEBIT') { - {{ journalEntry.amount | formatNumber }} - } - {{ 'labels.inputs.Credit' | translate }} - @if (journalEntry.entryType.value === 'CREDIT') { - {{ journalEntry.amount | formatNumber }} - } -
- - + +
+
+
+ + +
+
+
Account Code
+
{{ glAccountInfo.glCode }}
+
+
+
+
Account Name
+
{{ glAccountInfo.name }}
+
+
+
+
Classification
+
+ + + {{ glAccountInfo.type?.value }} + +
+
+
+
+
Usage
+
{{ glAccountInfo.usage?.value || '—' }}
+
+
+
+
Manual Entries
+
+ + + {{ glAccountInfo.manualEntriesAllowed ? 'Allowed' : 'Restricted' }} + +
+
+
+
+
Description
+
{{ glAccountInfo.description }}
+
+
+ + +
+ +
+
+
+ +
+
+ +
+
+
+
Opening Balance
+
+ {{ openingBalance | formatNumber }} +
+
Before {{ formatLedgerDate(ledgerData?.fromDate) || 'period start' }}
+
+
+
+ + +
+
+
+ +
+
+ DR +
+
+
+
Total Debit
+
{{ totalDebit | formatNumber }}
+
Debit entries in period
+
+
+
+ + +
+
+
+ +
+
+ CR +
+
+
+
Total Credit
+
{{ totalCredit | formatNumber }}
+
Credit entries in period
+
+
+
+ + +
+
+
+ +
+
+ + {{ isDebitNormal ? 'DR−CR' : 'CR−DR' }} +
+
+
+
Net Movement
+
+ {{ netMovement | formatNumber }} +
+
+ {{ netMovementPositive ? 'Net Positive' : 'Net Negative' }} · + {{ isDebitNormal ? 'Debit Normal' : 'Credit Normal' }} +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
Closing Balance
+
+ {{ closingBalance | formatNumber }} +
+
As of {{ formatLedgerDate(ledgerData?.toDate) || 'period end' }}
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
Total Entries
+
{{ totalEntries || (dataSource?.records$ | async) || 0 }}
+
Matching journal entries
+
+
+
+
+ + +
+
Quick Range
+
+ + +
+
+ + +
+ +
+
+ + + + + + +
+ +
+ + + + + + +
+ +
+ +
+ +
+
+ +
+ + + + {{ et.option }} + + +
+ +
+ + +
+
+ + +
+
+
+ + + + + {{ o.name }} + + +
+ +
+ + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+
+
+ + +
+
+
+ Select a GL Account to unlock full ledger details — opening balance, day-wise running balance, + closing balance, and per-entry running totals. +
+
+ + +
+
+
+
+ Day-wise Balance Timeline + {{ dayWiseData.length }} days +
+
+ Opening + Debit + Credit + Net + Closing +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateOpening BalDebit (DR)Credit (CR)MovementNetClosing BalEntries
+
+
+
{{ formatLedgerDate(row.date) }}
+
+
{{ row.openingBalance | formatNumber }} + {{ row.totalDebit | formatNumber }} + — + + {{ row.totalCredit | formatNumber }} + — + +
+
+
+
+
+ + {{ row.netMovement | formatNumber }} + + {{ row.closingBalance | formatNumber }} + + {{ row.entryCount }} +
+
+
+ + +
+
+
+
+ Journal Entries + {{ (dataSource?.records$ | async) || 0 }} +
+
+
+ + DR {{ totalDebit | formatNumber }} + +
+ + CR {{ totalCredit | formatNumber }} + +
+
+
+ +
+ + + + # + + #{{ e.id }} + + + + + + Date + +
+
{{ formatLedgerDate(e.transactionDate) }}
+
+
+
+ + + + Txn ID + + + {{ e.transactionId }} + + + + + + + Office + +
+ + {{ e.officeName }} +
+
+
+ + + + Code + + {{ e.glAccountCode }} + + + + + + Account + + {{ e.glAccountName }} + + + + + + Type + + + + {{ e.glAccountType?.value }} + + + + + + + Type + + + {{ e.entryType?.value === 'DEBIT' ? 'DR' : 'CR' }} + + + + + + + Debit + + {{ e.amount | formatNumber }} + — + + + + + + Credit + + {{ e.amount | formatNumber }} + — + + + + + + Running Bal + + + {{ getRunningBalance(e) | formatNumber }} + + + + + + + Source + + + + {{ e.manualEntry ? 'Manual' : 'System' }} + + + + + + + By + +
+
{{ (e.createdByUserName || 'U').charAt(0).toUpperCase() }}
+ {{ e.createdByUserName }} +
+
+
+ + + + Submitted + + {{ e.submittedOnDate | datetimeFormat }} + + + + + + + + + + + + + + + + + +
+
+
No entries found
+
Try adjusting your filters or date range
+
+ + +
+ +
+ +
+
diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.scss b/src/app/accounting/search-journal-entry/search-journal-entry.component.scss index c6a038de4..8f7ad4359 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.scss +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.scss @@ -6,36 +6,1469 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -table { - width: 100%; - box-shadow: 0 2px 4px rgb(0 0 0 / 10%); - border: 1px solid var(--border-color, #ddd); - border-radius: 8px; - transition: - background-color 0.3s ease, - border-color 0.3s ease; +// ── Design tokens ───────────────────────────────────────────────────────────── +:host { + --c-bg: #f0f2f8; + --c-surface: #fff; + --c-border: #e2e8f0; + --c-text-1: #0f172a; + --c-text-2: #475569; + --c-text-3: #94a3b8; + --c-primary: #4f46e5; + --c-primary-h: #6366f1; + --c-dr: #ef4444; + --c-dr-bg: #fef2f2; + --c-cr: #10b981; + --c-cr-bg: #ecfdf5; + --c-opening: #6366f1; + --c-closing: #0891b2; + --c-net-pos: #10b981; + --c-net-neg: #ef4444; + --c-warn: #f59e0b; + --c-purple: #7c3aed; + --shadow-sm: 0 1px 3px rgb(0 0 0 / 8%), 0 1px 2px rgb(0 0 0 / 5%); + --shadow-md: 0 4px 12px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 4%); + --shadow-lg: 0 10px 30px rgb(0 0 0 / 10%); + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 16px; + --transition: 220ms cubic-bezier(0.4, 0, 0.2, 1); +} - .select-row:hover { - cursor: pointer; +// ── HERO ────────────────────────────────────────────────────────────────────── +.gl-hero { + position: relative; + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 24px; + min-height: 120px; + + &__bg { + position: absolute; + inset: 0; + background: linear-gradient(135deg, #1e1b4b 0%, #312e81 40%, #4338ca 70%, #6366f1 100%); + + &::after { + content: ''; + position: absolute; + inset: 0; + background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); + } + } + + &__content { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + padding: 28px 32px; + gap: 20px; + flex-wrap: wrap; + } + + &__left { + display: flex; + align-items: center; + gap: 18px; + } + + &__icon-wrap { + position: relative; + width: 56px; + height: 56px; + background: rgb(255 255 255 / 15%); + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(10px); + border: 1px solid rgb(255 255 255 / 20%); + flex-shrink: 0; + } + + &__icon { + font-size: 24px; + color: #fff; + } + + &__icon-pulse { + position: absolute; + inset: -4px; + border-radius: 20px; + border: 2px solid rgb(255 255 255 / 15%); + animation: pulse 2.5s ease infinite; + } + + &__text { + color: #fff; + } + + &__breadcrumb { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + opacity: 0.65; + margin-bottom: 6px; + letter-spacing: 0.03em; + } + + &__bc-icon { + font-size: 0.65rem; + } + + &__bc-sep { + font-size: 0.6rem; + opacity: 0.5; + } + + &__bc-active { + opacity: 1; + font-weight: 600; + } + + &__title { + margin: 0 0 6px; + font-size: 1.6rem; + font-weight: 800; + letter-spacing: -0.02em; + } + + &__subtitle { + margin: 0; + font-size: 0.88rem; + opacity: 0.7; + } + + &__acct-pill { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + opacity: 0.9; + flex-wrap: wrap; + } + + &__acct-code { + font-family: 'Courier New', monospace; + font-weight: 700; + font-size: 0.85rem; + } + + &__acct-sep { + opacity: 0.4; + } + + &__acct-name { + font-size: 0.85rem; + } + + &__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + &__meta { + display: flex; + align-items: center; + gap: 6px; + color: rgb(255 255 255 / 70%); + font-size: 0.78rem; + background: rgb(255 255 255 / 8%); + border-radius: 20px; + padding: 5px 12px; + border: 1px solid rgb(255 255 255 / 12%); + + &-icon { + font-size: 0.72rem; + } } } -.filter-container { - margin-bottom: 1rem; - padding: 1.5rem; - box-shadow: 0 2px 4px rgb(0 0 0 / 10%); - border: 1px solid var(--border-color, #ddd); - border-radius: 8px; - transition: - background-color 0.3s ease, - border-color 0.3s ease; +@keyframes pulse { + 0%, + 100% { + transform: scale(1); + opacity: 0.8; + } + + 50% { + transform: scale(1.08); + opacity: 0.3; + } } -.table-container { - box-shadow: 0 2px 4px rgb(0 0 0 / 10%); - border: 1px solid var(--border-color, #ddd); +.gl-hero-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 18px; + border-radius: 10px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + border: none; + transition: all var(--transition); + white-space: nowrap; + + &--ghost { + background: rgb(255 255 255 / 12%); + color: #fff; + border: 1px solid rgb(255 255 255 / 25%); + + &:hover { + background: rgb(255 255 255 / 20%); + } + } + + &--primary { + background: #fff; + color: var(--c-primary); + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); + + &:hover { + background: #f0f2ff; + transform: translateY(-1px); + box-shadow: 0 6px 16px rgb(0 0 0 / 20%); + } + } +} + +// ── Progress bar ────────────────────────────────────────────────────────────── +.gl-progress-bar { + height: 3px; + background: var(--c-border); + border-radius: 3px; + margin-bottom: 20px; + overflow: hidden; + + &__fill { + height: 100%; + background: linear-gradient(90deg, var(--c-primary), var(--c-primary-h), var(--c-cr)); + animation: progress 1.4s ease infinite; + width: 40%; + } +} + +@keyframes progress { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(350%); + } +} + +// ── Account strip ───────────────────────────────────────────────────────────── +.gl-acct-strip { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0; + background: var(--c-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + border: 1px solid var(--c-border); + padding: 14px 24px; + margin-bottom: 20px; + overflow: hidden; + + &__item { + display: flex; + flex-direction: column; + gap: 3px; + padding: 0 20px; + + &:first-child { + padding-left: 0; + } + + &--desc { + flex: 1; + } + } + + &__divider { + width: 1px; + height: 36px; + background: var(--c-border); + flex-shrink: 0; + } + + &__label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--c-text-3); + font-weight: 600; + } + + &__value { + font-size: 0.88rem; + color: var(--c-text-1); + font-weight: 500; + + &--muted { + color: var(--c-text-2); + font-size: 0.82rem; + } + } +} + +// ── GL Type pills ───────────────────────────────────────────────────────────── +.gl-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + border: 1px solid transparent; + + &__icon { + font-size: 0.68rem; + } + + &--sm { + padding: 2px 8px; + font-size: 0.68rem; + } + + &--asset { + background: #eff6ff; + color: #1d4ed8; + border-color: #bfdbfe; + } + + &--liability { + background: #f5f3ff; + color: #6d28d9; + border-color: #ddd6fe; + } + + &--income { + background: #ecfdf5; + color: #065f46; + border-color: #a7f3d0; + } + + &--expense { + background: #fef2f2; + color: #991b1b; + border-color: #fecaca; + } + + &--equity { + background: #fff7ed; + color: #c2410c; + border-color: #fed7aa; + } + + &--default { + background: #f8fafc; + color: #475569; + border-color: #e2e8f0; + } +} + +.gl-status { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.78rem; + font-weight: 600; + + &--on { + color: var(--c-cr); + } + + &--off { + color: var(--c-text-3); + } +} + +// ── KPI cards ───────────────────────────────────────────────────────────────── +.gl-kpi-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 10px; + margin-bottom: 16px; + transition: opacity var(--transition); + + &--loading { + opacity: 0.55; + pointer-events: none; + } + + @media (width <= 1400px) { + grid-template-columns: repeat(3, 1fr); + } + + @media (width <= 900px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (width <= 560px) { + grid-template-columns: 1fr; + } +} + +.gl-kpi { + background: var(--c-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + border: 1px solid var(--c-border); + padding: 12px 14px 0; + display: flex; + flex-direction: column; + transition: + transform var(--transition), + box-shadow var(--transition); + overflow: hidden; + position: relative; + cursor: default; + + &:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + } + + &__icon-box { + width: 30px; + height: 30px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + color: #fff; + } + + &__trend { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 0.6rem; + font-weight: 700; + padding: 2px 6px; + border-radius: 20px; + + &--pos { + background: #ecfdf5; + color: var(--c-cr); + } + + &--neg { + background: #fef2f2; + color: var(--c-dr); + } + + &--dr { + background: #fef2f2; + color: var(--c-dr); + } + + &--cr { + background: #ecfdf5; + color: var(--c-cr); + } + + &--neutral { + background: #f1f5f9; + color: var(--c-text-3); + } + } + + &__norm-tag { + font-family: 'Courier New', monospace; + } + + &__body { + flex: 1; + } + + &__label { + font-size: 0.63rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--c-text-3); + font-weight: 600; + margin-bottom: 2px; + } + + &__value { + font-size: 1.1rem; + font-weight: 800; + color: var(--c-text-1); + font-family: 'Courier New', monospace; + letter-spacing: -0.02em; + margin-bottom: 2px; + line-height: 1.2; + + &--dr { + color: var(--c-dr); + } + + &--cr { + color: var(--c-cr); + } + + &--pos { + color: var(--c-net-pos); + } + + &--neg { + color: var(--c-net-neg); + } + + &--count { + color: var(--c-primary); + } + } + + &__caption { + font-size: 0.63rem; + color: var(--c-text-3); + margin-bottom: 10px; + } + + &__bar { + height: 3px; + margin: 0 -14px; + border-radius: 0; + } + + // Color variants + &--opening { + .gl-kpi__icon-box { + background: linear-gradient(135deg, #6366f1, #818cf8); + } + + .gl-kpi__bar--opening { + background: linear-gradient(90deg, #6366f1, #818cf8); + } + } + + &--dr { + .gl-kpi__icon-box { + background: linear-gradient(135deg, #ef4444, #f87171); + } + + .gl-kpi__bar--dr { + background: linear-gradient(90deg, #ef4444, #f87171); + } + } + + &--cr { + .gl-kpi__icon-box { + background: linear-gradient(135deg, #10b981, #34d399); + } + + .gl-kpi__bar--cr { + background: linear-gradient(90deg, #10b981, #34d399); + } + } + + &--net { + .gl-kpi__icon-box { + background: linear-gradient(135deg, #7c3aed, #a78bfa); + } + + .gl-kpi__bar--net { + background: linear-gradient(90deg, #7c3aed, #a78bfa); + } + } + + &--closing { + .gl-kpi__icon-box { + background: linear-gradient(135deg, #0891b2, #22d3ee); + } + + .gl-kpi__bar--closing { + background: linear-gradient(90deg, #0891b2, #22d3ee); + } + } + + &--count { + .gl-kpi__icon-box { + background: linear-gradient(135deg, #f59e0b, #fcd34d); + } + + .gl-kpi__bar--count { + background: linear-gradient(90deg, #f59e0b, #fcd34d); + } + } +} + +// ── Quick date chips ────────────────────────────────────────────────────────── +.gl-quick-bar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; + + &__label { + font-size: 0.75rem; + font-weight: 700; + color: var(--c-text-3); + text-transform: uppercase; + letter-spacing: 0.07em; + white-space: nowrap; + display: flex; + align-items: center; + gap: 5px; + } + + &__chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + } +} + +.gl-qchip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 14px; + border-radius: 20px; + font-size: 0.77rem; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--c-border); + background: var(--c-surface); + color: var(--c-text-2); + transition: all var(--transition); + + &:hover { + border-color: var(--c-primary); + color: var(--c-primary); + background: #f5f3ff; + } + + &.active { + background: var(--c-primary); + color: #fff; + border-color: var(--c-primary); + box-shadow: 0 2px 8px rgb(79 70 229 / 35%); + } + + &--clear { + border-color: #fecaca; + color: var(--c-dr); + + &:hover { + background: var(--c-dr-bg); + border-color: var(--c-dr); + } + } +} + +// ── Filter panel ────────────────────────────────────────────────────────────── +.gl-filter-panel { + background: var(--c-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + border: 1px solid var(--c-border); + padding: 20px 24px; + margin-bottom: 24px; + + &__primary { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: flex-end; + } + + &__advanced { + max-height: 0; + overflow: hidden; + transition: + max-height 0.3s ease, + padding 0.3s ease; + + &--open { + max-height: 200px; + padding-top: 16px; + margin-top: 16px; + border-top: 1px dashed var(--c-border); + } + } + + &__adv-inner { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: flex-end; + } +} + +.gl-fgroup { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 160px; + max-width: 220px; + + &--wide { + max-width: 300px; + } +} + +.gl-flabel { + font-size: 0.72rem; + font-weight: 700; + color: var(--c-text-3); + text-transform: uppercase; + letter-spacing: 0.07em; + display: flex; + align-items: center; + gap: 5px; +} + +.gl-ffield { + width: 100%; +} + +.gl-ffield--selector { + mifosx-gl-account-selector { + display: block; + width: 100%; + } + + mifosx-gl-account-selector ::ng-deep mat-form-field { + width: 100%; + } +} + +.gl-field-suffix-icon { + color: var(--c-text-3); + margin-right: 8px; + font-size: 0.85rem; +} + +.gl-filter-actions { + display: flex; + align-items: flex-end; + gap: 8px; + padding-bottom: 4px; +} + +.gl-fab { + width: 40px; + height: 40px; + border-radius: 10px; + border: 1px solid var(--c-border); + background: var(--c-surface); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all var(--transition); + position: relative; + color: var(--c-text-2); + + &:hover { + border-color: var(--c-primary); + color: var(--c-primary); + background: #f5f3ff; + } + + &--adv-on { + background: var(--c-primary); + color: #fff; + border-color: var(--c-primary); + } + + &--reset { + &:hover { + border-color: var(--c-dr); + color: var(--c-dr); + background: var(--c-dr-bg); + } + } + + &__badge { + position: absolute; + top: -6px; + right: -6px; + background: var(--c-warn); + color: #fff; + border-radius: 50%; + width: 16px; + height: 16px; + font-size: 0.6rem; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + } +} + +// ── Tip card ────────────────────────────────────────────────────────────────── +.gl-tip { + display: flex; + align-items: flex-start; + gap: 12px; + background: linear-gradient(135deg, #eff6ff, #f5f3ff); + border: 1px solid #bfdbfe; + border-radius: var(--radius-md); + padding: 14px 20px; + margin-bottom: 20px; + font-size: 0.85rem; + color: var(--c-text-2); + + &__icon { + color: #3b82f6; + font-size: 1.1rem; + margin-top: 2px; + flex-shrink: 0; + } + + &__body { + line-height: 1.5; + } +} + +// ── Section header ──────────────────────────────────────────────────────────── +.gl-section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + flex-wrap: wrap; + gap: 10px; + + &__left { + display: flex; + align-items: center; + gap: 10px; + } + + &__right { + display: flex; + align-items: center; + gap: 8px; + } + + &__dot { + width: 10px; + height: 10px; + border-radius: 50%; + + &--purple { + background: var(--c-purple); + box-shadow: 0 0 0 3px rgb(124 58 237 / 15%); + } + + &--blue { + background: var(--c-primary); + box-shadow: 0 0 0 3px rgb(79 70 229 / 15%); + } + } + + &__title { + font-size: 0.95rem; + font-weight: 700; + color: var(--c-text-1); + } + + &__badge { + background: var(--c-primary); + color: #fff; + border-radius: 20px; + padding: 2px 8px; + font-size: 0.7rem; + font-weight: 700; + } +} + +.gl-legend-pill { + font-size: 0.68rem; + font-weight: 700; + padding: 2px 8px; + border-radius: 4px; + letter-spacing: 0.04em; + + &--open { + background: #eef2ff; + color: #4338ca; + } + + &--dr { + background: var(--c-dr-bg); + color: var(--c-dr); + } + + &--cr { + background: var(--c-cr-bg); + color: #065f46; + } + + &--net { + background: #f5f3ff; + color: var(--c-purple); + } + + &--close { + background: #ecfeff; + color: #0e7490; + } +} + +// ── Day-wise timeline table ─────────────────────────────────────────────────── +.gl-timeline-section { + margin-bottom: 24px; +} + +.gl-timeline-wrap { + background: var(--c-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + border: 1px solid var(--c-border); + overflow: hidden; +} + +.gl-tl-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; + + thead { + background: linear-gradient(90deg, #1e1b4b, #312e81); + } + + .gl-tl-th { + padding: 8px 12px; + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgb(255 255 255 / 80%); + text-align: left; + white-space: nowrap; + + &--num { + text-align: right; + } + + &--dr { + color: #fca5a5; + } + + &--cr { + color: #6ee7b7; + } + + &--close { + color: #67e8f9; + } + + &--bar { + min-width: 90px; + } + + &--ctr { + text-align: center; + } + } + + .gl-tl-row { + border-bottom: 1px solid #f1f5f9; + transition: background var(--transition); + + &:hover { + background: #f8f9ff; + } + + &--first td { + font-weight: 500; + } + + &--last { + border-bottom: none; + } + } + + .gl-tl-footer { + background: #f8fafc; + border-top: 2px solid var(--c-border); + + &__label { + font-weight: 700; + padding: 7px 12px; + color: var(--c-text-1); + font-size: 0.74rem; + } + } +} + +.gl-tl-date { + padding: 7px 12px; + + &__inner { + display: flex; + align-items: center; + gap: 8px; + } + + &__dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + + &--up { + background: var(--c-cr); + box-shadow: 0 0 0 2px rgb(16 185 129 / 20%); + } + + &--down { + background: var(--c-dr); + box-shadow: 0 0 0 2px rgb(239 68 68 / 20%); + } + } + + &__text { + font-size: 0.75rem; + font-weight: 600; + color: var(--c-text-1); + white-space: nowrap; + } +} + +.gl-tl-num { + padding: 7px 12px; + text-align: right; + font-family: 'Courier New', monospace; + font-size: 0.75rem; + color: var(--c-text-2); + white-space: nowrap; + + &--dr { + color: var(--c-dr); + font-weight: 600; + } + + &--cr { + color: var(--c-cr); + font-weight: 600; + } + + &--pos { + color: var(--c-cr); + font-weight: 600; + } + + &--neg { + color: var(--c-dr); + font-weight: 600; + } + + &--close { + color: #0891b2; + font-weight: 600; + } +} + +.gl-tl-nil { + color: var(--c-text-3); + font-size: 0.7rem; +} + +.gl-tl-bar-cell { + padding: 7px 12px; +} + +.gl-minibar { + height: 8px; + background: #f1f5f9; + border-radius: 4px; + display: flex; + overflow: hidden; + gap: 2px; + min-width: 120px; + + &__dr { + height: 100%; + background: linear-gradient(90deg, #ef4444, #f87171); + border-radius: 4px 0 0 4px; + min-width: 0; + transition: flex 0.5s ease; + } + + &__cr { + height: 100%; + background: linear-gradient(90deg, #10b981, #34d399); + border-radius: 0 4px 4px 0; + min-width: 0; + transition: flex 0.5s ease; + } +} + +.gl-tl-net-icon { + font-size: 0.65rem; + margin-right: 2px; +} + +.gl-tl-ctr { + text-align: center; + padding: 7px 12px; +} + +.gl-tl-badge { + background: #eef2ff; + color: var(--c-primary); border-radius: 8px; - transition: - background-color 0.3s ease, - border-color 0.3s ease; + padding: 1px 7px; + font-size: 0.65rem; + font-weight: 700; + + &--total { + background: var(--c-primary); + color: #fff; + } +} + +// ── Entries section ─────────────────────────────────────────────────────────── +.gl-entries-section { + margin-top: 24px; +} + +.gl-entries-totals { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.72rem; + + &__dr { + display: flex; + align-items: center; + gap: 5px; + color: var(--c-dr); + font-weight: 600; + } + + &__cr { + display: flex; + align-items: center; + gap: 5px; + color: var(--c-cr); + font-weight: 600; + } + + &__sep { + width: 1px; + height: 16px; + background: var(--c-border); + } +} + +.gl-table-shell { + background: var(--c-surface); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + border: 1px solid var(--c-border); + overflow: auto; + max-height: 620px; +} + +.gl-mat-table { + width: 100%; + + .gl-header-row { + background: #fafafa; + border-bottom: 2px solid var(--c-border); + } + + .gl-th { + font-size: 0.63rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--c-text-3); + padding: 7px 10px; + + &--num { + text-align: right !important; + } + } + + .gl-data-row { + border-bottom: 1px solid #f8fafc; + transition: background var(--transition); + + &:hover { + background: #f8f9ff !important; + } + + &--dr { + border-left: 3px solid rgb(239 68 68 / 25%) !important; + } + + &--cr { + border-left: 3px solid rgb(16 185 129 / 25%) !important; + } + } + + .gl-td { + padding: 5px 10px !important; + vertical-align: middle !important; + font-size: 0.74rem; + + &--num { + text-align: right !important; + } + } +} + +// ── Table cell components ───────────────────────────────────────────────────── +.gl-id-link { + font-family: 'Courier New', monospace; + font-size: 0.72rem; + font-weight: 700; + color: var(--c-primary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.gl-date-cell { + &__main { + font-size: 0.73rem; + font-weight: 600; + color: var(--c-text-1); + white-space: nowrap; + } +} + +.gl-txn-link { + font-size: 0.72rem; + font-weight: 600; + color: var(--c-primary); + text-decoration: none; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + vertical-align: middle; + + &:hover { + text-decoration: underline; + } +} + +.gl-mono { + font-family: 'Courier New', monospace; +} + +.gl-office-cell { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.73rem; + color: var(--c-text-2); +} + +.gl-office-icon { + color: var(--c-text-3); + font-size: 0.68rem; +} + +.gl-code-chip { + display: inline-block; + padding: 1px 6px; + background: #f1f5f9; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 0.68rem; + color: var(--c-text-2); + font-weight: 600; + border: 1px solid var(--c-border); +} + +.gl-acct-cell { + font-size: 0.74rem; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + color: var(--c-text-1); +} + +.gl-drcr { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 18px; + border-radius: 4px; + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.03em; + + &--dr { + background: var(--c-dr-bg); + color: var(--c-dr); + border: 1px solid #fecaca; + } + + &--cr { + background: var(--c-cr-bg); + color: #065f46; + border: 1px solid #a7f3d0; + } +} + +.gl-amt { + font-family: 'Courier New', monospace; + font-size: 0.77rem; + font-weight: 700; + + &--dr { + color: var(--c-dr); + } + + &--cr { + color: var(--c-cr); + } +} + +.gl-amt-nil { + color: var(--c-text-3); + font-size: 0.7rem; +} + +.gl-running { + font-family: 'Courier New', monospace; + font-size: 0.73rem; + font-weight: 600; + color: #0891b2; + + &--neg { + color: var(--c-dr); + } +} + +.gl-src { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 6px; + border-radius: 4px; + font-size: 0.65rem; + font-weight: 600; + + &--manual { + background: #fff7ed; + color: #c2410c; + border: 1px solid #fed7aa; + } + + &--system { + background: #eff6ff; + color: #1d4ed8; + border: 1px solid #bfdbfe; + } +} + +.gl-user-cell { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.73rem; + color: var(--c-text-2); +} + +.gl-user-avatar { + width: 22px; + height: 22px; + border-radius: 50%; + background: linear-gradient(135deg, var(--c-primary), var(--c-primary-h)); + color: #fff; + font-size: 0.6rem; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.gl-ts { + font-size: 0.65rem; + color: var(--c-text-3); + white-space: nowrap; +} + +.gl-view-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + color: var(--c-primary); + text-decoration: none; + background: transparent; + transition: all var(--transition); + + &:hover { + background: #eef2ff; + } +} + +// ── Empty state ─────────────────────────────────────────────────────────────── +.gl-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + width: 100%; + text-align: center; + + &__icon { + font-size: 2.5rem; + color: #e2e8f0; + margin-bottom: 10px; + } + + &__title { + font-size: 0.85rem; + font-weight: 700; + color: var(--c-text-2); + margin-bottom: 4px; + } + + &__sub { + font-size: 0.74rem; + color: var(--c-text-3); + } +} + +// ── Paginator ───────────────────────────────────────────────────────────────── +.gl-pager-wrap { + border-top: 1px solid var(--c-border); + background: #fafbfe; } 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 be72be648..f925afe36 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 @@ -6,26 +6,20 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** Angular Imports */ -import { Component, OnInit, ViewChild, AfterViewInit, inject } from '@angular/core'; +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, ReactiveFormsModule } from '@angular/forms'; +import { UntypedFormControl } from '@angular/forms'; import { ActivatedRoute, RouterLink } from '@angular/router'; - -/** rxjs Imports */ import { merge } from 'rxjs'; import { tap, startWith, map, distinctUntilChanged, debounceTime } from 'rxjs/operators'; - -/** Custom Services */ import { AccountingService } from '../accounting.service'; import { SettingsService } from 'app/settings/settings.service'; -/** Custom Data Source */ import { JournalEntriesDataSource } from './journal-entry.datasource'; import { Dates } from 'app/core/utils/dates'; -import { MatAutocompleteTrigger, MatOption, MatAutocomplete } from '@angular/material/autocomplete'; +import { MatAutocompleteTrigger, MatAutocomplete } from '@angular/material/autocomplete'; import { GlAccountSelectorComponent } from '../../shared/accounting/gl-account-selector/gl-account-selector.component'; -import { AsyncPipe } from '@angular/common'; +import { AsyncPipe, NgClass, NgIf, NgFor, DecimalPipe, CurrencyPipe } from '@angular/common'; import { MatTable, MatColumnDef, @@ -36,18 +30,23 @@ import { MatHeaderRowDef, MatHeaderRow, MatRowDef, - MatRow + 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'; -/** - * Search journal entry component. - */ @Component({ selector: 'mifosx-search-journal-entry', + standalone: true, templateUrl: './search-journal-entry.component.html', styleUrls: ['./search-journal-entry.component.scss'], imports: [ @@ -67,97 +66,120 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatHeaderRow, MatRowDef, MatRow, + MatNoDataRow, MatPaginator, AsyncPipe, + NgClass, + NgIf, + NgFor, DateFormatPipe, DatetimeFormatPipe, - FormatNumberPipe + FormatNumberPipe, + FaIconComponent, + MatCardModule, + MatTooltipModule, + MatIconModule, + RouterLink ] }) -export class SearchJournalEntryComponent implements OnInit, AfterViewInit { +export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterViewInit { private accountingService = inject(AccountingService); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); private route = inject(ActivatedRoute); - /** Minimum transaction date allowed. */ + // ── Misc ────────────────────────────────────────────────────────────────── minDate = new Date(2000, 0, 1); - /** Maximum transaction date allowed. */ maxDate = new Date(); - /** Office name filter form control. */ + + // ── Filter controls ─────────────────────────────────────────────────────── officeName = new UntypedFormControl(); - /** Office data. */ officeData: any; - /** Filtered office data for autocomplete. */ filteredOfficeData: any; - /** Gl Account filter form control. */ glAccount = new UntypedFormControl(); - /** Gl Account data. */ glAccountData: any; - /** Filtered gl account data. */ - filteredGLAccountData: any; - /** Entry type filter form control. */ entryTypeFilter = new UntypedFormControl(''); - /** Entry type filter data. */ - entryTypeFilterData = [ - { - option: 'labels.inputs.All', - value: '' - }, - { - option: 'labels.inputs.Manual Entries', - value: true - }, - { - option: 'labels.inputs.System Entries', - value: false // Bug: unable to implement from server side - } - ]; - /** Transaction date from form control. */ transactionDateFrom = new UntypedFormControl(new Date(new Date().setMonth(new Date().getMonth() - 1))); - /** Transaction date to form control. */ transactionDateTo = new UntypedFormControl(new Date()); - /** Transaction ID form control. */ - transactionId = new UntypedFormControl(); - /** Submitted on date from form control. */ + transactionIdCtrl = new UntypedFormControl(); submittedOnDateFrom = new UntypedFormControl(); - /** Submitted on date to form control. */ submittedOnDateTo = new UntypedFormControl(); - /** Columns to be displayed in journal entries table. */ + referenceNumber = new UntypedFormControl(); + + entryTypeFilterData = [ + { option: 'All Entries', value: '' }, + { option: 'Manual Entries', value: 'true' }, + { option: 'System Entries', value: 'false' } + ]; + + // ── Table columns ───────────────────────────────────────────────────────── displayedColumns: string[] = [ 'id', - 'officeName', - 'transactionId', 'transactionDate', - 'glAccountType', - 'createdByUserName', - 'submittedOnDate', + 'transactionId', + 'officeName', 'glAccountCode', 'glAccountName', - 'currency', + 'glAccountType', + 'entryType', 'debit', - 'credit' + 'credit', + 'runningBalance', + 'manualEntry', + 'createdByUserName', + 'submittedOnDate', + 'actions' ]; - /** Data source for journal entries table. */ + dayWiseColumns = [ + 'date', + 'openingBalance', + 'totalDebit', + 'totalCredit', + 'netMovement', + 'closingBalance', + 'entryCount' + ]; + dataSource: JournalEntriesDataSource; - /** Journal entries filter. */ + + // ── 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: 'officeId', value: '' }, + { type: 'glAccountId', value: '' }, + { type: 'manualEntriesOnly', value: '' }, + { type: 'transactionId', value: '' }, { type: 'fromDate', value: this.dateUtils.formatDate( @@ -165,39 +187,16 @@ export class SearchJournalEntryComponent implements OnInit, AfterViewInit { 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 - } + { 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 } ]; - /** Paginator for journal entries table. */ @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; - /** Sorter for journal entries table. */ @ViewChild(MatSort, { static: true }) sort: MatSort; - /** - * Retrieves the offices and gl accounts data from `resolve`. - * @param {AccountingService} accountingService Accounting Service. - * @param {ActivatedRoute} route Activated Route. - * @param {SettingsService} settingsService Settings Service. - */ constructor() { this.route.data.subscribe((data: { offices: any; glAccounts: any }) => { this.officeData = data.offices; @@ -205,51 +204,130 @@ export class SearchJournalEntryComponent implements OnInit, AfterViewInit { }); } - /** - * Sets filtered offices and gl accounts for autocomplete and journal entries table. - */ ngOnInit() { this.maxDate = this.settingsService.businessDate; + this.paginator.pageSize = 10; + this.restoreState(); this.setFilteredOffices(); - this.setFilteredGlAccounts(); 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 */ + } } - /** - * Subscribes to all search filters: - * Office Name, GL Account, Transaction ID, Transaction Date From, Transaction Date To, - * sort change and page change. - */ ngAfterViewInit() { this.officeName.valueChanges .pipe( - map((value) => (value.id ? value.id : '')), + map((v) => (v?.id ? String(v.id) : '')), debounceTime(500), distinctUntilChanged(), - tap((filterValue) => { - this.applyFilter(filterValue, 'officeId'); - }) + tap((val) => this.applyFilter(val, 'officeId')) ) .subscribe(); this.glAccount.valueChanges .pipe( - map((value) => (value ? value : '')), debounceTime(500), distinctUntilChanged(), - tap((filterValue) => { - this.applyFilter(filterValue, 'glAccountId'); + 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.transactionId.valueChanges + this.entryTypeFilter.valueChanges .pipe( - debounceTime(500), + debounceTime(300), distinctUntilChanged(), - tap((filterValue) => { - this.applyFilter(filterValue, 'transactionId'); - }) + tap((val) => this.applyFilter(val ?? '', 'manualEntriesOnly')) + ) + .subscribe(); + + this.transactionIdCtrl.valueChanges + .pipe( + debounceTime(600), + distinctUntilChanged(), + tap((val) => this.applyFilter(val ?? '', 'transactionId')) ) .subscribe(); @@ -257,9 +335,7 @@ export class SearchJournalEntryComponent implements OnInit, AfterViewInit { .pipe( debounceTime(500), distinctUntilChanged(), - tap((filterValue) => { - this.applyFilter(this.dateUtils.formatDate(filterValue, this.settingsService.dateFormat), 'fromDate'); - }) + tap((val) => this.applyFilter(this.dateUtils.formatDate(val, this.settingsService.dateFormat), 'fromDate')) ) .subscribe(); @@ -267,9 +343,7 @@ export class SearchJournalEntryComponent implements OnInit, AfterViewInit { .pipe( debounceTime(500), distinctUntilChanged(), - tap((filterValue) => { - this.applyFilter(this.dateUtils.formatDate(filterValue, this.settingsService.dateFormat), 'toDate'); - }) + tap((val) => this.applyFilter(this.dateUtils.formatDate(val, this.settingsService.dateFormat), 'toDate')) ) .subscribe(); @@ -277,12 +351,9 @@ export class SearchJournalEntryComponent implements OnInit, AfterViewInit { .pipe( debounceTime(500), distinctUntilChanged(), - tap((filterValue) => { - this.applyFilter( - this.dateUtils.formatDate(filterValue, this.settingsService.dateFormat), - 'submittedOnDateFrom' - ); - }) + tap((val) => + this.applyFilter(this.dateUtils.formatDate(val, this.settingsService.dateFormat), 'submittedOnDateFrom') + ) ) .subscribe(); @@ -290,106 +361,181 @@ export class SearchJournalEntryComponent implements OnInit, AfterViewInit { .pipe( debounceTime(500), distinctUntilChanged(), - tap((filterValue) => { - this.applyFilter( - this.dateUtils.formatDate(filterValue, this.settingsService.dateFormat), - 'submittedOnDateTo' - ); - }) + 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(); } - /** - * Loads a page of journal entries. - */ + // ── 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.active; + delete (this.sort as any).active; } this.dataSource.getJournalEntries( this.filterJournalEntriesBy, this.sort.active, this.sort.direction, this.paginator.pageIndex, - this.paginator.pageSize + this.paginator.pageSize || 10 ); } - /** - * Filters data in journal entries table based on passed value and poperty. - * @param {string} filterValue Value to filter data. - * @param {string} property Property to filter data by. - */ applyFilter(filterValue: string, property: string) { this.paginator.pageIndex = 0; - const findIndex = this.filterJournalEntriesBy.findIndex((filter) => filter.type === property); - this.filterJournalEntriesBy[findIndex].value = filterValue; + const idx = this.filterJournalEntriesBy.findIndex((f) => f.type === property); + if (idx >= 0) this.filterJournalEntriesBy[idx].value = filterValue; this.loadJournalEntriesPage(); + this.loadLedgerSummary(); } - /** - * Displays office name in form control input. - * @param {any} office Office data. - * @returns {string} Office name if valid otherwise undefined. - */ - displayOfficeName(office?: any): string | undefined { - return office ? office.name : undefined; - } - - /** - * Sets filtered offices for autocomplete. - */ - setFilteredOffices() { - this.filteredOfficeData = this.officeName.valueChanges.pipe( - startWith(''), - map((office: any) => (typeof office === 'string' ? office : office.name)), - map((officeName: string) => (officeName ? this.filterOfficeAutocompleteData(officeName) : this.officeData)) - ); - } - - /** - * Sets filtered gl accounts for autocomplete. - */ - setFilteredGlAccounts() { - this.filteredGLAccountData = this.glAccount.valueChanges.pipe( - startWith(''), - map((glAccount: any) => - typeof glAccount === 'string' ? glAccount : glAccount.name + ' (' + glAccount.glCode + ')' - ), - map((glAccount: string) => (glAccount ? this.filterGLAccountAutocompleteData(glAccount) : this.glAccountData)) - ); - } - - /** - * Filters offices. - * @param {string} officeName Office name to filter office by. - * @returns {any} Filtered offices. - */ - private filterOfficeAutocompleteData(officeName: string): any { - return this.officeData.filter((office: any) => office.name.toLowerCase().includes(officeName.toLowerCase())); - } - - /** - * Filters gl accounts. - * @param {string} glAccount Gl Account name to filter gl account by. - * @returns {any} Filtered gl accounts. - */ - private filterGLAccountAutocompleteData(glAccount: string): any { - return this.glAccountData.filter((option: any) => - (option.name + ' (' + option.glCode + ')').toLowerCase().includes(glAccount.toLowerCase()) - ); - } - - /** - * Initializes the data source for journal entries table and loads the first page. - */ getJournalEntries() { this.dataSource = new JournalEntriesDataSource(this.accountingService); this.dataSource.getJournalEntries( @@ -397,7 +543,83 @@ export class SearchJournalEntryComponent implements OnInit, AfterViewInit { this.sort.active, this.sort.direction, this.paginator.pageIndex, - this.paginator.pageSize + 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); + } } diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html index affeee5b9..8840be7f9 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html @@ -7,159 +7,253 @@ --> @if (isJournalEntryLoaded) { -
- @if (dataSource?.data[0].manualEntry) { -
- +
+

+ + Transaction Detail +

+ @if (transactionId) { + ID: {{ transactionId }} + } +
- } - @if (isViewTransaction()) { -
- @if (dataSource) { - - -
-
- {{ 'labels.inputs.Office' | translate }} -
-
- {{ dataSource.data[0].officeName }} -
-
- {{ 'labels.inputs.Transaction Date' | translate }} -
-
- {{ dataSource.data[0].transactionDate | dateFormat }} -
-
- {{ 'labels.inputs.Created By' | translate }} -
-
- {{ dataSource.data[0].createdByUserName }} -
-
- {{ 'labels.inputs.Submitted on' | translate }} -
-
- {{ dataSource.data[0].submittedOnDate | datetimeFormat }} -
-
- {{ 'labels.inputs.Manual Journal Entry' | translate }} -
-
- {{ dataSource.data[0].manualEntry | yesNo }} -
- @if (dataSource.data[0].externalAssetOwner) { -
- {{ 'labels.inputs.External Asset Owner' | translate }} -
- } - @if (dataSource.data[0].externalAssetOwner) { -
- {{ dataSource.data[0].externalAssetOwner }} -
- } -
-
-
+
+ @if (dataSource?.data[0].manualEntry) { + }
+
+ + + @if (isViewTransaction()) { + @if (dataSource) { +
+
+
+ +
+
+
Entry Type
+
+ {{ dataSource.data[0].manualEntry ? 'Manual Entry' : 'System Entry' }} +
+
+
+ +
+
+
+
Office
+
{{ dataSource.data[0].officeName }}
+
+
+ +
+
+
+
Transaction Date
+
{{ dataSource.data[0].transactionDate | dateFormat }}
+
+
+ +
+
+
+
Submitted On
+
+ {{ dataSource.data[0].submittedOnDate | datetimeFormat }} +
+
+
+ +
+
+
+
Created By
+
{{ dataSource.data[0].createdByUserName }}
+
+
+ + @if (dataSource.data[0].externalAssetOwner) { +
+
+
+
External Asset Owner
+
{{ dataSource.data[0].externalAssetOwner }}
+
+
+ } +
+ + +
+
+ + Total Debit: {{ getTotalDebit() | formatNumber }} +
+
+
+ + Total Credit: {{ getTotalCredit() | formatNumber }} +
+
+
+ + {{ isBalanced() ? 'Balanced' : 'Unbalanced' }} +
+
+ } } -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
- {{ 'labels.inputs.Entry ID' | translate }} - {{ transaction.id }} - {{ 'labels.inputs.Type' | translate }} - {{ transaction.glAccountType.value }} - {{ 'labels.inputs.Account Code' | translate }} - {{ transaction.glAccountCode }} - {{ 'labels.inputs.Account Name' | translate }} - {{ transaction.glAccountName }} - {{ 'labels.inputs.Debit' | translate }} - - @if (transaction.entryType.value === 'DEBIT') { - - {{ transaction.currency.displaySymbol || transaction.currency.code }} - {{ transaction.amount | formatNumber }} - - } - - {{ 'labels.inputs.Credit' | translate }} - - @if (transaction.entryType.value === 'CREDIT') { - - {{ transaction.currency.displaySymbol || transaction.currency.code }} - {{ transaction.amount | formatNumber }} - - } -
-
- -
+ + + + + + + Journal Entries + {{ dataSource?.data?.length || 0 }} entries + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
# ID + {{ entry.id }} + Account Type + + {{ entry.glAccountType?.value }} + + Code + {{ entry.glAccountCode }} + Account Name + {{ entry.glAccountName }} + DR / CR + + {{ entry.entryType?.value === 'DEBIT' ? 'DR' : 'CR' }} + + + Debit (Dr) + + @if (entry.entryType?.value === 'DEBIT') { + + {{ entry.currency?.displaySymbol || entry.currency?.code }} + {{ entry.amount | formatNumber }} + + } @else { + — + } + + Credit (Cr) + + @if (entry.entryType?.value === 'CREDIT') { + + {{ entry.currency?.displaySymbol || entry.currency?.code }} + {{ entry.amount | formatNumber }} + + } @else { + — + } +
+
+
+
+ + +
} @if (!isJournalEntryLoaded) { -
-
-
- - {{ 'labels.text.No data found' | translate }} -
-
-
-
diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.scss b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.scss index 623977a98..e8a92eb74 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.scss +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.scss @@ -6,13 +6,495 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -@use 'assets/styles/helper'; -@use 'assets/styles/colours' as *; +$dr-color: #e53935; +$cr-color: #43a047; +$radius: 10px; +$shadow: 0 2px 12px rgb(0 0 0 / 8%); -table { - width: 100%; +.m-r-6 { + margin-right: 6px; +} - .select-row:hover { +.m-r-8 { + margin-right: 8px; +} + +.m-r-4 { + margin-right: 4px; +} + +// ─── Page Layout ───────────────────────────────────────────────────────────── +.txn-detail-page { + padding: 0.5rem 0; + display: flex; + flex-direction: column; + gap: 1.1rem; +} + +// ─── Header Bar ─────────────────────────────────────────────────────────────── +.txn-header-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0; + gap: 0.5rem; + flex-wrap: wrap; + + &__left { + display: flex; + align-items: center; + gap: 0.6rem; + } + + &__title-group { + display: flex; + flex-direction: column; + } + + &__title { + margin: 0; + font-size: 1.2rem; + font-weight: 700; + color: var(--primary-text, #212121); + display: flex; + align-items: center; + } + + &__txn-id { + font-size: 0.75rem; + color: #78909c; + font-family: 'Courier New', monospace; + margin-top: 2px; + } + + &__actions { + display: flex; + gap: 0.5rem; + } +} + +.txn-back-btn { + color: #546e7a; + + &:hover { + color: #1976d2; + } +} + +.txn-revert-btn { + border-radius: 8px !important; + font-weight: 600 !important; +} + +// ─── Info Cards Grid ────────────────────────────────────────────────────────── +.txn-info-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.85rem; +} + +.txn-info-card { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.9rem 1.1rem; + border-radius: $radius; + background: #fff; + box-shadow: $shadow; + border-left: 4px solid #e0e0e0; + transition: transform 0.12s ease; + + &:hover { + transform: translateY(-1px); + } + + &__icon { + width: 38px; + height: 38px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + color: #fff; + flex-shrink: 0; + background: #90a4ae; + } + + &__label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #9e9e9e; + margin-bottom: 0.15rem; + } + + &__value { + font-size: 0.9rem; + font-weight: 600; + color: #212121; + + &--small { + font-size: 0.78rem; + } + } + + &--manual { + border-left-color: #f57f17; + + .txn-info-card__icon { + background: linear-gradient(135deg, #f57f17, #ffb74d); + } + } + + &--system { + border-left-color: #3949ab; + + .txn-info-card__icon { + background: linear-gradient(135deg, #3949ab, #7986cb); + } + } + + &--blue { + border-left-color: #1976d2; + + .txn-info-card__icon { + background: linear-gradient(135deg, #1976d2, #42a5f5); + } + } + + &--teal { + border-left-color: #00796b; + + .txn-info-card__icon { + background: linear-gradient(135deg, #00796b, #4db6ac); + } + } + + &--purple { + border-left-color: #6a1b9a; + + .txn-info-card__icon { + background: linear-gradient(135deg, #6a1b9a, #ab47bc); + } + } + + &--grey { + border-left-color: #546e7a; + + .txn-info-card__icon { + background: linear-gradient(135deg, #546e7a, #90a4ae); + } + } + + &--orange { + border-left-color: #e65100; + + .txn-info-card__icon { + background: linear-gradient(135deg, #e65100, #ff8a65); + } + } +} + +// ─── Summary Bar ────────────────────────────────────────────────────────────── +.txn-summary-bar { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0.85rem 1.25rem; + background: #fff; + border-radius: $radius; + box-shadow: $shadow; + flex-wrap: wrap; + + &__item { + font-size: 0.85rem; + font-weight: 500; + display: flex; + align-items: center; + + strong { + margin-left: 5px; + font-size: 1rem; + } + + &--dr { + color: $dr-color; + } + + &--cr { + color: $cr-color; + } + + &--balanced { + color: #2e7d32; + font-weight: 700; + } + + &--unbalanced { + color: #c62828; + font-weight: 700; + } + } + + &__divider { + width: 1px; + height: 24px; + background: #e0e0e0; + } +} + +// ─── Entries Card ───────────────────────────────────────────────────────────── +.txn-entries-card { + border-radius: $radius !important; + box-shadow: $shadow !important; + border: 1px solid var(--border-color, #e0e0e0); + overflow: hidden; + + &__header { + padding: 0.85rem 1.25rem 0 !important; + + mat-card-title { + font-size: 0.95rem !important; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--primary-text, #424242); + } + } + + &__content { + padding: 0 !important; + } +} + +.txn-entries-count { + font-size: 0.72rem; + font-weight: 600; + padding: 2px 9px; + border-radius: 20px; + background: #e3f2fd; + color: #1976d2; +} + +// ─── Table ──────────────────────────────────────────────────────────────────── +.txn-table-wrapper { + overflow-x: auto; +} + +.txn-table { + width: 100%; + + .txn-header-row { + background: #f5f7fa; + + th { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #616161; + padding: 0.7rem 1rem; + white-space: nowrap; + border-bottom: 2px solid #e0e0e0; + } + } + + .txn-data-row { cursor: pointer; + transition: background 0.1s ease; + + td { + padding: 0.65rem 1rem; + font-size: 0.84rem; + border-bottom: 1px solid #f0f0f0; + vertical-align: middle; + } + + &:hover { + background: #f8f9ff !important; + } + + &.txn-row--dr td:first-child { + border-left: 3px solid rgba($dr-color, 0.4); + } + + &.txn-row--cr td:first-child { + border-left: 3px solid rgba($cr-color, 0.4); + } + + &:last-child td { + border-bottom: none; + } + } +} + +.txn-th--center, +.txn-td--center { + text-align: center !important; +} + +.txn-th--amount { + text-align: right !important; +} + +.txn-td--amount { + text-align: right !important; +} + +.txn-th--dr { + color: $dr-color !important; +} + +.txn-th--cr { + color: $cr-color !important; +} + +// ─── Cell Elements ──────────────────────────────────────────────────────────── +.txn-entry-id { + font-weight: 600; + font-size: 0.78rem; + color: #1976d2; + background: #e3f2fd; + padding: 2px 8px; + border-radius: 20px; +} + +.txn-acct-code { + font-family: 'Courier New', monospace; + font-size: 0.78rem; + font-weight: 600; + color: #455a64; + background: #eceff1; + padding: 2px 7px; + border-radius: 4px; +} + +.txn-acct-name { + font-size: 0.84rem; + font-weight: 500; + color: #37474f; +} + +// ─── Badges ─────────────────────────────────────────────────────────────────── +.txn-acct-type-badge { + font-size: 0.7rem; + font-weight: 700; + padding: 3px 9px; + border-radius: 20px; + text-transform: uppercase; + letter-spacing: 0.4px; + + &.txn-badge--asset { + background: #e3f2fd; + color: #1565c0; + } + + &.txn-badge--liability { + background: #f3e5f5; + color: #6a1b9a; + } + + &.txn-badge--income { + background: #e8f5e9; + color: #2e7d32; + } + + &.txn-badge--expense { + background: #ffebee; + color: #c62828; + } + + &.txn-badge--equity { + background: #fff3e0; + color: #e65100; + } + + &.txn-badge--default { + background: #f5f5f5; + color: #616161; + } +} + +.txn-drcr-badge { + font-size: 0.72rem; + font-weight: 800; + padding: 3px 11px; + border-radius: 20px; + letter-spacing: 0.8px; + + &--dr { + background: #ffebee; + color: $dr-color; + border: 1px solid rgba($dr-color, 0.3); + } + + &--cr { + background: #e8f5e9; + color: $cr-color; + border: 1px solid rgba($cr-color, 0.3); + } +} + +// ─── Amounts ────────────────────────────────────────────────────────────────── +.txn-amount { + font-weight: 700; + font-size: 0.9rem; + white-space: nowrap; + + &--dr { + color: $dr-color; + } + + &--cr { + color: $cr-color; + } +} + +.txn-amount--empty { + color: #bdbdbd; +} + +// ─── Footer ─────────────────────────────────────────────────────────────────── +.txn-footer-actions { + display: flex; + justify-content: flex-start; + padding: 0.25rem 0 0.5rem; +} + +// ─── No Data ────────────────────────────────────────────────────────────────── +.txn-no-data { + display: flex; + justify-content: center; + align-items: center; + min-height: 60vh; + + &__box { + text-align: center; + padding: 2.5rem; + border-radius: $radius; + background: #fff; + box-shadow: $shadow; + max-width: 400px; + + h4 { + font-size: 1.1rem; + font-weight: 700; + color: #424242; + margin: 0.75rem 0 0.4rem; + } + + p { + font-size: 0.85rem; + color: #757575; + margin-bottom: 1.25rem; + } + } + + &__icon { + color: #e0e0e0; + margin-bottom: 0.5rem; } } diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts index 20bbfd7de..c84efe9e0 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts @@ -27,13 +27,15 @@ import { MatRowDef, MatRow } from '@angular/material/table'; -import { Location } from '@angular/common'; +import { Location, NgClass } from '@angular/common'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; 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 { YesnoPipe } from '@pipes/yesno.pipe'; +import { MatCardModule } from '@angular/material/card'; +import { MatTooltipModule } from '@angular/material/tooltip'; @Component({ selector: 'mifosx-view-journal-entry-transaction', @@ -54,10 +56,13 @@ import { YesnoPipe } from '@pipes/yesno.pipe'; MatHeaderRow, MatRowDef, MatRow, + NgClass, DateFormatPipe, DatetimeFormatPipe, FormatNumberPipe, - YesnoPipe + YesnoPipe, + MatCardModule, + MatTooltipModule ] }) export class ViewJournalEntryTransactionComponent implements OnInit { @@ -69,34 +74,27 @@ export class ViewJournalEntryTransactionComponent implements OnInit { title: string; journalEntriesData: any[]; - /** Transaction data. */ transaction: any; - /** Transaction ID. */ transactionId: string; - /** Columns to be displayed in transaction table. */ + displayedColumns: string[] = [ 'id', 'glAccountType', 'glAccountCode', 'glAccountName', + 'entryType', 'debit', 'credit' ]; - /** Data source for transaction table. */ + dataSource: MatTableDataSource; - /** Paginator for transaction table. */ @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; - /** Sorter for transaction table. */ @ViewChild(MatSort, { static: true }) sort: MatSort; isJournalEntryLoaded = false; - isManualJournalEntry = false; - /** - * Retrieves the transaction data from `resolve` and sets the transaction table. - */ ngOnInit() { this.route.data.subscribe((data: { title: string; transaction: any; transferJournalEntryData: any }) => { this.title = data.title; @@ -119,14 +117,10 @@ export class ViewJournalEntryTransactionComponent implements OnInit { isViewTransaction(): boolean { return this.title === 'View Transaction'; } - isViewTransfer(): boolean { return this.title === 'View Transfer'; } - /** - * Initializes the data source for transaction table with journal entries, paginator and sorter. - */ setTransaction() { if (this.journalEntriesData != null) { this.dataSource = new MatTableDataSource(this.journalEntriesData); @@ -149,23 +143,13 @@ export class ViewJournalEntryTransactionComponent implements OnInit { this.dataSource.sort = this.sort; } - /** - * View details of selected journal entry. - * @param {any} journalEntry Selected journal entry. - */ viewJournalEntry(journalEntry: any) { - this.dialog.open(ViewJournalEntryComponent, { - data: { journalEntry: journalEntry } - }); + this.dialog.open(ViewJournalEntryComponent, { data: { journalEntry } }); } - /** - * Reverts the given transaction and redirects to reverted transaction. - * @param {transactionId} transactionId Transaction ID of transaction to be reverted. - */ revertTransaction(transactionId?: string) { const revertTransactionDialogRef = this.dialog.open(RevertTransactionComponent, { - data: { reverted: this.dataSource.data[0].reversed, transactionId: transactionId } + data: { reverted: this.dataSource.data[0].reversed, transactionId } }); revertTransactionDialogRef.afterClosed().subscribe((response: any) => { if (response.revert) { @@ -191,10 +175,30 @@ export class ViewJournalEntryTransactionComponent implements OnInit { this.location.back(); } - journalEntryColor(): string { - if (this.isManualJournalEntry) { - return 'manual-entry'; - } - return ''; + getTotalDebit(): number { + return (this.dataSource?.data || []) + .filter((e) => e.entryType?.value === 'DEBIT') + .reduce((sum, e) => sum + (e.amount || 0), 0); + } + + getTotalCredit(): number { + return (this.dataSource?.data || []) + .filter((e) => e.entryType?.value === 'CREDIT') + .reduce((sum, e) => sum + (e.amount || 0), 0); + } + + isBalanced(): boolean { + return Math.abs(this.getTotalDebit() - this.getTotalCredit()) < 0.01; + } + + getAccountTypeBadgeClass(type: string): string { + const map: Record = { + ASSET: 'txn-badge--asset', + LIABILITY: 'txn-badge--liability', + INCOME: 'txn-badge--income', + EXPENSE: 'txn-badge--expense', + EQUITY: 'txn-badge--equity' + }; + return map[type] || 'txn-badge--default'; } } diff --git a/src/app/shared/icons.module.ts b/src/app/shared/icons.module.ts index 85e048995..3fe4458d7 100644 --- a/src/app/shared/icons.module.ts +++ b/src/app/shared/icons.module.ts @@ -193,7 +193,25 @@ import { faRobot, faMicrochip, faMagic, - faQrcode + faQrcode, + faCalendarDay, + faCalendarWeek, + faChartArea, + faChevronUp, + faWallet, + faExchangeAlt, + faArrowsAltH, + faSignInAlt, + faDoorOpen, + faBoxOpen, + faToggleOn, + faToggleOff, + faChartColumn, + faMoneyBillTrendUp, + faScaleBalanced, + faNotesMedical, + faFileLines, + faBuildingColumns } from '@fortawesome/free-solid-svg-icons'; /** @@ -393,7 +411,25 @@ export class IconsModule { faRobot, faMicrochip, faMagic, - faQrcode + faQrcode, + faCalendarDay, + faCalendarWeek, + faChartArea, + faChevronUp, + faWallet, + faExchangeAlt, + faArrowsAltH, + faSignInAlt, + faDoorOpen, + faBoxOpen, + faToggleOn, + faToggleOff, + faChartColumn, + faMoneyBillTrendUp, + faScaleBalanced, + faNotesMedical, + faFileLines, + faBuildingColumns ); } }