diff --git a/src/app/accounting/accounting-routing.module.ts b/src/app/accounting/accounting-routing.module.ts index ed14fee6f..928b95931 100644 --- a/src/app/accounting/accounting-routing.module.ts +++ b/src/app/accounting/accounting-routing.module.ts @@ -72,6 +72,7 @@ import { ViewJournalEntryTransactionComponent } from 'app/shared/accounting/view import { JournalEntryTransactionResolver } from './common-resolvers/journal-entry-transaction.resolver'; import { ExternalAssetOwnerJournalEntryResolver } from 'app/loans/common-resolvers/external-asset-owner-journal-entry.resolver'; import { ExternalAssetConfigurationResolver } from './common-resolvers/external-asset-configuration.resolver'; +import { accountingFeatureEnabledGuard } from './disabled-features'; /** Accounting Routes */ const routes: Routes = [ @@ -99,7 +100,8 @@ const routes: Routes = [ { path: 'frequent-postings', component: FrequentPostingsComponent, - data: { title: 'Frequent Postings', breadcrumb: 'Frequent Postings' }, + canActivate: [accountingFeatureEnabledGuard], + data: { title: 'Frequent Postings', breadcrumb: 'Frequent Postings', feature: 'frequentPostings' }, resolve: { offices: OfficesResolver, accountingRules: AccountingRulesAssociationsResolver, @@ -110,7 +112,8 @@ const routes: Routes = [ { path: 'create', component: CreateJournalEntryComponent, - data: { title: 'Create Journal Entry', breadcrumb: 'Create' }, + canActivate: [accountingFeatureEnabledGuard], + data: { title: 'Create Journal Entry', breadcrumb: 'Create', feature: 'createJournalEntries' }, resolve: { offices: OfficesResolver, currencies: CurrenciesResolver, @@ -194,6 +197,11 @@ const routes: Routes = [ offices: OfficesResolver } }, + { + path: 'balance-sheet', + loadComponent: () => import('./balance-sheet/balance-sheet.component').then((c) => c.BalanceSheetComponent), + data: { title: 'Balance Sheet', breadcrumb: 'Balance Sheet' } + }, { path: 'chart-of-accounts', data: { title: 'Chart of Accounts', breadcrumb: 'Chart of Accounts' }, @@ -244,7 +252,8 @@ const routes: Routes = [ }, { path: 'closing-entries', - data: { title: 'Accounting Closures', breadcrumb: 'Closing Entries' }, + canActivate: [accountingFeatureEnabledGuard], + data: { title: 'Accounting Closures', breadcrumb: 'Closing Entries', feature: 'closingEntries' }, children: [ { path: '', @@ -285,7 +294,8 @@ const routes: Routes = [ }, { path: 'accounting-rules', - data: { title: 'Accounting Rules', breadcrumb: 'Accounting Rules' }, + canActivate: [accountingFeatureEnabledGuard], + data: { title: 'Accounting Rules', breadcrumb: 'Accounting Rules', feature: 'accountingRules' }, children: [ { path: '', @@ -332,11 +342,17 @@ const routes: Routes = [ { path: 'periodic-accruals', component: PeriodicAccrualsComponent, - data: { title: 'Periodic Accrual Accounting', breadcrumb: 'Execute Periodic Accrual Accounting' } + canActivate: [accountingFeatureEnabledGuard], + data: { + title: 'Periodic Accrual Accounting', + breadcrumb: 'Execute Periodic Accrual Accounting', + feature: 'accruals' + } }, { path: 'provisioning-entries', - data: { title: 'Provisioning Entries', breadcrumb: 'Provisioning Entries' }, + canActivate: [accountingFeatureEnabledGuard], + data: { title: 'Provisioning Entries', breadcrumb: 'Provisioning Entries', feature: 'provisioningEntries' }, children: [ { path: '', diff --git a/src/app/accounting/accounting.component.html b/src/app/accounting/accounting.component.html index de5ec4653..8685bea9a 100644 --- a/src/app/accounting/accounting.component.html +++ b/src/app/accounting/accounting.component.html @@ -17,35 +17,39 @@
- - -
-
- {{ 'labels.heading.Frequent Postings' | translate }} - {{ 'labels.text.These are predefined postings' | translate }} -
-
+ + @if (!disabledFeatures.frequentPostings) { + +
+
+ {{ 'labels.heading.Frequent Postings' | translate }} + {{ 'labels.text.These are predefined postings' | translate }} +
+
+ } - - -
-
- {{ 'labels.heading.Create Journal Entries' | translate }} - {{ - 'labels.text.Manual journal entry transactions recorded in a journal' | translate - }} -
-
+ + @if (!disabledFeatures.createJournalEntries) { + +
+
+ {{ 'labels.heading.Create Journal Entries' | translate }} + {{ + 'labels.text.Manual journal entry transactions recorded in a journal' | translate + }} +
+
+ } @@ -113,43 +117,64 @@
- - -
+ +
+
- {{ 'labels.heading.Closing Entries' | translate }} - {{ - 'labels.text.Journal entries made at the end of accounting period' | translate - }} + Balance Sheet + Header/Detail balances as on any date
- - -
-
- {{ 'labels.heading.Accounting Rules' | translate }} - {{ 'labels.text.List of all accounting rules' | translate }} -
-
+ + @if (!disabledFeatures.closingEntries) { + +
+
+ {{ 'labels.heading.Closing Entries' | translate }} + {{ + 'labels.text.Journal entries made at the end of accounting period' | translate + }} +
+
+ } - - -
-
- {{ 'labels.heading.Accruals' | translate }} - {{ 'labels.text.Accrues income, expenses and liabilities' | translate }} -
-
+ + @if (!disabledFeatures.accountingRules) { + +
+
+ {{ 'labels.heading.Accounting Rules' | translate }} + {{ 'labels.text.List of all accounting rules' | translate }} +
+
+ } - - -
-
- {{ 'labels.heading.Provisioning Entries' | translate }} - {{ 'labels.text.Create provisioning entries' | translate }} -
-
+ + @if (!disabledFeatures.accruals) { + +
+
+ {{ 'labels.heading.Accruals' | translate }} + {{ 'labels.text.Accrues income, expenses and liabilities' | translate }} +
+
+ } + + + @if (!disabledFeatures.provisioningEntries) { + +
+
+ {{ 'labels.heading.Provisioning Entries' | translate }} + {{ 'labels.text.Create provisioning entries' | translate }} +
+
+ } diff --git a/src/app/accounting/accounting.component.ts b/src/app/accounting/accounting.component.ts index cf9771660..c1318e154 100644 --- a/src/app/accounting/accounting.component.ts +++ b/src/app/accounting/accounting.component.ts @@ -18,6 +18,7 @@ import { MatIcon } from '@angular/material/icon'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatLine } from '@angular/material/grid-list'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { DISABLED_ACCOUNTING_FEATURES } from './disabled-features'; /** * Accounting component. @@ -63,6 +64,9 @@ export class AccountingComponent implements AfterViewInit { // Initialize an array of 10 boolean values, all set to false arrowBooleans: boolean[] = new Array(10).fill(false); + /** Which accounting tiles are switched off for this installation — see disabled-features.ts. */ + disabledFeatures = DISABLED_ACCOUNTING_FEATURES; + /** * Popover function * @param template TemplateRef. @@ -104,12 +108,15 @@ export class AccountingComponent implements AfterViewInit { ); }); } - if (this.configurationWizardService.showClosingEntries) { + // Closing Entries and Create Journal Entries can be switched off (see disabled-features.ts), in + // which case their tiles never render and these refs stay undefined — the wizard simply has no + // step to point at, rather than throwing on a missing element. + if (this.configurationWizardService.showClosingEntries && this.closingEntries) { setTimeout(() => { this.showPopover(this.templateClosingEntries, this.closingEntries.nativeElement, 'bottom', true); }); } - if (this.configurationWizardService.showCreateJournalEntries) { + if (this.configurationWizardService.showCreateJournalEntries && this.createJournalEntries) { setTimeout(() => { this.showPopover(this.templateCreateJournalEntries, this.createJournalEntries.nativeElement, 'bottom', true); }); diff --git a/src/app/accounting/accounting.service.ts b/src/app/accounting/accounting.service.ts index 69b896779..2143fbd22 100644 --- a/src/app/accounting/accounting.service.ts +++ b/src/app/accounting/accounting.service.ts @@ -116,6 +116,22 @@ export class AccountingService { return this.http.get(`/glaccounts`, { params: httpParams }); } + /** + * As-on-date balance sheet, grouped by Header/Detail hierarchy — see BalanceSheetApiResource. + * @param asOnDate yyyy-MM-dd. Defaults to today on the backend when omitted. + * @param officeId Optional — scopes every balance to one office instead of the whole organization. + */ + getBalanceSheet(asOnDate?: string, officeId?: number): Observable { + let httpParams = new HttpParams(); + if (asOnDate) { + httpParams = httpParams.set('asOnDate', asOnDate); + } + if (officeId != null) { + httpParams = httpParams.set('officeId', officeId.toString()); + } + return this.http.get('/accounting/balancesheet', { params: httpParams }); + } + /** * @param transactionId Transaction ID of journal entries. * @returns {Observable} Journal Entries. diff --git a/src/app/accounting/balance-sheet/balance-sheet-pdf.ts b/src/app/accounting/balance-sheet/balance-sheet-pdf.ts new file mode 100644 index 000000000..954ec2e52 --- /dev/null +++ b/src/app/accounting/balance-sheet/balance-sheet-pdf.ts @@ -0,0 +1,363 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Builds the Consolidated Balance Sheet PDF, laid out like the bank's printed report: liabilities + * on the left, assets on the right, each top-level group a named block with its accounts listed + * under it and a subtotal in the TOTAL column. + * + * Drawn directly with jsPDF rather than printed from the DOM: window.print() carries the whole app + * shell (sidenav, toolbar, ticker) into the output and gives no control over where a page breaks, + * which is what made the printed sheet unreadable. Here each column is paginated on its own, the + * way the printed report does it — the left side can run onto page 2 while the right side is still + * on page 1 — and every page repeats the heading. + */ + +import { jsPDF } from 'jspdf'; + +export interface BalanceSheetPdfNode { + name: string; + glCode: string; + usage: 'HEADER' | 'DETAIL'; + balance: number; + children: BalanceSheetPdfNode[]; +} + +export interface BalanceSheetPdfSide { + label: string; + total: number; + groups: { classification: string; total: number; nodes: BalanceSheetPdfNode[] }[]; +} + +export interface BalanceSheetPdfInput { + bankName: string; + officeLabel: string; + /** yyyy-MM-dd */ + asOnDate: string; + creditSide: BalanceSheetPdfSide; + debitSide: BalanceSheetPdfSide; +} + +// ── Page geometry, in mm on A4 portrait ───────────────────────────────────── +const PAGE_W = 210; +const PAGE_H = 297; +const MARGIN = 8; +const COL_W = (PAGE_W - MARGIN * 2) / 2; +const COL_X = [ + MARGIN, + MARGIN + COL_W +]; +/** Right edges of the BALANCE and TOTAL columns, relative to a column's left edge. */ +const BALANCE_R = COL_W - 26; +const TOTAL_R = COL_W - 2; +/** Name text wraps within this width so it never runs under the BALANCE column. */ +const NAME_W = COL_W - 30; + +const BODY_TOP = 36; +/** Leaves room under the body for the sign-off row and the print-date footer. */ +const BODY_BOTTOM = PAGE_H - 21; +const LINE_H = 3.1; + +const INK: [number, number, number] = [ + 20, + 20, + 20 +]; +const MUTED: [number, number, number] = [ + 90, + 90, + 90 +]; + +type Line = + | { kind: 'block'; text: string } + | { kind: 'subhead'; text: string; depth: number } + | { kind: 'detail'; lines: string[]; amount: string; depth: number } + | { kind: 'subsubtotal'; amount: string } + | { kind: 'subtotal'; amount: string } + | { kind: 'standalone'; lines: string[]; amount: string } + | { kind: 'grand'; amount: string } + | { kind: 'gap' }; + +function money(value: number): string { + return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0); +} + +function ddmmyyyy(iso: string): string { + if (!iso) { + return ''; + } + const [ + y, + m, + d + ] = iso.split('-'); + return `${d}/${m}/${y}`; +} + +function heightOf(line: Line): number { + switch (line.kind) { + case 'block': + // Heading plus the short dashed rule beneath it. + return LINE_H + 1.8; + case 'detail': + case 'standalone': + return LINE_H * line.lines.length; + case 'gap': + return 2.4; + case 'grand': + // Rule above, the figure, and the double rule beneath it. + return LINE_H + 6; + default: + return LINE_H; + } +} + +/** + * Flattens one side into the exact sequence of printed lines. Done up front so pagination is a + * matter of measuring heights, with no recursion happening mid-page. + */ +function flatten(doc: jsPDF, side: BalanceSheetPdfSide): Line[] { + const out: Line[] = []; + // splitTextToSize measures with whatever font is currently set, so it has to match the font the + // detail lines are actually drawn in — otherwise names wrap at the wrong width. + doc.setFont('helvetica', 'normal').setFontSize(6.8); + + const pushNode = (node: BalanceSheetPdfNode, depth: number): void => { + if (node.usage === 'DETAIL') { + const indent = depth * 2; + const wrapped: string[] = doc.splitTextToSize(node.name, NAME_W - indent); + out.push({ kind: 'detail', lines: wrapped, amount: money(node.balance), depth }); + return; + } + out.push({ kind: 'subhead', text: node.name, depth }); + node.children.forEach((child) => pushNode(child, depth + 1)); + out.push({ kind: 'subsubtotal', amount: money(node.balance) }); + }; + + for (const group of side.groups) { + for (const root of group.nodes) { + if (root.usage === 'DETAIL') { + // A Detail account sitting under no group at all: a group of one, printed on a single line + // carrying both its balance and its total, rather than a heading and a figure on separate + // rows with the BALANCE column left empty. + out.push({ + kind: 'standalone', + lines: doc.splitTextToSize(root.name.toUpperCase(), NAME_W), + amount: money(root.balance) + }); + out.push({ kind: 'gap' }); + continue; + } + out.push({ kind: 'block', text: root.name }); + root.children.forEach((child) => pushNode(child, 0)); + out.push({ kind: 'subtotal', amount: money(root.balance) }); + out.push({ kind: 'gap' }); + } + } + // Carried as a line of its own so pagination reserves room for it, rather than squeezing it + // against the bottom margin under the last block. + out.push({ kind: 'grand', amount: money(side.total) }); + return out; +} + +function dottedLine(doc: jsPDF, x1: number, y: number, x2: number): void { + doc.setLineDashPattern( + [ + 0.4, + 0.6 + ], + 0 + ); + doc.setLineWidth(0.15); + doc.line(x1, y, x2, y); + doc.setLineDashPattern([], 0); +} + +function drawPageFrame(doc: jsPDF, input: BalanceSheetPdfInput, page: number, pageCount: number): void { + doc.setTextColor(...INK); + + doc.setFont('helvetica', 'bold').setFontSize(9.5); + doc.text(`${input.bankName.toUpperCase()} , HEAD OFFICE`, PAGE_W / 2, 11, { align: 'center' }); + + doc.setFontSize(8); + doc.text(`CONSOLIDATED BALANCE SHEET (SUB GROUP WISE) AS ON : ${ddmmyyyy(input.asOnDate)}`, PAGE_W / 2, 16, { + align: 'center' + }); + doc.setFont('helvetica', 'normal').setFontSize(7); + doc.text(`Page ${page} of ${pageCount}`, PAGE_W - MARGIN, 16, { align: 'right' }); + doc.text(`FOR BRANCHES : ${input.officeLabel}`, PAGE_W / 2, 20, { align: 'center' }); + + dottedLine(doc, MARGIN, 23, PAGE_W - MARGIN); + + // Side titles and column headings, drawn per column. + const titles = [ + 'LIABILITIES ( CREDIT )', + 'ASSETS ( DEBIT )' + ]; + for (let c = 0; c < 2; c++) { + const x = COL_X[c]; + doc.setFont('helvetica', 'bold').setFontSize(7.5); + doc.text(titles[c], x + 1, 27.5); + doc.setFontSize(6.5); + doc.text('ACCOUNT NAME', x + 1, 32); + doc.text('BALANCE', x + BALANCE_R, 32, { align: 'right' }); + doc.text('TOTAL', x + TOTAL_R, 32, { align: 'right' }); + dottedLine(doc, x + 1, 33.4, x + COL_W - 2); + } + + // The divider between the two sides. + doc.setLineWidth(0.15); + doc.setDrawColor(150, 150, 150); + doc.line(COL_X[1], 24, COL_X[1], BODY_BOTTOM + 2); + doc.setDrawColor(0, 0, 0); + + // Footer. + dottedLine(doc, MARGIN, BODY_BOTTOM + 6, PAGE_W - MARGIN); + doc + .setFont('helvetica', 'normal') + .setFontSize(6.5) + .setTextColor(...MUTED); + const now = new Date(); + const stamp = + `${`${now.getDate()}`.padStart(2, '0')}/${`${now.getMonth() + 1}`.padStart(2, '0')}/${now.getFullYear()} ` + + `${`${now.getHours()}`.padStart(2, '0')}:${`${now.getMinutes()}`.padStart(2, '0')}:${`${now.getSeconds()}`.padStart(2, '0')}`; + doc.text(`Print Date : ${stamp}`, MARGIN, BODY_BOTTOM + 10); + doc.text('Consolidated Balance Sheet (Sub Group Wise)', PAGE_W - MARGIN, BODY_BOTTOM + 10, { align: 'right' }); + doc.setTextColor(...INK); +} + +/** Draws one line into a column at y, and returns the y the next line starts at. */ +function drawLine(doc: jsPDF, line: Line, x: number, y: number): number { + switch (line.kind) { + case 'block': { + doc.setFont('helvetica', 'bold').setFontSize(7); + doc.text(line.text.toUpperCase(), x + 1, y); + dottedLine(doc, x + 1, y + 1.1, x + 34); + return y + heightOf(line); + } + case 'subhead': { + doc.setFont('helvetica', 'bold').setFontSize(6.8); + doc.text(line.text, x + 1 + line.depth * 2, y); + return y + heightOf(line); + } + case 'detail': { + doc.setFont('helvetica', 'normal').setFontSize(6.8); + const indent = x + 2 + line.depth * 2; + line.lines.forEach((text, i) => doc.text(text, indent, y + i * LINE_H)); + // The amount sits on the first line of a wrapped name, as the printed report has it. + doc.text(line.amount, x + BALANCE_R, y, { align: 'right' }); + return y + heightOf(line); + } + case 'subsubtotal': { + doc.setFont('helvetica', 'bold').setFontSize(6.8); + dottedLine(doc, x + BALANCE_R - 22, y - 2.2, x + BALANCE_R); + doc.text(line.amount, x + BALANCE_R, y, { align: 'right' }); + return y + heightOf(line); + } + case 'subtotal': { + doc.setFont('helvetica', 'bold').setFontSize(7); + doc.text(line.amount, x + TOTAL_R, y, { align: 'right' }); + return y + heightOf(line); + } + case 'standalone': { + doc.setFont('helvetica', 'bold').setFontSize(7); + line.lines.forEach((text, i) => doc.text(text, x + 1, y + i * LINE_H)); + doc.text(line.amount, x + BALANCE_R, y, { align: 'right' }); + doc.text(line.amount, x + TOTAL_R, y, { align: 'right' }); + return y + heightOf(line); + } + case 'grand': { + const top = y + 1.5; + doc.setLineWidth(0.3); + doc.line(x + 1, top, x + COL_W - 2, top); + doc.setFont('helvetica', 'bold').setFontSize(7.5); + doc.text(line.amount, x + TOTAL_R, top + 3.6, { align: 'right' }); + doc.setLineWidth(0.2); + doc.line(x + 1, top + 5, x + COL_W - 2, top + 5); + doc.line(x + 1, top + 5.7, x + COL_W - 2, top + 5.7); + return y + heightOf(line); + } + default: + return y + heightOf(line); + } +} + +/** Splits one side's lines into per-page chunks that fit between BODY_TOP and BODY_BOTTOM. */ +function paginate(lines: Line[]): Line[][] { + const pages: Line[][] = []; + let current: Line[] = []; + let y = BODY_TOP; + + for (const line of lines) { + const h = heightOf(line); + if (y + h > BODY_BOTTOM) { + pages.push(current); + current = []; + y = BODY_TOP; + } + current.push(line); + y += h; + } + // Keep the trailing chunk only if it has something on it, but always return at least one page so + // a side with no balances still gets its heading and a zero total. + if (current.length > 0 || pages.length === 0) { + pages.push(current); + } + return pages; +} + +export function buildBalanceSheetPdf(input: BalanceSheetPdfInput): jsPDF { + const doc = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4', compress: true }); + + const creditPages = paginate(flatten(doc, input.creditSide)); + const debitPages = paginate(flatten(doc, input.debitSide)); + // Both sides print side by side, so the sheet runs as long as the longer of the two, plus a + // final page for the grand totals when they would not fit under the last block. + const pageCount = Math.max(creditPages.length, debitPages.length); + + for (let p = 0; p < pageCount; p++) { + if (p > 0) { + doc.addPage(); + } + drawPageFrame(doc, input, p + 1, pageCount); + + const sides = [ + creditPages[p] ?? [], + debitPages[p] ?? [] + ]; + + for (let c = 0; c < 2; c++) { + const x = COL_X[c]; + let y = BODY_TOP; + for (const line of sides[c]) { + y = drawLine(doc, line, x, y); + } + } + + // Sign-off row, on the closing page only. + if (p === pageCount - 1) { + doc + .setFont('helvetica', 'normal') + .setFontSize(6.5) + .setTextColor(...MUTED); + const roles = [ + 'CLERK', + 'ACCOUNTANT / OFFICER', + 'ASST. GEN MANAGER', + 'GENERAL MANAGER', + 'CHAIRMAN' + ]; + const step = (PAGE_W - MARGIN * 2) / roles.length; + roles.forEach((role, i) => doc.text(role, MARGIN + step * i + step / 2, BODY_BOTTOM + 2, { align: 'center' })); + doc.setTextColor(...INK); + } + } + + return doc; +} diff --git a/src/app/accounting/balance-sheet/balance-sheet.component.html b/src/app/accounting/balance-sheet/balance-sheet.component.html new file mode 100644 index 000000000..883d4bcb5 --- /dev/null +++ b/src/app/accounting/balance-sheet/balance-sheet.component.html @@ -0,0 +1,182 @@ + + +
+ +
+ + + + + @if (!loading && debitSide && creditSide) { + + {{ tallied ? 'Balanced' : 'Out of balance by ' + fmt(difference) }} + + } + + +
+ + @if (error) { +
{{ error }}
+ } + + @if (loading) { +
Loading balance sheet…
+ } + + @if (!loading && debitSide && creditSide) { + +
+
+

CONSOLIDATED BALANCE SHEET (SUB GROUP WISE)

+

AS ON : {{ displayDate(asOnDateLoaded) }}

+

FOR BRANCHES : {{ officeLabel }}

+
+ +
+ +
+
LIABILITIES ( CREDIT )
+
+ ACCOUNT NAME + BALANCE + TOTAL +
+ @for (group of creditSide.groups; track group.classification) { + @for (root of group.nodes; track root.id) { + + } + } + @if (creditSide.groups.length === 0) { +
No liability, equity or income balances as on this date.
+ } +
+ + + {{ fmt(creditSide.total) }} +
+
+ +
+
ASSETS ( DEBIT )
+
+ ACCOUNT NAME + BALANCE + TOTAL +
+ @for (group of debitSide.groups; track group.classification) { + @for (root of group.nodes; track root.id) { + + } + } + @if (debitSide.groups.length === 0) { +
No asset or expense balances as on this date.
+ } +
+ + + {{ fmt(debitSide.total) }} +
+
+
+ +
+ CLERK + ACCOUNTANT / OFFICER + ASST. GEN MANAGER + GENERAL MANAGER + CHAIRMAN +
+
+ } +
+ + + + @if (root.usage === 'DETAIL') { + +
+
+ {{ root.name }} + {{ fmt(root.balance) }} + {{ fmt(root.balance) }} +
+
+ } @else { +
+
{{ root.name }}
+
+ @for (child of root.children; track child.id) { + + } + @if (root.children.length === 0) { +
+ No accounts under this group + + +
+ } +
+ + + {{ fmt(root.balance) }} +
+
+ } +
+ + + + @if (node.usage === 'DETAIL') { +
+ {{ node.name }} + {{ fmt(node.balance) }} + +
+ } @else { +
+ {{ node.name }} + + +
+ @for (child of node.children; track child.id) { + + } +
+ + {{ fmt(node.balance) }} + +
+ } +
diff --git a/src/app/accounting/balance-sheet/balance-sheet.component.scss b/src/app/accounting/balance-sheet/balance-sheet.component.scss new file mode 100644 index 000000000..3bbfe893b --- /dev/null +++ b/src/app/accounting/balance-sheet/balance-sheet.component.scss @@ -0,0 +1,372 @@ +/** + * 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/. + */ + +$ink: #111827; +$ink-soft: #374151; +$ink-faint: #9ca3af; +$rule: #cbd5e1; +$border: #e2e8f0; +$accent: #1d4ed8; +$ok: #15803d; +$ok-soft: #f0fdf4; +$danger: #b91c1c; +$danger-soft: #fef2f2; + +// Amount columns share one width so BALANCE and TOTAL line up down the whole sheet. +$amt-col: 128px; + +.bs-page { + padding: 20px 24px 40px; + background: #f1f5f9; + min-height: 100%; +} + +// ── Controls (screen only) ──────────────────────────────────────────────── +.bs-controls { + display: flex; + align-items: flex-end; + gap: 14px; + flex-wrap: wrap; + margin-bottom: 18px; + + &__spacer { + flex: 1 1 auto; + } +} + +.bs-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $ink-soft; + + input, + select { + height: 36px; + padding: 0 10px; + border: 1px solid $border; + border-radius: 6px; + background: #fff; + font-size: 13px; + font-weight: 500; + text-transform: none; + letter-spacing: normal; + color: $ink; + } +} + +.bs-check { + display: flex; + align-items: center; + gap: 6px; + height: 36px; + font-size: 12.5px; + font-weight: 600; + color: $ink-soft; + cursor: pointer; + user-select: none; + + input { + width: 15px; + height: 15px; + cursor: pointer; + accent-color: $accent; + } +} + +.bs-btn { + height: 36px; + padding: 0 16px; + border: 1px solid $border; + border-radius: 6px; + background: #fff; + color: $ink-soft; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; + + &--primary { + background: $ink; + border-color: $ink; + color: #fff; + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.bs-tally { + padding: 6px 12px; + border-radius: 999px; + background: $ok-soft; + color: $ok; + font-size: 12px; + font-weight: 700; + + &--off { + background: $danger-soft; + color: $danger; + } +} + +.bs-alert { + padding: 10px 14px; + border-radius: 6px; + background: $danger-soft; + color: $danger; + border: 1px solid #fecaca; + font-size: 13px; + margin-bottom: 14px; +} + +.bs-loading { + padding: 48px 0; + text-align: center; + color: $ink-faint; + font-size: 13.5px; +} + +// ── The printed sheet ───────────────────────────────────────────────────── +.bs-sheet { + background: #fff; + border: 1px solid $border; + padding: 22px 26px 18px; + // The ledger reads as a printed report, so the whole sheet is set in a mono face with + // figures on tabular numerals — columns stay aligned however long the names run. + font-family: 'DejaVu Sans Mono', 'Liberation Mono', Consolas, 'Courier New', monospace; + font-variant-numeric: tabular-nums; + color: $ink; + + &__head { + text-align: center; + border-bottom: 1px dotted $rule; + padding-bottom: 10px; + margin-bottom: 12px; + + h2 { + margin: 0; + font-size: 13.5px; + font-weight: 700; + letter-spacing: 0.04em; + } + + p { + margin: 4px 0 0; + font-size: 11.5px; + color: $ink-soft; + } + } + + &__branches { + letter-spacing: 0.05em; + } + + &__body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + } + + &__foot { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 26px; + padding-top: 10px; + border-top: 1px dotted $rule; + font-size: 10px; + letter-spacing: 0.05em; + color: $ink-soft; + } +} + +.bs-col { + padding: 0 16px; + min-width: 0; + + &:first-child { + border-right: 1px dotted $rule; + padding-left: 0; + } + + &:last-child { + padding-right: 0; + } + + &__title { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; + padding-bottom: 6px; + } +} + +// ── Rows: one grid so name / BALANCE / TOTAL align across every block ────── +.bs-row { + display: grid; + grid-template-columns: minmax(0, 1fr) $amt-col $amt-col; + gap: 8px; + align-items: baseline; + font-size: 11.5px; + line-height: 1.55; + + > span:nth-child(2), + > span:nth-child(3) { + text-align: right; + white-space: nowrap; + } + + &--colhead { + font-weight: 700; + font-size: 10.5px; + letter-spacing: 0.05em; + border-bottom: 1px dotted $rule; + padding-bottom: 4px; + margin-bottom: 6px; + color: $ink-soft; + } + + &--line { + color: $ink-soft; + } + + // A nested Header inside a block: its own named sub-group. + &--subhead { + font-weight: 700; + color: $ink; + padding-top: 3px; + } + + // Sub-group subtotal sits in the BALANCE column, so it never collides with the + // block's own figure in TOTAL. + &--subsubtotal { + border-top: 1px dotted $rule; + font-weight: 700; + color: $accent; + } + + // Top-level block subtotal, in the TOTAL column. + &--subtotal { + font-weight: 700; + color: $ink; + padding-top: 2px; + } + + // A Header-less Detail account: its own one-line group. + &--standalone { + font-weight: 700; + color: $ink; + + .bs-name { + text-transform: uppercase; + letter-spacing: 0.03em; + } + } + + &--grand { + margin-top: 14px; + padding-top: 8px; + border-top: 1px solid $ink; + border-bottom: 3px double $ink; + font-weight: 700; + font-size: 12.5px; + } +} + +.bs-name { + overflow-wrap: anywhere; + + &--muted { + color: $ink-faint; + font-style: italic; + } +} + +.bs-amt { + font-variant-numeric: tabular-nums; +} + +.bs-block { + margin-bottom: 14px; + + &--single { + margin-bottom: 10px; + } + + &__name { + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + // The short dashed rule the printed sheet puts under every group heading. + &__rule { + width: 130px; + border-top: 1px dotted $ink-faint; + margin: 2px 0 4px; + } +} + +.bs-empty { + padding: 14px 0; + font-size: 11.5px; + color: $ink-faint; +} + +// ── Narrow screens: stack the two sides instead of squeezing the columns ─── +@media (width <= 1100px) { + .bs-sheet__body { + grid-template-columns: 1fr; + } + + .bs-col { + padding: 0; + + &:first-child { + border-right: none; + border-bottom: 1px dotted $rule; + padding-bottom: 16px; + margin-bottom: 16px; + } + } +} + +// ── Print: only the sheet, at report proportions ─────────────────────────── +@media print { + .bs-controls, + .bs-alert, + .bs-loading { + display: none !important; + } + + .bs-page { + padding: 0; + background: #fff; + } + + .bs-sheet { + border: none; + padding: 0; + font-size: 10px; + } + + .bs-sheet__body { + grid-template-columns: 1fr 1fr; + } + + .bs-block { + break-inside: avoid; + } +} diff --git a/src/app/accounting/balance-sheet/balance-sheet.component.ts b/src/app/accounting/balance-sheet/balance-sheet.component.ts new file mode 100644 index 000000000..fff932b9e --- /dev/null +++ b/src/app/accounting/balance-sheet/balance-sheet.component.ts @@ -0,0 +1,218 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { AccountingService } from '../accounting.service'; +import { OrganizationService } from '../../organization/organization.service'; +import { buildBalanceSheetPdf } from './balance-sheet-pdf'; + +interface BalanceSheetNode { + id: number; + name: string; + glCode: string; + usage: 'HEADER' | 'DETAIL'; + balance: number; + children: BalanceSheetNode[]; +} + +interface BalanceSheetGroup { + classification: string; + total: number; + nodes: BalanceSheetNode[]; +} + +interface BalanceSheetSide { + label: string; + total: number; + groups: BalanceSheetGroup[]; +} + +/** + * Consolidated Balance Sheet (sub-group wise), laid out the way the bank's own printed balance + * sheet reads: liabilities on the left, assets on the right, each top-level Header account a named + * block whose Detail accounts are listed under it and subtotalled in the TOTAL column. + * + * Balances come from BalanceSheetApiResource, which reads the live ledger - a Detail account's own + * postings, and a Header's as the recursive sum of its children (a Header never carries a posting + * of its own). A Detail account that sits under no Header at all is shown as its own one-line block + * rather than being dropped. + */ +@Component({ + selector: 'mifosx-balance-sheet', + standalone: true, + imports: [ + CommonModule, + FormsModule + ], + templateUrl: './balance-sheet.component.html', + styleUrl: './balance-sheet.component.scss' +}) +export class BalanceSheetComponent implements OnInit { + private accountingService = inject(AccountingService); + private organizationService = inject(OrganizationService); + + /** Printed at the head of the PDF. Falls back to a neutral title if the profile is not set up. */ + bankName = 'Bank'; + + asOnDate: string = new Date().toISOString().slice(0, 10); + officeId: number | null = null; + offices: { id: number; name: string }[] = []; + + loading = false; + error = ''; + asOnDateLoaded = ''; + + /** What the server returned, kept whole so the zero-balance toggle never needs a refetch. */ + private rawDebitSide: BalanceSheetSide | null = null; + private rawCreditSide: BalanceSheetSide | null = null; + + debitSide: BalanceSheetSide | null = null; + creditSide: BalanceSheetSide | null = null; + + /** + * A working chart of accounts carries far more accounts than a bank actually operates, and a + * printed balance sheet lists only the ones carrying a balance — leaving every nil line in makes + * the sheet unreadable. On by default for that reason; switch it off to audit the full chart. + */ + hideZeroBalances = true; + + /** Both sides are derived from the same double-entry ledger, so they can only disagree if a + * posting is missing a leg — worth surfacing rather than hiding. */ + get tallied(): boolean { + if (!this.debitSide || !this.creditSide) { + return true; + } + return Math.abs(this.debitSide.total - this.creditSide.total) < 0.01; + } + + get difference(): number { + if (!this.debitSide || !this.creditSide) { + return 0; + } + return Math.abs(this.debitSide.total - this.creditSide.total); + } + + get officeLabel(): string { + if (this.officeId == null) { + return 'ALL BRANCHES'; + } + return (this.offices.find((o) => o.id === this.officeId)?.name ?? '').toUpperCase(); + } + + ngOnInit(): void { + this.accountingService.getOffices().subscribe({ + next: (offices) => (this.offices = offices || []), + error: () => (this.offices = []) + }); + this.organizationService.getInstitutionProfile().subscribe({ + next: (profile: any) => { + this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName; + }, + // A missing institution profile must not stop the sheet from loading — the PDF just carries + // the fallback title. + error: () => undefined + }); + this.load(); + } + + load(): void { + this.loading = true; + this.error = ''; + this.accountingService.getBalanceSheet(this.asOnDate, this.officeId ?? undefined).subscribe({ + next: (res: any) => { + this.rawDebitSide = res.debitSide; + this.rawCreditSide = res.creditSide; + this.asOnDateLoaded = res.asOnDate; + this.applyZeroFilter(); + this.loading = false; + }, + error: () => { + this.error = 'Could not load the balance sheet for this date.'; + this.loading = false; + } + }); + } + + /** + * Rebuilds the displayed sides from what the server sent, dropping nil lines when asked. The + * totals are deliberately left as the server computed them — hiding a nil line must never change + * a figure, and a group total is unaffected by removing children that summed to nothing. + */ + applyZeroFilter(): void { + this.debitSide = this.filterSide(this.rawDebitSide); + this.creditSide = this.filterSide(this.rawCreditSide); + } + + private filterSide(side: BalanceSheetSide | null): BalanceSheetSide | null { + if (!side) { + return null; + } + if (!this.hideZeroBalances) { + return side; + } + const groups = side.groups + .map((group) => ({ + ...group, + nodes: group.nodes + .map((node) => this.pruneZeros(node)) + .filter((node): node is BalanceSheetNode => node !== null) + })) + .filter((group) => group.nodes.length > 0); + return { ...side, groups }; + } + + /** Drops a node whose balance is nil, and any Header left with nothing under it. */ + private pruneZeros(node: BalanceSheetNode): BalanceSheetNode | null { + if (Math.abs(node.balance) < 0.005) { + return null; + } + const children = node.children + .map((child) => this.pruneZeros(child)) + .filter((child): child is BalanceSheetNode => child !== null); + return { ...node, children }; + } + + /** Indian grouping with two decimals, matching the printed report (2,76,92,608.00). */ + fmt(value: number): string { + return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0); + } + + /** dd/MM/yyyy, as the printed report dates it. */ + displayDate(iso: string): string { + if (!iso) { + return ''; + } + const [ + y, + m, + d + ] = iso.split('-'); + return `${d}/${m}/${y}`; + } + + /** + * Builds the report as a real PDF rather than printing the page: window.print() takes the whole + * app shell with it and breaks pages wherever they happen to fall, which is what made the earlier + * output unusable. + */ + downloadPdf(): void { + if (!this.debitSide || !this.creditSide) { + return; + } + const doc = buildBalanceSheetPdf({ + bankName: this.bankName, + officeLabel: this.officeLabel, + asOnDate: this.asOnDateLoaded || this.asOnDate, + creditSide: this.creditSide, + debitSide: this.debitSide + }); + doc.save(`balance-sheet-${this.asOnDateLoaded || this.asOnDate}.pdf`); + } +} diff --git a/src/app/accounting/disabled-features.ts b/src/app/accounting/disabled-features.ts new file mode 100644 index 000000000..32c9c30eb --- /dev/null +++ b/src/app/accounting/disabled-features.ts @@ -0,0 +1,43 @@ +/** + * 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 { inject } from '@angular/core'; +import { Router, CanActivateFn } from '@angular/router'; + +/** + * Accounting features switched off for this installation. + * + * Nothing is deleted — the components, routes and services all stay in place, and flipping a flag + * back to `false` brings the feature back with its tile and its route. The matching API block lives + * in DisabledFeatureApiFilter on the backend, so a hidden feature cannot be reached by calling the + * endpoint directly either. + */ +export const DISABLED_ACCOUNTING_FEATURES = { + createJournalEntries: true, + frequentPostings: true, + closingEntries: true, + accruals: true, + provisioningEntries: true, + accountingRules: true +}; + +/** + * Blocks a route belonging to a disabled feature, for anyone who reaches it by typing the URL + * rather than through a tile. `data.feature` names the flag to check. + */ +export const accountingFeatureEnabledGuard: CanActivateFn = (route) => { + const router = inject(Router); + const feature = route.data?.['feature'] as keyof typeof DISABLED_ACCOUNTING_FEATURES | undefined; + + if (!feature || !DISABLED_ACCOUNTING_FEATURES[feature]) { + return true; + } + + router.navigate(['/accounting'], { replaceUrl: true }); + return false; +}; diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html index d6e56e66e..8c6cb227e 100644 --- a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html @@ -49,119 +49,144 @@ } @if (glStatus && !glError) { - -
- account_balance -
-
Cash GL Account
-
({{ glStatus.glCode }}) {{ glStatus.glAccountName }}
- @if (glHasBalance) { + @if (todayAlreadyOpen) { + +
+ lock +
+
Business Day Already Open
+
+ {{ todayOpenInfo.businessDate }} — opened by {{ todayOpenInfo.openedByName }}, opening cash ₹{{ + todayOpenInfo.openingCash | number: '1.2-2' + }} +
- Already holds - ₹{{ glTarget.toLocaleString('en-IN') }} - — count the drawer to exactly this amount. + This branch has already opened today's business day — counting the drawer again here would only be rejected + on submit. Deposits, withdrawals and closing today's day happen from Day Register instead.
- } @else { -
- No opening balance posted yet — whatever you count below becomes this account's opening balance. -
- } -
-
- - - - -
-
Denomination
-
Count
-
Amount
+
- @for (d of allDenoms; track d.id) { -
-
- {{ d.label }} {{ d.kind }} -
-
- - - -
-
₹{{ subtotalFor(d).toLocaleString('en-IN') }}
-
- } -
-
- - -
-
- Counted Total - ₹{{ countedTotal.toLocaleString('en-IN') }}
- @if (glHasBalance) { -
- GL Target - ₹{{ glTarget.toLocaleString('en-IN') }} + } @else { + +
+ account_balance +
+
Cash GL Account
+
({{ glStatus.glCode }}) {{ glStatus.glAccountName }}
+ @if (glHasBalance) { +
+ Already holds + ₹{{ glTarget.toLocaleString('en-IN') }} + — count the drawer to exactly this amount. +
+ } @else { +
+ No opening balance posted yet — whatever you count below becomes this account's opening balance. +
+ }
- } -
- @if (countedTotal <= 0) { - edit_note - Count each denomination above - } @else if (isReconciled) { - check_circle - {{ glHasBalance ? 'Matches the GL balance' : 'Will define the GL opening balance' }} - } @else { - error - Off by ₹{{ - (countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString('en-IN') - }} - }
-
- @if (needsDifferenceReason) { - + + - - Why does the count differ from the GL balance? - - +
+
Denomination
+
Count
+
Amount
+
+ @for (d of allDenoms; track d.id) { +
+
+ {{ d.label }} {{ d.kind }} +
+
+ + + +
+
₹{{ subtotalFor(d).toLocaleString('en-IN') }}
+
+ }
- } - @if (errorMsg) { -
{{ errorMsg }}
- } - - @if (successMsg) { -
- check_circle -
- {{ successMsg }} -
- + +
+
+ Counted Total + ₹{{ countedTotal.toLocaleString('en-IN') }} +
+ @if (glHasBalance) { +
+ GL Target + ₹{{ glTarget.toLocaleString('en-IN') }}
+ } +
+ @if (countedTotal <= 0) { + edit_note + Count each denomination above + } @else if (isReconciled) { + check_circle + {{ glHasBalance ? 'Matches the GL balance' : 'Will define the GL opening balance' }} + } @else { + error + Off by ₹{{ + (countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString( + 'en-IN' + ) + }} + }
- } - @if (!successMsg) { -
- -
+ @if (needsDifferenceReason) { + + + + Why does the count differ from the GL balance? + + + + + } + + @if (errorMsg) { +
{{ errorMsg }}
+ } + + @if (successMsg) { +
+ check_circle +
+ {{ successMsg }} +
+ +
+
+
+ } + + @if (!successMsg) { +
+ +
+ } } }
diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss index 6dcf31aa0..c68d75abd 100644 --- a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss @@ -115,6 +115,10 @@ color: #64748b; line-height: 1.4; } + + button { + margin-top: 10px; + } } .mcd-grid { @@ -171,7 +175,11 @@ } &__input { - width: 48px; + /* Width is set inline per row (see countInputWidthCh()), growing with the digit count typed — + min-width here only covers the fallback before Angular has bound anything. */ + min-width: 48px; + max-width: 96px; + transition: width 0.1s ease-out; text-align: center; font-variant-numeric: tabular-nums; border: 1px solid #e2e8f0; diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts index a66fb5651..2509a0fce 100644 --- a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts @@ -66,6 +66,14 @@ export class MigrateCashDenominationComponent implements OnInit { glStatus: any = null; glError = ''; + /** Set once per office change: whether TODAY's business day is already open for this office. This + * screen is a one-time "day 1" tool (see the class doc) - once a day has actually been opened, + * reopening the same one is exactly what BranchDayRegisterApiResource#migrateCashDenomination + * itself refuses (its own openDayInternal call rejects a day that is already open), so this is + * checked here too, before the user counts an entire drawer only to have the submit rejected. */ + todayAlreadyOpen = false; + todayOpenInfo: any = null; + notes: Denom[] = []; coins: Denom[] = []; counts: Record = {}; @@ -118,6 +126,8 @@ export class MigrateCashDenominationComponent implements OnInit { this.result = null; this.successMsg = ''; this.errorMsg = ''; + this.todayAlreadyOpen = false; + this.todayOpenInfo = null; if (!this.officeId) return; this.glLoading = true; @@ -131,6 +141,15 @@ export class MigrateCashDenominationComponent implements OnInit { this.glError = res.message || 'No cash GL account is configured for this tenant yet.'; } }); + + this.service + .branchDayRegister(this.officeId) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + const openDay = res?.openDay; + this.todayAlreadyOpen = !!openDay && openDay.businessDate === res?.today; + this.todayOpenInfo = this.todayAlreadyOpen ? openDay : null; + }); } countFor(d: Denom): number { @@ -149,6 +168,12 @@ export class MigrateCashDenominationComponent implements OnInit { this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta); } + /** Widens the count box as the typed count grows more digits, so a large count (hundreds, + * thousands) is never clipped or hard to read inside a field sized for one or two digits. */ + countInputWidthCh(d: Denom): number { + return Math.max(3, String(this.countFor(d)).length + 2); + } + get countedTotal(): number { return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0); } diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html index 728b7cf77..ac06c467a 100644 --- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html +++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html @@ -162,7 +162,18 @@
{{ openingBalancesData.glAccounts[i].glAccountCode }}
-
{{ openingBalancesData.glAccounts[i].glAccountName }}
+
+ {{ openingBalancesData.glAccounts[i].glAccountName }} + @if (isDenominationManaged(openingBalancesData.glAccounts[i].glAccountId)) { + + lock + Migrate Cash Denomination + + } +
diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss index 14cf2ca5a..578f9a0ee 100644 --- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss +++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss @@ -219,6 +219,33 @@ word-break: break-word; } + /* Cash at Vault / Cash at Teller — locked, see MigrateOpeningBalancesComponent.isDenominationManaged. */ + &__lock { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 8px; + padding: 1px 8px 1px 6px; + border-radius: 999px; + background: #fff7ed; + color: #9a3412; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + vertical-align: middle; + + mat-icon { + font-size: 13px; + width: 13px; + height: 13px; + } + + a { + color: inherit; + text-decoration: underline; + } + } + &__amt { text-align: right; } diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts index b1636d9df..0a4bcf291 100644 --- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts +++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts @@ -17,6 +17,7 @@ import { } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; /** Custom Services */ import { AccountingService } from '../accounting.service'; @@ -43,7 +44,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; ...STANDALONE_SHARED_IMPORTS, GlAccountDisplayComponent, CurrencyPipe, - MatIconModule + MatIconModule, + MatTooltipModule ] }) export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { @@ -74,6 +76,13 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { debitsSum = 0; /** Sum total of credits. */ creditsSum = 0; + /** GL account ids mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) — the + * account(s) Migrate Cash Denomination owns. Their opening balance is shown here for visibility + * but locked against editing: redefining it as a bare number here, instead of through a counted + * denomination breakdown, would let the GL figure drift away from what is physically in the till. + * The backend enforces the same rule independently (see defineOpeningBalance's + * validateNoDenominationManagedCashAccounts) — this is a convenience, not the source of truth. */ + denominationManagedGlAccountIds: number[] = []; /* Reference of search form */ @ViewChild('searchFormRef') searchFormRef: ElementRef; @@ -95,6 +104,22 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { this.officeData = data.offices; this.currencyData = data.currencies.selectedCurrencyOptions; }); + // Office-independent: which GL account(s) this tenant has mapped to Cash at Vault / Cash at + // Teller, so their row can be locked the moment the office's accounts are drawn. + this.accountingService.getFinancialActivityAccounts().subscribe((mappings: any[]) => { + this.denominationManagedGlAccountIds = (mappings || []) + .filter((mapping) => mapping.financialActivityData?.id === 101 || mapping.financialActivityData?.id === 102) + .map((mapping) => mapping.glAccountData?.id) + .filter((id) => id != null); + // Retrieve may already have run before this (fast) request came back — lock any row it + // missed rather than leaving it editable for the rest of this visit. + this.glAccountEntries?.controls.forEach((control) => { + const glAccountId = control.get('glAccountId')?.value; + if (this.isDenominationManaged(glAccountId) && control.enabled) { + control.disable(); + } + }); + }); } /** @@ -136,14 +161,31 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { * @returns {FormGroup} GL Account entry form. */ createGLAccountEntryForm(glAccount: any): UntypedFormGroup { - return this.formBuilder.group( + // A never-touched account arrives as a placeholder row (amount/entryType both null); one that + // already carries an opening balance arrives with its real, committed figure — show that + // instead of leaving the field looking blank. + const existingDebit = glAccount.entryType?.value === 'DEBIT' ? glAccount.amount : null; + const existingCredit = glAccount.entryType?.value === 'CREDIT' ? glAccount.amount : null; + const group = this.formBuilder.group( { glAccountId: [glAccount.glAccountId], - debit: [null], - credit: [null] + debit: [existingDebit], + credit: [existingCredit] }, { validator: onlyOneOfTheFieldsIsRequiredValidator } ); + if (this.isDenominationManaged(glAccount.glAccountId)) { + // Disabling excludes this row from the form's own .value, so it is never resubmitted here — + // the account keeps whatever Migrate Cash Denomination last set it to. + group.disable(); + } + return group; + } + + /** Whether this GL account is Cash at Vault / Cash at Teller — locked here, see + * denominationManagedGlAccountIds. */ + isDenominationManaged(glAccountId: number): boolean { + return this.denominationManagedGlAccountIds.includes(glAccountId); } /** @@ -176,17 +218,30 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { this.openingBalancesData = openingBalancesData; - entry.valueChanges.subscribe(() => { - this.debitsSum = 0; - this.creditsSum = 0; - entry.controls.forEach((value) => { - this.debitsSum += value.value.debit; - this.creditsSum += value.value.credit; - }); - }); + entry.valueChanges.subscribe(() => this.recomputeSums()); + // Runs once immediately too: valueChanges only fires on a later edit, and the locked cash + // row's own prefilled figure (set once, above, never through a further value change) would + // otherwise never be reflected in the banner at all. + this.recomputeSums(); }); } + /** Total Debit / Total Credit shown in the balance banner. A locked (denomination-managed) row is + * excluded here exactly as it is excluded from the form's own .value used at submit time — this + * screen only needs to balance the accounts it is itself about to (re)define, not the one + * Migrate Cash Denomination already settled and this form can never touch. */ + private recomputeSums(): void { + this.debitsSum = 0; + this.creditsSum = 0; + this.glAccountEntries.controls.forEach((control) => { + if (control.disabled) { + return; + } + this.debitsSum += control.value.debit || 0; + this.creditsSum += control.value.credit || 0; + }); + } + /** * Submits the opening balances form and defines opening balances, * if successful redirects to view created transaction. 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 f8dacebff..99e81b425 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 @@ -53,10 +53,13 @@ Refresh - + + @if (!disabledFeatures.createJournalEntries) { + + }
diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts index 39c8e1694..cf8330511 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts @@ -14,6 +14,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router'; import { merge } from 'rxjs'; import { tap, startWith, map, distinctUntilChanged, debounceTime } from 'rxjs/operators'; import { AccountingService } from '../accounting.service'; +import { DISABLED_ACCOUNTING_FEATURES } from '../disabled-features'; import { SettingsService } from 'app/settings/settings.service'; import { JournalEntriesDataSource } from './journal-entry.datasource'; import { Dates } from 'app/core/utils/dates'; @@ -90,6 +91,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView // ── Misc ────────────────────────────────────────────────────────────────── minDate = new Date(2000, 0, 1); maxDate = new Date(); + /** Accounting features switched off for this installation — see disabled-features.ts. */ + disabledFeatures = DISABLED_ACCOUNTING_FEATURES; // ── Filter controls ─────────────────────────────────────────────────────── officeName = new UntypedFormControl(); diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index e5db06861..65d27eb6e 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -49,6 +49,18 @@
} + @if (firstOpenNeedsCashMigration && !missedOnly) { +
+ lock + + This branch has never opened a business day, and its Cash at Vault GL account has no opening balance yet. Count + the vault and open the first business day together from + Migrate Cash Denomination — Open Day here is + locked until that is done, so today's cash tracking never starts out of step with the ledger. + +
+ } + @if (missed.length > 0) {
@@ -703,10 +715,10 @@ · {{ timeOnly(d.closedAt) }} } - {{ d.closingCash != null ? '₹' + (d.closingCash | number: '1.2-2') : '—' }} - {{ d.bookCash != null ? '₹' + (d.bookCash | number: '1.2-2') : '—' }} + {{ hasValue(d.closingCash) ? '₹' + (d.closingCash | number: '1.2-2') : '—' }} + {{ hasValue(d.bookCash) ? '₹' + (d.bookCash | number: '1.2-2') : '—' }} - @if (d.cashDifference == null) { + @if (!hasValue(d.cashDifference)) { — } @else if (d.cashDifference === 0) { Tallied diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss index fa49ed69d..c97aebcc9 100644 --- a/src/app/cashier/branch-day/branch-day.component.scss +++ b/src/app/cashier/branch-day/branch-day.component.scss @@ -30,6 +30,36 @@ } } +/* Blocks Open Day until the branch's first-ever open has gone through Migrate Cash Denomination — + see BranchDayComponent.checkFirstOpenCashMigration(). Same shape as .ve-note, in the error palette + the rest of this screen already uses for a missed close (.bd-missed). */ +.bd-locked { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 14px; + margin-bottom: 14px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #b91c1c; + font-size: 12px; + line-height: 1.5; + + mat-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + } + + a { + color: inherit; + font-weight: 600; + text-decoration: underline; + } +} + .bd-success { padding: 10px 14px; margin-bottom: 14px; diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts index 579a3364a..da476f658 100644 --- a/src/app/cashier/branch-day/branch-day.component.ts +++ b/src/app/cashier/branch-day/branch-day.component.ts @@ -10,7 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, RouterLink } from '@angular/router'; import { catchError, forkJoin, map, of, switchMap } from 'rxjs'; import { CashierService } from '../cashier.service'; @@ -61,7 +61,8 @@ interface BranchDay { imports: [ CommonModule, FormsModule, - MatIconModule + MatIconModule, + RouterLink ] }) export class BranchDayComponent implements OnInit { @@ -85,6 +86,12 @@ export class BranchDayComponent implements OnInit { loaded = false; errorMsg = ''; successMsg = ''; + /** True only for a branch that has never opened a business day and whose Cash at Vault / Cash at + * Teller GL account has never had anything posted to it — Open Day here would let day-to-day cash + * tracking begin while the ledger silently stays wherever it already was. The backend enforces + * this independently too (see BranchDayRegisterApiResource#requireCashMigrationBeforeFirstEverOpen); + * this flag only steers the user to the right screen before they hit that rejection. */ + firstOpenNeedsCashMigration = false; // Open-day form showOpenForm = false; @@ -202,6 +209,7 @@ export class BranchDayComponent implements OnInit { this.today = res?.today ?? ''; this.canOperate = !!res?.canOperate; this.openDate = this.today; + this.checkFirstOpenCashMigration(); }); this.service @@ -212,9 +220,42 @@ export class BranchDayComponent implements OnInit { }); } + /** + * Only relevant the moment this branch has never opened a business day before — every later + * open/close on this screen is untouched, so there is nothing to re-check once history exists. + */ + private checkFirstOpenCashMigration(): void { + if (this.days.length > 0 || this.openDay) { + this.firstOpenNeedsCashMigration = false; + return; + } + this.service.cashGlStatus().subscribe({ + next: (status: any) => { + // configured === false means this tenant has no Cash at Vault / Cash at Teller account + // mapped at all — nothing for this check to reconcile against, so it does not block. + this.firstOpenNeedsCashMigration = status?.configured !== false && !status?.hasBalance; + }, + error: () => { + this.firstOpenNeedsCashMigration = false; + } + }); + } + /** True when the branch has no open day at all, so today can be opened. */ get canOpenToday(): boolean { - return this.canOperate && !this.openDay; + return this.canOperate && !this.openDay && !this.firstOpenNeedsCashMigration; + } + + /** + * A still-open day's closing/book figures are dropped from the API response entirely (the + * backend's Gson instance omits a null map value rather than sending it as JSON null), so the + * field arrives as undefined, not null. Checking only `!== null` against that — which a stricter + * pass of this file made the mistake of doing — leaves undefined on the "has a value" branch, + * which then renders `'₹' + null` (the number pipe's own null output, string-concatenated) as the + * literal text "₹null". This checks for either, the way an open day actually shows up. + */ + hasValue(value: number | null | undefined): boolean { + return value !== null && value !== undefined; } startOpen(): void { diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index 5a49e956d..6f33ad242 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -3189,7 +3189,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Step 1: search on the correct endpoint for this account type this.http .get( - `${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}` + `${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}${cfg.extraListQueryParams ? `&${cfg.extraListQueryParams}` : ''}` ) .pipe(catchError(() => of(null))) .subscribe((res: any) => { @@ -3278,6 +3278,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { matchField: string; detailParams: string; supportsPagination: boolean; + /** Extra fixed query params (e.g. "usage=1") appended to every list-search request for this + * account type — used to keep Header GL accounts out of the results a clerk/cashier can pick + * for a transaction; a Header can never receive a posting, so it should never be offered. */ + extraListQueryParams?: string; } { const t = acTyp?.toUpperCase(); if (t === 'LA') { @@ -3331,7 +3335,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { listQueryParam: 'searchParam', matchField: 'glCode', detailParams: '?fetchRunningBalance=true', - supportsPagination: false + supportsPagination: false, + // Detail-only: a Header account is a pure grouping node and can never receive a + // transaction, so it must never be a selectable match here in the first place. + extraListQueryParams: 'usage=1' }; } if (t === 'CA' || t === 'CB') { diff --git a/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts index 7d01f68d9..99cbd5959 100644 --- a/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts +++ b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts @@ -164,6 +164,13 @@ export class ContactOtpBlockComponent implements OnChanges, OnDestroy { request$.subscribe({ next: (res: any) => { this.sending = false; + // The backend reports failure to actually dispatch the OTP (gateway error, flagged + // number, SMTP failure...) as a 200 response with success:false and a real reason in + // `message` — never a thrown error. Surface that reason instead of treating it as sent. + if (res?.success === false) { + this.errorMessage = res?.message || 'Could not send OTP.'; + return; + } this.otpId = res?.otpId ?? res?.resourceId ?? null; this.otpSent = true; this.clearAllDigits(); diff --git a/src/app/core/shell/sidenav/sidenav.component.html b/src/app/core/shell/sidenav/sidenav.component.html index 95778bb7c..a8ae77c84 100644 --- a/src/app/core/shell/sidenav/sidenav.component.html +++ b/src/app/core/shell/sidenav/sidenav.component.html @@ -299,32 +299,38 @@ {{ 'labels.menus.Notifications' | translate }} - - - - - {{ 'labels.menus.Frequent Postings' | translate }} - - - - - - {{ 'labels.menus.Create Journal Entry' | translate }} - + + @if (!disabledFeatures.frequentPostings) { + + + + + {{ 'labels.menus.Frequent Postings' | translate }} + + } + @if (!disabledFeatures.createJournalEntries) { + + + + + {{ 'labels.menus.Create Journal Entry' | translate }} + + } Holiday Calendar - Financial Year / Closing Entries + + @if (!disabledFeatures.closingEntries) { + Financial Year / Closing Entries + } Business Date (Day Register) diff --git a/src/app/organization/institution-profile/institution-profile.component.ts b/src/app/organization/institution-profile/institution-profile.component.ts index 41e8b41e5..2d04f9480 100644 --- a/src/app/organization/institution-profile/institution-profile.component.ts +++ b/src/app/organization/institution-profile/institution-profile.component.ts @@ -12,6 +12,7 @@ import { catchError, of } from 'rxjs'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { OrganizationService } from '../organization.service'; import { SettingsService } from 'app/settings/settings.service'; +import { DISABLED_ACCOUNTING_FEATURES } from '../../accounting/disabled-features'; const BANK_TYPES = [ { value: 'COOPERATIVE', label: 'Co-operative Bank' }, @@ -63,6 +64,8 @@ export class InstitutionProfileComponent implements OnInit { private organizationService = inject(OrganizationService); private settingsService = inject(SettingsService); + /** Accounting features switched off for this installation — see accounting/disabled-features.ts. */ + disabledFeatures = DISABLED_ACCOUNTING_FEATURES; bankTypes = BANK_TYPES; ownershipTypes = OWNERSHIP_TYPES; licenseStatuses = LICENSE_STATUSES; diff --git a/src/app/platform-admin/menu-access/menu-access.component.ts b/src/app/platform-admin/menu-access/menu-access.component.ts index e98bcfebd..c5607a687 100644 --- a/src/app/platform-admin/menu-access/menu-access.component.ts +++ b/src/app/platform-admin/menu-access/menu-access.component.ts @@ -104,7 +104,8 @@ export class PlatformMenuAccessComponent { 'X-Api-Key': this.apiKey, // Any real tenant satisfies the platform's tenant-resolution filter; the target tenant for // read/write is this header's own value, so it is set to whichever tenant is actually in play. - 'Fineract-Platform-TenantId': tenantForFilter || this.selectedTenant || (this.tenants[0]?.identifier ?? 'default') + 'Fineract-Platform-TenantId': + tenantForFilter || this.selectedTenant || this.tenants[0]?.identifier || environment.fineractPlatformTenantId }; } @@ -126,7 +127,9 @@ export class PlatformMenuAccessComponent { this.rememberKey(); this.loadingTenants = true; try { - const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), { headers: this.headers('default') }); + const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), { + headers: this.headers(environment.fineractPlatformTenantId) + }); if (!res.ok) { throw new Error( res.status === 403 || res.status === 401 ? 'Invalid or inactive API key.' : `Server error (${res.status}).` diff --git a/src/app/products/share-products/create-share-product/create-share-product.component.html b/src/app/products/share-products/create-share-product/create-share-product.component.html index 89efc79c2..3d5ee4a18 100644 --- a/src/app/products/share-products/create-share-product/create-share-product.component.html +++ b/src/app/products/share-products/create-share-product/create-share-product.component.html @@ -110,8 +110,7 @@ [shareProductsTemplate]="shareProductsTemplate" [accountingRuleData]="accountingRuleData" [shareProduct]="shareProduct" - [taskPermission]="'CREATE_SHAREPRODUCT'" - (submitEvent)="submit()" + [showActions]="false" >
diff --git a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html index 91c9f9ec0..4bc274f94 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html +++ b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html @@ -250,11 +250,13 @@
-
- - -
+@if (showActions) { +
+ + +
+} diff --git a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts index 1c1fc2c11..c725721bb 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts +++ b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts @@ -52,6 +52,10 @@ export class ShareProductPreviewStepComponent { @Input() accountingRuleData: any; @Input() shareProduct: any; @Input() taskPermission: string; + /** Set to false when the host page already renders its own Cancel/Submit bar for this step + * (the Create flow's shared bottom action bar) — Edit's stepper has no such bar of its own, so + * this defaults to true and keeps rendering the button there. */ + @Input() showActions = true; @Output() submitEvent = new EventEmitter(); marketPriceDisplayedColumns: string[] = [ diff --git a/src/app/savings/create-savings-account/create-savings-account.component.html b/src/app/savings/create-savings-account/create-savings-account.component.html index dfbf67e7c..f90406dab 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.html +++ b/src/app/savings/create-savings-account/create-savings-account.component.html @@ -91,16 +91,10 @@ [savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []" [submitting]="submitting" (submitEvent)="submit()" + (previousEvent)="prev()" > } - -
- -
diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html index 44831e2d5..d67ed5cbb 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.html @@ -585,25 +585,30 @@
@if (!hideButtons) {
- - +
+ + +
} diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss index cffbf47eb..8c772a802 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss @@ -13,17 +13,21 @@ padding: 0.5rem 0; } -/* Submit + Cancel row at the bottom of the preview. Previous lives in the - parent wizard's tab action bar. */ +/* Previous, Cancel & Submit row at the bottom of the preview. */ .preview-submit-row { display: flex; - justify-content: flex-end; + justify-content: space-between; align-items: center; - gap: 12px; margin-top: 18px; padding-top: 16px; border-top: 1px solid #f1f5f9; + &__right { + display: flex; + align-items: center; + gap: 12px; + } + button { height: 42px !important; border-radius: 10px !important; diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts index 880e51d90..c86dc9198 100644 --- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts @@ -116,6 +116,9 @@ export class SavingsAccountPreviewStepComponent implements OnChanges { /** Form submission event */ @Output() submitEvent = new EventEmitter(); + /** Goes back a tab in the parent wizard — kept here so Previous renders in the same row as + * Cancel/Submit instead of a separate action bar underneath. */ + @Output() previousEvent = new EventEmitter(); ngOnChanges(): void { if (this.activeClientMembers?.length > 0) { diff --git a/src/app/shares/shares-account-view/shares-account-view.component.ts b/src/app/shares/shares-account-view/shares-account-view.component.ts index e8318cff0..84103260f 100644 --- a/src/app/shares/shares-account-view/shares-account-view.component.ts +++ b/src/app/shares/shares-account-view/shares-account-view.component.ts @@ -149,6 +149,13 @@ export class SharesAccountViewComponent implements OnInit { // capital that was never issued. if (!this.isSettled) { this.buttonConfig.removeOption('Share Certificate'); + } else { + // "Apply Collection" settles the account's ORIGINAL application funding -- a one-time + // step. Once collectionApplied is true there is nothing left to settle this way; any + // further share purchases on this account go through "Apply Additional Shares" instead, + // which raises and settles its own tranche. Leaving this button up after settlement invited + // reopening the dialog against an account with no unapplied receipt to find. + this.buttonConfig.removeButton('Apply Collection'); } } } diff --git a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts index 3426b8bda..b4516b3c4 100644 --- a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts +++ b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts @@ -206,7 +206,9 @@ export class GlSettingsDialogComponent implements OnInit { loadGlAccountsAndMapping(): void { this.loading = true; - this.http.get('/glaccounts?manualEntriesAllowed=true&disabled=false').subscribe({ + // usage=1 (Detail only): these settings map real Sponsor DD transactions onto a GL account, and + // a Header account can never receive one, so it must never be offered here. + this.http.get('/glaccounts?manualEntriesAllowed=true&disabled=false&usage=1').subscribe({ next: (accounts) => { this.glAccounts = accounts || []; this.sponsorDDService.getGlMappings().subscribe({ diff --git a/src/assets/env.js b/src/assets/env.js index 3b7c7551c..e7ec0e8c1 100644 --- a/src/assets/env.js +++ b/src/assets/env.js @@ -20,8 +20,8 @@ // Tenants the login screen offers, and which one it starts on. Every identifier here has to exist // in the tenants table of the tenant-store database, or the platform rejects the request as an // unknown tenant. - window["env"]["fineractPlatformTenantId"] = 'default'; - window["env"]["fineractPlatformTenantIds"] = 'default,abc_bank,socity_bank'; + window["env"]["fineractPlatformTenantId"] = 'lincpay'; + window["env"]["fineractPlatformTenantIds"] = 'lincpay'; window['env']['tenantLogoUrl'] = ''; window['env']['tenantLogoUrlDark'] = ''; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index cffbec166..d7885aac8 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -19,8 +19,8 @@ export const environment = { version: env.mifos_x.version, hash: env.mifos_x.hash, // For connecting to server running elsewhere update the tenant identifier - fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'default', - fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'default', + fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'lincpay', + fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'lincpay', // For connecting to others servers running elsewhere update the base API URL baseApiUrls: loadedEnv['fineractApiUrls'] || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin, diff --git a/src/environments/environment.ts b/src/environments/environment.ts index f4cf45cb8..700ff10f9 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -22,8 +22,8 @@ export const environment = { version: env.mifos_x.version, hash: env.mifos_x.hash, // For connecting to server running elsewhere update the tenant identifier - fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'default', - fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'default', + fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'lincpay', + fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'lincpay', // For connecting to others servers running elsewhere update the base API URL baseApiUrls: loadedEnv.fineractApiUrls || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,