diff --git a/src/app/core/http/error-handler.interceptor.ts b/src/app/core/http/error-handler.interceptor.ts index fd794c9ab..7a047209d 100644 --- a/src/app/core/http/error-handler.interceptor.ts +++ b/src/app/core/http/error-handler.interceptor.ts @@ -109,7 +109,15 @@ export class ErrorHandlerInterceptor implements HttpInterceptor { } else if (status === 400) { alert = { type: 'Bad Request', message: errorMessage || 'Invalid parameters were passed in the request!' }; } else if (status === 403) { - alert = { type: 'Unauthorized Request', message: errorMessage || 'You are not authorized for this request!' }; + // A 403 is either a missing permission or a business rule the record does not meet yet ("post the coupon + // first", "matures on …"). Only the first is an authorisation problem; the rule is told by its code. + const errorCode: string = + errorBody?.errors?.[0]?.userMessageGlobalisationCode || errorBody?.userMessageGlobalisationCode || ''; + const isPermission = !errorCode || /not\.authori[sz]ed|unauthori[sz]ed|access\.denied|forbidden/i.test(errorCode); + alert = { + type: isPermission ? 'Unauthorized Request' : 'Action Not Allowed', + message: errorMessage || 'You are not authorized for this request!' + }; } else if (status === 404) { alert = { type: this.translate.instant('error.resource.not.found'), diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 420d10c4a..990eb78e6 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1499,7 +1499,7 @@
Investment
- + + + + +
+ @if (error) { +

{{ error }}

+ } + + + @if (diary; as d) { +
+
+ Entries{{ rows.length }} +
+
+ Pending{{ d.pendingCount }} +
+
+ Overdue{{ d.overdueCount }} +
+
+ Money expected (not yet received)₹{{ d.expectedInflow | number: '1.2-2' }} +
+
+ +
+ + + + + + + + + + + + + + + + @for (r of rows; track r.instrument + r.refId + r.kind + r.date) { + + + + + + + + + + + } @empty { + + + + } + +
DateDueInvestmentDeal NoParticularsAmountStatus
{{ r.date }}{{ kindLabels[r.kind] }}{{ r.instrument === 'FD' ? 'FD' : 'Govt Security / Bond' }} + {{ r.dealNo }} + {{ r.particulars }} + {{ r.amount !== null && r.amount !== undefined ? '₹' + (r.amount | number: '1.2-2') : '—' }} + + {{ statusLabel(r) }} + @if (r.voucherNo) { +
{{ r.voucherNo }} + } +
+ @if (r.status !== 'POSTED') { + Open → + } +
Nothing falls due in this period.
+
+ } + diff --git a/src/app/investment/investment-diary/investment-diary.component.scss b/src/app/investment/investment-diary/investment-diary.component.scss new file mode 100644 index 000000000..6ecf95318 --- /dev/null +++ b/src/app/investment/investment-diary/investment-diary.component.scss @@ -0,0 +1,84 @@ +/** + * 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/. + */ + +.diary-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} + +.diary-error { + margin: 8px 0 0; + color: #b91c1c; +} + +.diary-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +.diary-tile { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 16px; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #fff; + + &--bad { + border-color: #fecaca; + background: #fef2f2; + } +} + +.diary-tile-l { + font-size: 12px; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.diary-tile-v { + font-size: 22px; + font-weight: 700; + color: #0f172a; +} + +.diary-badge--posted { + background: #f0fdf4; + color: #15803d; + border: 1px solid #bbf7d0; +} + +.diary-badge--due { + background: #fff7ed; + color: #c2410c; + border: 1px solid #fed7aa; +} + +.diary-badge--overdue { + background: #fef2f2; + color: #b91c1c; + border: 1px solid #fecaca; +} + +.diary-badge--upcoming { + background: #eff6ff; + color: #1d4ed8; + border: 1px solid #bfdbfe; +} + +.diary-open { + font-weight: 600; + white-space: nowrap; + text-decoration: none; +} diff --git a/src/app/investment/investment-diary/investment-diary.component.ts b/src/app/investment/investment-diary/investment-diary.component.ts new file mode 100644 index 000000000..55a1bab20 --- /dev/null +++ b/src/app/investment/investment-diary/investment-diary.component.ts @@ -0,0 +1,122 @@ +/** + * 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 { RouterModule } from '@angular/router'; +import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component'; +import { addPeriod, todayDDMMYYYY } from '../investment-data'; +import { InvestmentDiaryDto, InvestmentDiaryRowDto, InvestmentService, apiErrorMessage } from '../investment.service'; + +type InstrumentFilter = '' | 'SECURITY' | 'FD'; +type StatusFilter = '' | 'PENDING' | 'POSTED'; + +/** + * Due Date Diary for All Investment: coupons, 31 Mar / 30 Sep provisions, premium amortisation, maturities and FD + * payouts in a period, with what is posted, due or overdue, and a link to the screen that posts each one. + */ +@Component({ + selector: 'mifosx-investment-diary', + templateUrl: './investment-diary.component.html', + styleUrls: [ + '../../cashier/cashier-shared.scss', + '../investment-shared.scss', + './investment-diary.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule, + DateDdmmyyyyPickerComponent + ] +}) +export class InvestmentDiaryComponent implements OnInit { + private readonly investmentService = inject(InvestmentService); + + // Opens on everything still to be done: what is pending from the past year and what falls due in the next 30 days. + fromDate = addPeriod(todayDDMMYYYY(), -1, 0, 0); + toDate = addPeriod(todayDDMMYYYY(), 0, 0, 30); + instrument: InstrumentFilter = ''; + status: StatusFilter = 'PENDING'; + diary: InvestmentDiaryDto | null = null; + loading = false; + error = ''; + + readonly kindLabels: Record = { + COUPON: 'Coupon', + PROVISION: 'Interest provision', + MATURITY: 'Maturity / redemption', + AMORTISATION: 'Premium amortisation', + FD_PROVISION: 'FD interest provision', + FD_PAYOUT: 'FD interest payout', + FD_MATURITY: 'FD maturity' + }; + + ngOnInit(): void { + this.load(); + } + + /** Quick ranges from today. */ + setRange(days: number): void { + this.status = ''; + this.fromDate = todayDDMMYYYY(); + this.toDate = addPeriod(this.fromDate, 0, 0, days); + this.load(); + } + + /** What is still pending from the past (up to a year back) and today. */ + showOverdue(): void { + this.toDate = todayDDMMYYYY(); + this.fromDate = addPeriod(this.toDate, -1, 0, 0); + this.status = 'PENDING'; + this.load(); + } + + load(): void { + if (!this.fromDate || !this.toDate) { + this.error = 'Enter the From and To dates.'; + return; + } + this.loading = true; + this.error = ''; + this.investmentService.getDiary(this.fromDate, this.toDate).subscribe({ + next: (d) => { + this.diary = d; + this.loading = false; + }, + error: (err) => { + this.diary = null; + this.loading = false; + this.error = apiErrorMessage(err); + } + }); + } + + get rows(): InvestmentDiaryRowDto[] { + return (this.diary?.rows ?? []).filter( + (r) => + (!this.instrument || r.instrument === this.instrument) && + (!this.status || (this.status === 'POSTED' ? r.status === 'POSTED' : r.status !== 'POSTED')) + ); + } + + /** The screen that posts this entry. */ + link(r: InvestmentDiaryRowDto): { path: string; query: Record } { + if (r.instrument === 'FD') { + return { path: '/investment/fd', query: { tab: 'interest', fd: r.refId } }; + } + const tab = r.kind === 'MATURITY' ? 'redemption' : r.kind === 'AMORTISATION' ? 'amortize' : 'interest'; + return { path: '/investment/bonds', query: { tab } }; + } + + statusLabel(r: InvestmentDiaryRowDto): string { + return r.status === 'DUE' ? 'Due today' : r.status.charAt(0) + r.status.slice(1).toLowerCase(); + } +} diff --git a/src/app/investment/investment-masters/investment-masters.component.html b/src/app/investment/investment-masters/investment-masters.component.html index 8105906e0..df4b99eba 100644 --- a/src/app/investment/investment-masters/investment-masters.component.html +++ b/src/app/investment/investment-masters/investment-masters.component.html @@ -349,6 +349,16 @@ +