diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index 57d49000e..9c13b940d 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -352,6 +352,51 @@ } + + @if (preview?.fdInterestDue?.length) { +
+
+ savings + Fixed deposit interest due today +
+ @for (d of preview.fdInterestDue; track d.productId) { +
+ + {{ d.productName }} · {{ d.fdType }} · {{ d.accountCount }} deposit(s) + @if (d.reportStatus === 'APPROVED' || d.reportStatus === 'POSTED') { + {{ d.reportStatus }} + } @else { + {{ d.reportStatus === 'NONE' ? 'NO REPORT' : d.reportStatus }} + } + + ₹{{ d.totalInterest | number: '1.2-2' }} +
+ } +
+ } + @if (preview?.fdInterestPending?.length) { +
+ warning +
+ The day cannot be closed yet. Fixed deposit interest is not approved for: + {{ preview.fdInterestPending.join(', ') }}. Approve it under Reports → FD Interest Posting Reports, then + close the day. The user who generated a report cannot approve it. +
+
+ } + @if (preview?.savingsInterestPending?.length) { +
+ warning +
+ The day cannot be closed yet. Savings interest is not approved for: + {{ preview.savingsInterestPending.join(', ') }}. Approve it under Reports → Interest Posting Reports, then + close the day. +
+
+ } + diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss index 449dfced1..e8d2b209a 100644 --- a/src/app/cashier/branch-day/branch-day.component.scss +++ b/src/app/cashier/branch-day/branch-day.component.scss @@ -195,6 +195,75 @@ font-size: 11px; color: #94a3b8; } + + /* Names the block, for the interest panels that sit beside the cash arithmetic. */ + &__head { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px solid #e2e8f0; + font-size: 13px; + color: #0d1b2a; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #1d4ed8; + } + } +} + +/* Interest is money leaving as the day closes, not cash counted in the drawer. */ +.bd-calc--fd { + border-left: 3px solid #1d4ed8; +} + +.bd-fd-ok, +.bd-fd-pending { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.bd-fd-ok { + background: #dcfce7; + color: #15803d; +} + +.bd-fd-pending { + background: #fef3c7; + color: #b45309; +} + +/* Why the close will be refused, said before the count is filled in rather than after. */ +.bd-warn { + display: flex; + align-items: flex-start; + gap: 8px; + max-width: 640px; + margin-bottom: 14px; + padding: 10px 14px; + background: #fffbeb; + border: 1px solid #fcd34d; + border-radius: 8px; + font-size: 12px; + line-height: 1.5; + color: #78350f; + + mat-icon { + flex-shrink: 0; + font-size: 20px; + width: 20px; + height: 20px; + color: #b45309; + } } .bd-hint { diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 27da072b6..437da9532 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -666,6 +666,9 @@ + diff --git a/src/app/fd-interest-posting-report/fd-interest-posting-report.component.html b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.html new file mode 100644 index 000000000..6bbb1aa36 --- /dev/null +++ b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.html @@ -0,0 +1,412 @@ + + +
+
+
+

FD Interest Posting Reports

+
+ Each branch gets its own report for each fixed deposit scheme, generated automatically at 12:00 noon on the + posting date. One user puts the figures up and a second approves them — whoever generated a report cannot + approve it. Approved interest posts when the branch closes its day, and it cannot close until then. +
+
+
+ + + +
+
+ + @if (!approvalEnabled) { +
+ +
+ Fixed deposit interest is currently posted directly by the nightly job, so there is nothing to approve here. + Turn on fd-interest-posting-approval under System → Configurations to put deposits behind + this report. +
+
+ } + + @if (isHeadOffice) { +
+ + +
+ @if (tab === 'others') { +
+ +
+ Each branch generates and approves its own reports. The head office can act here only for a branch that has + not opened the day, and only once every other open branch has closed. Anything it approves is marked (HO). +
+
+ } + } + + @if (errorMessage) { +
+ +
{{ errorMessage }}
+
+ } + @if (notice) { +
+ +
{{ notice }}
+
+ } + @if (pendingCount > 0) { +
+ +
+ {{ pendingCount }} report(s) waiting for approval. The day cannot be closed until they are + approved, and the user who generated a report cannot be the one to approve it. +
+
+ } + + @if (due.length > 0) { +
+ +
+
Fixed deposit interest is due but no report has been generated yet for:
+ @for (d of due; track d.productId + '-' + d.officeId) { +
+ {{ d.productName }} + {{ fdTypeShort(d.fdType) }} + + · {{ d.officeName }} · {{ d.postingDate }} · {{ d.accountCount }} deposit(s) · + ₹{{ d.totalInterest | number: '1.2-2' }} + + @if (d.canAct) { + + } @else if (d.blockedReason) { + {{ d.blockedReason }} + } +
+ } +
+
+ } + + +
+
+
Interest due, no report
+
₹{{ dueTotal | number: '1.2-2' }}
+
{{ dueAccountTotal }} deposit(s) · {{ due.length }} product(s)
+
+
+
Awaiting approval
+
₹{{ awaitingTotal | number: '1.2-2' }}
+
{{ awaitingReports.length }} report(s) · blocking day close
+
+
+
Approved — posts at close
+
₹{{ approvedTotal | number: '1.2-2' }}
+
{{ approvedReports.length }} report(s)
+
+
+
Posted today
+
₹{{ postedTotal | number: '1.2-2' }}
+
{{ postedReports.length }} report(s)
+
+
+ + @if (showPreview) { +
+
+
+

FD interest preview — earned up to {{ previewAsOf }}

+
+ What each deposit would be paid if interest were posted today. View only — nothing is saved, approved + or posted. +
+
+
+ Total so far + ₹{{ previewTotal | number: '1.2-2' }} + {{ previewRows.length }} deposit(s) +
+
+
+ + + +
+ @if (previewLoading) { +
Working out what each deposit has earned up to today…
+ } @else { +
+ + + + + + + + + + + + @for (g of previewGroups; track g.officeName + g.productName) { + + + + + + + + } @empty { + + + + } + +
BranchProductSchemeDepositsInterest so far
{{ g.officeName }} + {{ g.productName }} + + {{ fdTypeShort(g.fdType) }} + {{ g.accounts }}₹{{ g.interest | number: '1.2-2' }}
No fixed deposit has interest waiting to be posted.
+
+ +
+ + + + + + + + + + + + + + + + @for (r of previewRows; track r.fdAccountId + '-' + r.interestPostingDate) { + + + + + + + + + + + + } + + + + + + + +
Account noCustomerSchemeInterest forDepositRate %MaturityPays intoInterest so far
{{ r.accountNo }}{{ r.clientName }} + {{ fdTypeShort(r.fdType) }} + {{ r.interestPostingDate }}₹{{ r.depositAmount | number: '1.2-2' }}{{ r.interestRate | number: '1.2-2' }}{{ r.maturityDate || '—' }}{{ r.payoutAccountNo || 'Stays in the deposit' }}₹{{ r.interest | number: '1.2-2' }}
Total + ₹{{ previewTotal | number: '1.2-2' }} +
+
+ } +
+ } + +
+
+

Reports{{ postingDate ? ' for ' + postingDate : '' }}

+ One report per scheme, per branch +
+
+ + + + + + + + + + + + + + + + + @for (r of reports; track r.id) { + + + + + + + + + + + + + } @empty { + + + + } + +
BranchSchemeTypePosting dateDepositsTotal interestStatusMakerChecker
{{ r.officeName }} + {{ r.productName }} + + {{ fdTypeShort(r.fdType) }} + {{ r.postingDate }}{{ r.accountCount }}₹{{ r.totalInterest | number: '1.2-2' }} + {{ r.status }} + @if (r.failureReason) { +
Posting failed: {{ r.failureReason }}
+ } +
+ {{ r.generatedBy || 'System' }} · {{ r.generationMode }}
{{ + r.generatedAt | date: 'dd MMM, HH:mm' + }} +
+ @if (r.approvedAt) { + {{ r.approvedBy }} + @if (r.approvedOnBehalf) { + (HO) + } +
{{ r.approvedAt | date: 'dd MMM, HH:mm' }} + } @else { + — + } +
+
+ @if (canRegenerate(r)) { + + } + @if (canApprove(r)) { + + } @else if (r.status === 'GENERATED' && r.approveBlockedReason) { + {{ r.approveBlockedReason }} + } +
+
+ @if (loading) { + Loading… + } @else { + No fixed deposit interest posting report for {{ postingDate || 'this date' }}. Reports are generated + at 12:00 noon on a product's posting date. + } +
+
+
+ + @if (selected) { +
+
+
+

+ {{ selected.officeName }} · {{ selected.productName }} — {{ selected.postingDate }} + {{ fdTypeShort(selected.fdType) }} + {{ selected.status }} +

+
{{ fdTypeLabel(selected.fdType) }}
+
+ Dr Interest Expense → Cr {{ selected.payableGlCode }} {{ selected.payableGlName }}; Dr + {{ selected.payableGlCode }} → Cr {{ selected.paidGlCode }} {{ selected.paidGlName }}; then Dr + {{ selected.paidGlCode }} → Cr each deposit below +
+
+
+ Total interest + ₹{{ selected.totalInterest | number: '1.2-2' }} + {{ selected.accountCount }} deposit(s) +
+
+
+ + + + + + + + + + + + + + + + + + @for (l of selected.lines; track l.fdAccountId + '-' + l.interestPostingDate) { + + + + + + + + + + + + + + } + + + + + + + + +
Account noAccount holderInterest forDepositRate %MaturityInterestBalance beforeBalance afterPays intoPosted txn
{{ l.accountNo }}{{ l.clientName }}{{ l.interestPostingDate }}₹{{ l.depositAmount | number: '1.2-2' }}{{ l.interestRate | number: '1.2-2' }}{{ l.maturityDate || '—' }}₹{{ l.interestAmount | number: '1.2-2' }}₹{{ l.balanceBefore | number: '1.2-2' }}₹{{ l.balanceAfter | number: '1.2-2' }}{{ l.payoutAccountNo || 'Stays in the deposit' }}{{ l.fdTransactionId ? '#' + l.fdTransactionId : '—' }}
Total + ₹{{ selected.totalInterest | number: '1.2-2' }} +
+
+
+ } +
diff --git a/src/app/fd-interest-posting-report/fd-interest-posting-report.component.scss b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.scss new file mode 100644 index 000000000..1900de118 --- /dev/null +++ b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.scss @@ -0,0 +1,559 @@ +/** + * 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/. + */ + +:host { + --fd-navy: #101723; + --fd-accent: #1b4d8f; + --fd-accent-dark: #123c70; + --fd-ground: #f7f8fa; + --fd-border: #dfe4eb; + --fd-border-soft: #edf0f4; + --fd-muted: #5b6676; + --fd-text: #3c4757; + --fd-amber: #6b4600; + --fd-amber-dark: #5a3b00; + --fd-amber-tint: #fdf3dc; + --fd-amber-border: #efd9a3; + --fd-amber-wash: #fffcf4; + --fd-green: #185e38; + --fd-green-dark: #1d6f42; + --fd-green-tint: #e2f3e8; + --fd-green-wash: #f3faf5; + --fd-blue-tint: #e6eef8; + --fd-slate-tint: #edf0f4; + + display: block; + font-family: 'IBM Plex Sans', system-ui, sans-serif; + color: var(--fd-navy); +} + +.fd { + max-width: 1320px; + margin: 0 auto; + padding: 20px 4px 56px; +} + +// ── Header ────────────────────────────────────────────────────────────── + +.fd-head { + display: flex; + flex-wrap: wrap; + gap: 24px; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 22px; +} + +.fd-head-t { + margin: 0; + font-family: 'IBM Plex Serif', serif; + font-weight: 600; + font-size: 26px; + letter-spacing: -0.01em; + display: flex; + align-items: center; + gap: 10px; + + fa-icon { + color: var(--fd-accent); + font-size: 20px; + } +} + +.fd-head-s { + max-width: 720px; + margin-top: 8px; + font-size: 13.5px; + line-height: 1.6; + color: var(--fd-muted); +} + +.fd-head-a { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px; +} + +.fd-date { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fd-muted); + + input { + font-family: 'IBM Plex Mono', monospace; + font-size: 13px; + padding: 9px 11px; + min-height: 44px; + border: 1px solid #cdd5df; + border-radius: 7px; + background: #fff; + color: var(--fd-navy); + } +} + +// ── Buttons ───────────────────────────────────────────────────────────── + +.fd-btn { + font-family: inherit; + font-size: 12.5px; + font-weight: 500; + padding: 8px 14px; + min-height: 44px; + border-radius: 7px; + border: 1px solid #cdd5df; + background: #fff; + color: var(--fd-navy); + cursor: pointer; + white-space: nowrap; + + fa-icon { + margin-right: 6px; + } + + &:hover:not([disabled]) { + background: #f2f5f9; + } + + &[disabled] { + opacity: 0.5; + cursor: not-allowed; + } + + &--primary { + background: var(--fd-accent); + border-color: var(--fd-accent); + color: #fff; + + &:hover:not([disabled]) { + background: var(--fd-accent-dark); + } + } + + &--slim { + min-height: 34px; + padding: 6px 11px; + font-size: 12px; + } +} + +// ── Tabs ──────────────────────────────────────────────────────────────── + +.fd-tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; + border-bottom: 1px solid var(--fd-border); +} + +.fd-tab { + font-family: inherit; + font-size: 13px; + font-weight: 500; + padding: 10px 6px; + min-height: 44px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--fd-muted); + cursor: pointer; + + fa-icon { + margin-right: 7px; + } + + &.is-active { + border-bottom-color: var(--fd-accent); + color: var(--fd-accent); + font-weight: 600; + } + + & + .fd-tab { + margin-left: 18px; + } +} + +// ── Alerts ────────────────────────────────────────────────────────────── + +.fd-alert { + display: flex; + gap: 12px; + align-items: flex-start; + margin-bottom: 18px; + padding: 14px 18px; + border-radius: 10px; + font-size: 13px; + line-height: 1.6; + + fa-icon { + margin-top: 2px; + flex-shrink: 0; + } + + &--info { + background: #eef3fa; + border: 1px solid #c8d8ec; + color: #1f3a5c; + } + + &--warn { + background: var(--fd-amber-tint); + border: 1px solid var(--fd-amber-border); + color: var(--fd-amber-dark); + + strong { + color: var(--fd-amber-dark); + } + } + + &--ok { + background: var(--fd-green-wash); + border: 1px solid #bfe3cc; + color: var(--fd-green); + } + + &--error { + background: #fdecea; + border: 1px solid #f3b8b1; + color: #8a2b22; + } +} + +.fd-due-body { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.fd-due-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 0; + + & + .fd-due-row { + border-top: 1px dashed var(--fd-amber-border); + } +} + +.fd-blocked { + font-size: 11.5px; + color: var(--fd-muted); +} + +// ── Summary strip ─────────────────────────────────────────────────────── + +.fd-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; + + @media (width <= 860px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.fd-stat { + background: #fff; + border: 1px solid var(--fd-border); + border-radius: 12px; + padding: 18px 20px; + + &__label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fd-muted); + } + + &__value { + font-family: 'IBM Plex Mono', monospace; + font-variant-numeric: tabular-nums; + font-size: 25px; + font-weight: 500; + letter-spacing: -0.02em; + margin-top: 9px; + } + + &__sub { + font-size: 11.5px; + color: var(--fd-muted); + margin-top: 4px; + } + + &--warn { + border-color: var(--fd-amber-border); + + .fd-stat__label, + .fd-stat__sub { + color: var(--fd-amber); + } + + .fd-stat__value { + color: var(--fd-amber-dark); + } + } + + &--ok { + .fd-stat__label { + color: var(--fd-green); + } + + .fd-stat__value { + color: var(--fd-green); + } + } + + &--posted { + .fd-stat__label { + color: var(--fd-accent); + } + + .fd-stat__value { + color: var(--fd-accent); + } + } +} + +// ── Cards ─────────────────────────────────────────────────────────────── + +.fd-card { + background: #fff; + border: 1px solid var(--fd-border); + border-radius: 12px; + margin-bottom: 20px; + overflow: hidden; +} + +.fd-card-h { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: baseline; + padding: 16px 20px; + border-bottom: 1px solid var(--fd-border); + + h2, + h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + } +} + +.fd-card-sub { + font-size: 12px; + color: var(--fd-muted); +} + +// ── Tables ────────────────────────────────────────────────────────────── + +.fd-table-wrap { + overflow-x: auto; +} + +.fd-table { + border-collapse: collapse; + width: 100%; + + th { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fd-muted); + text-align: left; + padding: 10px 14px; + border-bottom: 1px solid var(--fd-border); + white-space: nowrap; + } + + td { + font-size: 13px; + padding: 12px 14px; + border-bottom: 1px solid var(--fd-border-soft); + vertical-align: top; + } + + tbody tr:last-child td { + border-bottom: 0; + } + + tbody tr.is-clickable { + cursor: pointer; + + &:hover td { + background: #fafbfd; + } + } + + tbody tr.is-selected td { + background: var(--fd-blue-tint); + } + + tfoot tr { + background: var(--fd-ground); + } + + .num { + text-align: right; + font-family: 'IBM Plex Mono', monospace; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } +} + +.fd-mono { + font-family: 'IBM Plex Mono', monospace; + font-variant-numeric: tabular-nums; +} + +.fd-sub { + font-size: 11.5px; + color: var(--fd-muted); + margin-top: 3px; +} + +.fd-empty { + text-align: center; + padding: 30px 16px; + color: var(--fd-muted); + font-size: 13px; +} + +// ── Chips & badges ────────────────────────────────────────────────────── + +.fd-chip { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; + + &--single { + background: var(--fd-slate-tint); + color: var(--fd-text); + } + + &--double { + background: var(--fd-blue-tint); + color: var(--fd-accent); + } + + &--special { + background: var(--fd-amber-tint); + color: var(--fd-amber); + } +} + +.fd-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + white-space: nowrap; + + &--pending { + background: var(--fd-amber-tint); + color: var(--fd-amber); + } + + &--approved { + background: var(--fd-green-tint); + color: var(--fd-green); + } + + &--posted { + background: var(--fd-blue-tint); + color: var(--fd-accent); + } + + &--muted { + background: var(--fd-slate-tint); + color: var(--fd-muted); + } +} + +.fd-behalf { + font-size: 10.5px; + font-weight: 700; + color: var(--fd-accent); + margin-left: 4px; +} + +.fd-fail { + margin-top: 4px; + font-size: 11px; + color: #a32b22; +} + +// ── Detail panel ──────────────────────────────────────────────────────── + +.fd-detail-head { + display: flex; + flex-wrap: wrap; + gap: 20px; + justify-content: space-between; + align-items: flex-start; + padding: 22px 20px; + border-bottom: 1px solid var(--fd-border-soft); + background: #fbfcfd; + + h3 { + margin: 0; + font-family: 'IBM Plex Serif', serif; + font-weight: 600; + font-size: 19px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 9px; + } +} + +.fd-total { + text-align: right; + + span:first-child { + display: block; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fd-muted); + } + + strong { + display: block; + font-family: 'IBM Plex Mono', monospace; + font-variant-numeric: tabular-nums; + font-size: 26px; + font-weight: 500; + margin-top: 6px; + letter-spacing: -0.02em; + } +} + +.muted { + color: var(--fd-muted); + font-size: 12.5px; + margin-top: 4px; +} diff --git a/src/app/fd-interest-posting-report/fd-interest-posting-report.component.ts b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.ts new file mode 100644 index 000000000..28d12d189 --- /dev/null +++ b/src/app/fd-interest-posting-report/fd-interest-posting-report.component.ts @@ -0,0 +1,497 @@ +/** + * 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 { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { SettingsService } from 'app/settings/settings.service'; +import { exportWorkbook } from '../cashier/cashier-export'; +import { + FdInterestDue, + FdInterestPreviewRow, + FdInterestReport, + FdInterestReportDetail, + FdInterestPostingReportService, + FdReportScope, + FdType +} from './fd-interest-posting-report.service'; + +/** + * Fixed deposit interest posting reports. + * + * On a product's posting date the system generates, at noon, the list of what each deposit will be + * paid. One user of the branch reviews it and a second approves it — the maker cannot be the checker, + * because posting interest moves money to customers and, for a special deposit, straight out to their + * savings account. The approved amounts are posted when the branch closes the day, and until then it + * cannot close. Regenerating replaces the report and needs approving again. + */ +@Component({ + selector: 'mifosx-fd-interest-posting-report', + templateUrl: './fd-interest-posting-report.component.html', + styleUrls: ['./fd-interest-posting-report.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent + ] +}) +export class FdInterestPostingReportComponent implements OnInit { + private reportService = inject(FdInterestPostingReportService); + private settingsService = inject(SettingsService); + + postingDate = ''; + loading = false; + busy = false; + errorMessage = ''; + notice = ''; + + reports: FdInterestReport[] = []; + selected: FdInterestReportDetail | null = null; + /** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */ + due: FdInterestDue[] = []; + + /** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */ + tab: FdReportScope = 'own'; + isHeadOffice = false; + myBranch = ''; + /** False where the bank has deposits on the old direct-posting path; every list here is then empty by design. */ + approvalEnabled = true; + + ngOnInit(): void { + this.postingDate = this.businessDateIso(); + this.reportService.context().subscribe({ + next: (ctx) => { + this.isHeadOffice = !!ctx?.headOffice; + this.myBranch = ctx?.officeName ?? ''; + this.approvalEnabled = ctx?.approvalEnabled !== false; + }, + error: () => (this.isHeadOffice = false) + }); + this.load(); + } + + switchTab(tab: FdReportScope): void { + if (this.tab === tab) { + return; + } + this.tab = tab; + this.selected = null; + this.notice = ''; + this.load(); + } + + /** Acting for another branch is the head office's exception, so it is spelt out before it happens. */ + private confirmOnBehalf(officeName: string, action: string): boolean { + if (this.tab !== 'others') { + return true; + } + return confirm( + `${officeName} has not opened today. You are about to ${action} its fixed deposit interest posting report on its ` + + 'behalf, from the head office. Continue?' + ); + } + + load(): void { + this.loading = true; + this.errorMessage = ''; + this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({ + next: (reports) => { + this.reports = reports ?? []; + this.loading = false; + const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id); + if (stillThere) { + this.open(stillThere); + } else { + this.selected = null; + } + }, + error: (err) => { + this.loading = false; + this.errorMessage = this.messageOf(err, 'Could not load the fixed deposit interest posting reports.'); + } + }); + this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({ + next: (due) => (this.due = due ?? []), + error: () => (this.due = []) + }); + } + + generate(item: FdInterestDue): void { + if (!this.confirmOnBehalf(item.officeName, 'generate')) { + return; + } + this.busy = true; + this.notice = ''; + this.errorMessage = ''; + this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({ + next: (res) => { + this.busy = false; + this.notice = `Report generated for ${item.productName} — ${item.officeName}. Another user of the branch has to approve it.`; + this.load(); + this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail)); + }, + error: (err) => { + this.busy = false; + this.errorMessage = this.messageOf(err, 'Could not generate the report.'); + } + }); + } + + open(report: FdInterestReport): void { + this.errorMessage = ''; + this.reportService.detail(report.id).subscribe({ + next: (detail) => (this.selected = detail), + error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.')) + }); + } + + regenerate(report: FdInterestReport): void { + if ( + !confirm( + `Regenerate the fixed deposit interest posting report for ${report.productName} — ${report.officeName}? ` + + 'It will need approving again.' + ) || + !this.confirmOnBehalf(report.officeName, 'regenerate') + ) { + return; + } + this.busy = true; + this.notice = ''; + this.errorMessage = ''; + this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({ + next: (res) => { + this.busy = false; + this.notice = `Report regenerated for ${report.productName}. Another user of the branch has to approve it.`; + this.selected = null; + this.load(); + this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail)); + }, + error: (err) => { + this.busy = false; + this.errorMessage = this.messageOf(err, 'Could not regenerate the report.'); + } + }); + } + + approve(report: FdInterestReport): void { + const payout = + report.fdType === 'SPECIAL' + ? ' This is a monthly-income product, so the interest is paid out to the customers’ linked savings accounts.' + : ''; + if ( + !confirm( + `Approve interest of ₹${report.totalInterest} across ${report.accountCount} deposit(s) for ${report.productName}?` + + `${payout} It will be posted when the branch closes the day.` + ) || + !this.confirmOnBehalf(report.officeName, 'approve') + ) { + return; + } + this.busy = true; + this.notice = ''; + this.errorMessage = ''; + this.reportService.approve(report.id).subscribe({ + next: () => { + this.busy = false; + this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`; + this.load(); + }, + error: (err) => { + this.busy = false; + this.errorMessage = this.messageOf(err, 'Could not approve the report.'); + } + }); + } + + canRegenerate(report: FdInterestReport): boolean { + return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED'); + } + + /** + * The server decides this, not the screen: it alone knows who generated the report, and the maker + * cannot be the checker. + */ + canApprove(report: FdInterestReport): boolean { + return !!report.canApprove; + } + + statusClass(status: string): string { + return ( + { + GENERATED: 'fd-badge--pending', + APPROVED: 'fd-badge--approved', + POSTED: 'fd-badge--posted', + SUPERSEDED: 'fd-badge--muted' + }[status] ?? 'fd-badge--muted' + ); + } + + /** What the scheme means for the customer, spelt out rather than left as a code. */ + fdTypeLabel(fdType: FdType | string): string { + return ( + { + SINGLE: 'Single — interest paid at maturity', + DOUBLE: 'Double — interest compounded into the deposit', + SPECIAL: 'Special — interest paid monthly to linked savings' + }[fdType] ?? String(fdType ?? '') + ); + } + + fdTypeShort(fdType: FdType | string): string { + return ( + { + SINGLE: 'Single', + DOUBLE: 'Double', + SPECIAL: 'Special' + }[fdType] ?? String(fdType ?? '') + ); + } + + fdTypeClass(fdType: FdType | string): string { + return ( + { + SINGLE: 'fd-chip--single', + DOUBLE: 'fd-chip--double', + SPECIAL: 'fd-chip--special' + }[fdType] ?? 'fd-chip--single' + ); + } + + /** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */ + get pendingCount(): number { + return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0; + } + + // ── Summary strip: the day's reports, at a glance ── + + get dueTotal(): number { + return this.due.reduce((sum, d) => sum + Number(d.totalInterest || 0), 0); + } + + get dueAccountTotal(): number { + return this.due.reduce((sum, d) => sum + Number(d.accountCount || 0), 0); + } + + private reportsWithStatus(status: string): FdInterestReport[] { + return this.reports.filter((r) => r.status === status); + } + + private totalOf(list: FdInterestReport[]): number { + return list.reduce((sum, r) => sum + Number(r.totalInterest || 0), 0); + } + + get awaitingReports(): FdInterestReport[] { + return this.reportsWithStatus('GENERATED'); + } + + get approvedReports(): FdInterestReport[] { + return this.reportsWithStatus('APPROVED'); + } + + get postedReports(): FdInterestReport[] { + return this.reportsWithStatus('POSTED'); + } + + get awaitingTotal(): number { + return this.totalOf(this.awaitingReports); + } + + get approvedTotal(): number { + return this.totalOf(this.approvedReports); + } + + get postedTotal(): number { + return this.totalOf(this.postedReports); + } + + // ── Preview: what each deposit has earned so far, as if posted today. View only. ── + + showPreview = false; + previewLoading = false; + previewRows: FdInterestPreviewRow[] = []; + previewAsOf = ''; + + openPreview(): void { + this.showPreview = true; + this.previewLoading = true; + this.errorMessage = ''; + this.previewAsOf = this.businessDateIso(); + this.reportService.preview().subscribe({ + next: (rows) => { + this.previewRows = rows ?? []; + this.previewLoading = false; + }, + error: (err) => { + this.previewLoading = false; + this.previewRows = []; + this.errorMessage = this.messageOf(err, 'Could not load the fixed deposit interest preview.'); + } + }); + } + + closePreview(): void { + this.showPreview = false; + } + + get previewTotal(): number { + return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0); + } + + /** Totals by branch, product and scheme, for the summary above the deposit list and the export. */ + get previewGroups(): { + officeName: string; + productName: string; + fdType: string; + accounts: number; + interest: number; + }[] { + const map = new Map< + string, + { officeName: string; productName: string; fdType: string; accounts: number; interest: number } + >(); + this.previewRows.forEach((r) => { + const key = `${r.officeName}|${r.productName}`; + const g = map.get(key) ?? { + officeName: r.officeName, + productName: r.productName, + fdType: r.fdType, + accounts: 0, + interest: 0 + }; + g.accounts += 1; + g.interest += Number(r.interest || 0); + map.set(key, g); + }); + return [...map.values()]; + } + + exportPreview(): void { + const stamp = `Fixed deposit interest earned to ${this.previewAsOf} — preview, not posted`; + exportWorkbook( + [ + { + sheetName: 'By deposit', + title: 'Fixed Deposit Interest Preview', + subtitle: stamp, + headers: [ + 'Branch', + 'Product', + 'Scheme', + 'Account No', + 'Customer', + 'Interest For', + 'Deposit', + 'Rate %', + 'Maturity', + 'Balance', + 'Pays Into', + 'Interest So Far' + ], + widths: [ + 18, + 22, + 10, + 20, + 26, + 12, + 16, + 8, + 12, + 16, + 18, + 16 + ], + rows: [ + ...this.previewRows.map((r) => [ + r.officeName, + r.productName, + this.fdTypeShort(r.fdType), + r.accountNo, + r.clientName, + r.interestPostingDate ?? '', + Number(r.depositAmount || 0), + Number(r.interestRate || 0), + r.maturityDate ?? '', + Number(r.balance || 0), + r.payoutAccountNo ?? '', + Number(r.interest || 0) + ]), + [ + 'TOTAL', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + this.previewTotal + ] + ] + }, + { + sheetName: 'By branch & product', + title: 'Fixed Deposit Interest Preview — Totals', + subtitle: stamp, + headers: [ + 'Branch', + 'Product', + 'Scheme', + 'Deposits', + 'Interest So Far' + ], + widths: [ + 18, + 22, + 10, + 10, + 16 + ], + rows: [ + ...this.previewGroups.map((g) => [ + g.officeName, + g.productName, + this.fdTypeShort(g.fdType), + g.accounts, + g.interest + ]), + [ + 'TOTAL', + '', + '', + this.previewRows.length, + this.previewTotal + ] + ] + } + ], + `fd-interest-preview-${this.previewAsOf}` + ); + } + + private businessDateIso(): string { + const businessDate = this.settingsService.businessDate as any; + const parsed = businessDate ? new Date(businessDate) : new Date(); + if (isNaN(parsed.getTime())) { + return new Date().toISOString().slice(0, 10); + } + const month = String(parsed.getMonth() + 1).padStart(2, '0'); + const day = String(parsed.getDate()).padStart(2, '0'); + return `${parsed.getFullYear()}-${month}-${day}`; + } + + private messageOf(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback + ); + } +} diff --git a/src/app/fd-interest-posting-report/fd-interest-posting-report.service.ts b/src/app/fd-interest-posting-report/fd-interest-posting-report.service.ts new file mode 100644 index 000000000..85d7a4b91 --- /dev/null +++ b/src/app/fd-interest-posting-report/fd-interest-posting-report.service.ts @@ -0,0 +1,233 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +export type FdInterestReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED'; + +/** Which branches a list covers: the user's own, or (head office only) every other branch. */ +export type FdReportScope = 'own' | 'others'; + +/** The deposit scheme, which decides where a posting of interest ends up. */ +export type FdType = 'SINGLE' | 'DOUBLE' | 'SPECIAL'; + +export interface FdInterestReport { + id: number; + productId: number; + productName: string; + fdType: FdType; + officeId: number; + officeName: string; + postingDate: string; + status: FdInterestReportStatus; + totalInterest: number; + accountCount: number; + generationMode: 'AUTO' | 'MANUAL'; + generatedAt: string; + generatedBy: string | null; + approvedAt: string | null; + approvedBy: string | null; + approvedOnBehalf: boolean; + postedAt: string | null; + failureReason: string | null; + /** Whether this user may regenerate it: their own branch, or the head office for a branch that did not open. */ + canAct: boolean; + blockedReason: string | null; + /** Whether this user may approve it — false for the user who generated it, who cannot be its checker. */ + canApprove: boolean; + approveBlockedReason: string | null; +} + +export interface FdInterestReportLine { + fdAccountId: number; + accountNo: string; + clientName: string; + /** + * The period this interest is for, as the interest engine dated it — not the day the branch closes. + * A deposit owed two unpaid periods appears on two lines, one per date. + */ + interestPostingDate: string; + interestAmount: number; + balanceBefore: number; + balanceAfter: number; + depositAmount: number | null; + interestRate: number | null; + maturityDate: string | null; + /** SPECIAL only: the linked savings account this interest is swept to after posting. */ + payoutSavingsAccountId: number | null; + payoutAccountNo: string | null; + payoutTransactionId: number | null; + fdTransactionId: number | null; +} + +export interface FdInterestReportDetail extends FdInterestReport { + payableGlCode: string | null; + payableGlName: string | null; + paidGlCode: string | null; + paidGlName: string | null; + lines: FdInterestReportLine[]; +} + +export interface FdInterestDue { + productId: number; + productName: string; + fdType: FdType; + officeId: number; + officeName: string; + postingDate: string; + accountCount: number; + totalInterest: number; + canAct: boolean; + blockedReason: string | null; +} + +export interface FdInterestSummaryRow { + id: number; + postingDate: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + fdType: FdType; + accountCount: number; + totalInterest: number; + status: FdInterestReportStatus; + approvedBy: string | null; + approvedOnBehalf: boolean; + approvedAt: string | null; + postedAt: string | null; +} + +export interface FdInterestPreviewRow { + fdAccountId: number; + accountNo: string; + clientName: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + fdType: FdType; + /** The period this interest belongs to, as the engine dated it. */ + interestPostingDate: string; + depositAmount: number | null; + interestRate: number | null; + maturityDate: string | null; + balance: number; + interest: number; + payoutAccountNo: string | null; +} + +/** One product's deposits due on the day being closed, as the day-close screen shows them. */ +export interface FdDayCloseDueRow { + productId: number; + productName: string; + fdType: FdType; + accountCount: number; + totalInterest: number; + reportStatus: FdInterestReportStatus | 'NONE'; +} + +export interface FdDayCloseCheck { + businessDate: string; + canClose: boolean; + pendingProducts: string[]; + due: FdDayCloseDueRow[]; +} + +/** + * The fixed deposit interest posting reports, one per branch and product: generated at noon on a + * posting date, approved by a second user of the branch, posted when the branch closes the day. + */ +@Injectable({ providedIn: 'root' }) +export class FdInterestPostingReportService { + private http = inject(HttpClient); + private readonly base = '/fixeddepositinterestpostingreports'; + + /** scope 'own' is the user's own branch; 'others' (head office only) every other branch. */ + list( + postingDate?: string, + status?: string, + officeId?: number, + scope?: FdReportScope + ): Observable { + let params = new HttpParams(); + if (scope) params = params.set('scope', scope); + if (postingDate) params = params.set('postingDate', postingDate); + if (status) params = params.set('status', status); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get(this.base, { params }); + } + + /** Branch and product pairs with deposit interest due by the date and no report yet. */ + dueWithoutReport(postingDate?: string, scope?: FdReportScope): Observable { + let params = new HttpParams(); + if (scope) params = params.set('scope', scope); + if (postingDate) params = params.set('postingDate', postingDate); + return this.http.get(`${this.base}/due-without-report`, { params }); + } + + /** + * The signed-in user's branch, whether it is the head office, and whether deposits are on the + * approval path at all — with that switched off these screens are empty by design. + */ + context(): Observable<{ officeId: number; officeName: string; headOffice: boolean; approvalEnabled: boolean }> { + return this.http.get<{ officeId: number; officeName: string; headOffice: boolean; approvalEnabled: boolean }>( + `${this.base}/context` + ); + } + + detail(reportId: number): Observable { + return this.http.get(`${this.base}/${reportId}`); + } + + regenerate( + productId: number, + officeId: number, + postingDate: string + ): Observable<{ reportId: number; status: string }> { + return this.http.post<{ reportId: number; status: string }>( + this.base, + { productId, officeId, postingDate }, + { params: new HttpParams().set('command', 'regenerate') } + ); + } + + approve(reportId: number): Observable<{ reportId: number; status: string }> { + return this.http.post<{ reportId: number; status: string }>( + `${this.base}/${reportId}`, + {}, + { params: new HttpParams().set('command', 'approve') } + ); + } + + /** What each deposit has earned and not been paid, as if posted today. View only. */ + preview(officeId?: number): Observable { + let params = new HttpParams(); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get(`${this.base}/preview`, { params }); + } + + /** What stops a branch closing its day, and the deposits behind it. */ + dayCloseCheck(date?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (date) params = params.set('date', date); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get(`${this.base}/day-close-check`, { params }); + } + + /** The head office's branch-wise view over a date range. */ + summary(fromDate?: string, toDate?: string, productId?: number): Observable { + let params = new HttpParams(); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + if (productId) params = params.set('productId', String(productId)); + return this.http.get(`${this.base}/summary`, { params }); + } +} diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index af43e1032..0fb1f75c1 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -92,6 +92,14 @@ const routes: Routes = [ (m) => m.InterestPostingReportComponent ) }, + { + path: 'fd-interest-posting', + data: { title: 'FD Interest Posting Reports', breadcrumb: 'FD Interest Posting Reports' }, + loadComponent: () => + import('../fd-interest-posting-report/fd-interest-posting-report.component').then( + (m) => m.FdInterestPostingReportComponent + ) + }, { path: 'dormancy', data: { title: 'Inactive, Dormant & DEAF', breadcrumb: 'Inactive, Dormant & DEAF' }, diff --git a/src/index.html b/src/index.html index 129b49a38..ec2dafd34 100644 --- a/src/index.html +++ b/src/index.html @@ -27,7 +27,10 @@ rel="stylesheet" /> - +