From 0b79b0b0d370e9d7b19c40be8b906e1c56bb4c03 Mon Sep 17 00:00:00 2001 From: keshav Date: Tue, 25 Aug 2026 11:20:42 +0530 Subject: [PATCH] feat: implement cashier dashboard, dividend dashboards, share accounts views, and checker tasks --- package.json | 3 +- src/app/accounting/accounting.service.ts | 20 + .../account-register.component.html | 169 ++++++ .../account-register.component.scss | 144 +++++ .../account-register.component.ts | 221 ++++++++ .../branch-day/branch-day.component.html | 337 ++++++++++++ .../branch-day/branch-day.component.scss | 172 ++++++ .../branch-day/branch-day.component.ts | 284 ++++++++++ src/app/cashier/cashier-routing.module.ts | 41 ++ src/app/cashier/cashier.service.ts | 207 +++++++ .../cashier/day-book/day-book.component.html | 393 +++++++++++++ .../cashier/day-book/day-book.component.scss | 393 +++++++++++++ .../cashier/day-book/day-book.component.ts | 519 ++++++++++++++++++ .../transaction-entry.component.ts | 44 +- .../voucher-exceptions.component.html | 183 ++++++ .../voucher-exceptions.component.scss | 120 ++++ .../voucher-exceptions.component.ts | 248 +++++++++ .../clerk/dividend-claim-request.service.ts | 50 +- .../dividend-dashboard.component.html | 172 +++--- .../dividend-dashboard.component.scss | 75 +++ .../dividend-dashboard.component.ts | 154 ++++-- .../client-general-step.component.html | 4 - .../core/http/error-handler.interceptor.ts | 16 +- src/app/core/shell/shell.component.html | 85 ++- src/app/products/products-routing.module.ts | 41 +- src/app/products/products.service.ts | 107 +++- .../all-products-dividends.component.html | 182 ++++++ .../all-products-dividends.component.scss | 121 ++++ .../all-products-dividends.component.ts | 200 +++++++ .../declare-dividend.component.html | 217 ++++++++ .../declare-dividend.component.scss | 250 +++++++++ .../declare-dividend.component.ts | 208 +++++++ .../create-dividend.component.html | 218 ++++++-- .../create-dividend.component.scss | 71 +++ .../create-dividend.component.ts | 276 +++++++++- .../share-products.component.html | 24 + .../share-products.component.scss | 28 + .../share-products.component.ts | 33 ++ .../view-dividend.component.html | 39 ++ .../view-dividend.component.scss | 53 ++ .../view-dividend/view-dividend.component.ts | 74 ++- .../share-product-general-tab.component.html | 10 +- src/app/shared/footer/footer.component.ts | 22 +- .../create-shares-account.component.ts | 1 + .../edit-share-account-details.component.ts | 6 +- .../edit-shares-account.component.ts | 1 + ...shares-account-details-step.component.html | 21 +- .../shares-account-details-step.component.ts | 8 +- ...shares-account-preview-step.component.html | 17 +- .../shares-account-terms-step.component.html | 27 + .../shares-account-terms-step.component.scss | 11 + .../shares-account-terms-step.component.ts | 7 + .../apply-collection-dialog.component.html | 123 +++++ .../apply-collection-dialog.component.scss | 151 +++++ .../apply-collection-dialog.component.ts | 194 +++++++ .../debit-from-savings-dialog.component.html | 75 +++ .../debit-from-savings-dialog.component.scss | 98 ++++ .../debit-from-savings-dialog.component.ts | 121 ++++ .../general-tab/general-tab.component.html | 13 + .../passbook-tab/passbook-tab.component.html | 127 +++-- .../passbook-tab/passbook-tab.component.scss | 65 ++- .../passbook-tab/passbook-tab.component.ts | 2 + .../settled-account.guard.ts | 61 ++ .../shares-account-view.component.html | 65 ++- .../shares-account-view.component.ts | 72 +++ .../shares-buttons.config.ts | 15 + .../transactions-tab.component.html | 7 +- src/app/shares/shares-routing.module.ts | 7 + .../sponsor-dd/dd-list/dd-list.component.html | 7 +- src/app/system/system.service.ts | 11 +- ...hare-savings-debit-requests.component.html | 109 ++++ ...hare-savings-debit-requests.component.scss | 32 ++ .../share-savings-debit-requests.component.ts | 120 ++++ .../checker-inbox-and-tasks.component.html | 9 + src/app/tasks/tasks-routing.module.ts | 6 + src/assets/translations/en-US.json | 1 + src/environments/.env.ts | 4 +- 77 files changed, 7396 insertions(+), 426 deletions(-) create mode 100644 src/app/cashier/account-register/account-register.component.html create mode 100644 src/app/cashier/account-register/account-register.component.scss create mode 100644 src/app/cashier/account-register/account-register.component.ts create mode 100644 src/app/cashier/branch-day/branch-day.component.html create mode 100644 src/app/cashier/branch-day/branch-day.component.scss create mode 100644 src/app/cashier/branch-day/branch-day.component.ts create mode 100644 src/app/cashier/day-book/day-book.component.html create mode 100644 src/app/cashier/day-book/day-book.component.scss create mode 100644 src/app/cashier/day-book/day-book.component.ts create mode 100644 src/app/cashier/voucher-exceptions/voucher-exceptions.component.html create mode 100644 src/app/cashier/voucher-exceptions/voucher-exceptions.component.scss create mode 100644 src/app/cashier/voucher-exceptions/voucher-exceptions.component.ts create mode 100644 src/app/products/share-products/all-products-dividends/all-products-dividends.component.html create mode 100644 src/app/products/share-products/all-products-dividends/all-products-dividends.component.scss create mode 100644 src/app/products/share-products/all-products-dividends/all-products-dividends.component.ts create mode 100644 src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html create mode 100644 src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss create mode 100644 src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts create mode 100644 src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html create mode 100644 src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss create mode 100644 src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts create mode 100644 src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.html create mode 100644 src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.scss create mode 100644 src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.ts create mode 100644 src/app/shares/shares-account-view/settled-account.guard.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.ts diff --git a/package.json b/package.json index 3893f2fb0..4e0057794 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "playwright:headed": "playwright test --headed", "playwright:debug": "playwright test --debug", "headers:check": "node scripts/check-file-headers.js", - "headers:add": "node scripts/add-file-headers.js" + "headers:add": "node scripts/add-file-headers.js", + "mock:dip": "node \"../cbsnexora-backend/scripts/mock-dip-lookup-service.js\"" }, "dependencies": { "@angular/animations": "20.3.16", diff --git a/src/app/accounting/accounting.service.ts b/src/app/accounting/accounting.service.ts index 9d2f8825d..69b896779 100644 --- a/src/app/accounting/accounting.service.ts +++ b/src/app/accounting/accounting.service.ts @@ -44,6 +44,26 @@ export class AccountingService { return this.http.get('/accounting/profitandloss', { params: httpParams }); } + /** + * How much profit a share dividend may actually be declared out of — net profit reduced by the + * statutory appropriations that legally precede a dividend (Statutory Reserve Fund, education + * fund) and by dividends already declared for other share products over an overlapping period. + * @param {string} fromDate Period start, yyyy-MM-dd. + * @param {string} toDate Period end, yyyy-MM-dd. + * @param {string} excludeProductId Leave this product's own declarations out of `alreadyDeclared` + * (its overlapping declaration is separately blocked outright, so counting it would double up). + * @returns {Observable} `{ netProfit, statutoryReserveAmount, educationFundAmount, + * distributableProfit, alreadyDeclared, availableForDeclaration, maximumRatePercentage, + * existingDeclarations[] }`. + */ + getDistributableProfit(fromDate: string, toDate: string, excludeProductId?: string): Observable { + let httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate); + if (excludeProductId) { + httpParams = httpParams.set('excludeProductId', excludeProductId); + } + return this.http.get('/accounting/distributable-profit', { params: httpParams }); + } + /** * Total share dividend actually paid to members in a date range — derived from the Dividend * Payable GL account's own ledger (no separate "Dividend Paid" account needed). diff --git a/src/app/cashier/account-register/account-register.component.html b/src/app/cashier/account-register/account-register.component.html new file mode 100644 index 000000000..d1a802313 --- /dev/null +++ b/src/app/cashier/account-register/account-register.component.html @@ -0,0 +1,169 @@ + + +
+
+
+
REPORTS · ACCOUNT REGISTER
+

Account Register

+

+ Every account as it stood on the chosen date — which ones opened and closed that day, the balance each held + then, and what it holds now. +

+
+
+ + + + + +
+
+ + +

Account Register as on {{ date }} · {{ section }}

+ +
+ + + + + +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + + @if (loading) { +
Loading…
+ } + + @if (!loading && loaded) { + +
+
+ Accounts opened on {{ date }} + {{ openedCount }} +
+
+ Accounts closed on {{ date }} + {{ closedCount }} +
+
+ Balance on {{ date }} + ₹{{ shownBalanceOnDate | number: '1.2-2' }} +
+
+ Current balance + ₹{{ shownCurrentBalance | number: '1.2-2' }} +
+
+ } + + @if (!loading && loaded && filteredRows.length === 0) { +
+ account_balance_wallet +

+ @if (movementOnly) { + No account was opened or closed on {{ date }}. + } @else { + No accounts existed on {{ date }}. + } +

+
+ } + + @if (filteredRows.length > 0) { +
+ + + + + + + + + + + + + + + + @for (r of filteredRows; track r.section + r.accountId) { + + + + + + + + + + + + } + + + + + + + + +
Account NoSectionClientProductStatusOpenedClosedBalance on {{ date }}Current Balance
+ {{ r.accountNo }} + @if (r.openedOnDate) { + OPENED + } + @if (r.closedOnDate) { + CLOSED + } + {{ r.section }}{{ r.clientName ?? '—' }}{{ r.productName ?? '—' }} + {{ r.status }} + {{ r.openedOn ?? '—' }}{{ r.closedOn ?? '—' }}₹{{ r.balanceOnDate | number: '1.2-2' }} + ₹{{ r.currentBalance | number: '1.2-2' }} + @if (r.section === 'SHARE' && r.sharesHeld) { + · {{ r.sharesHeld }} sh + } +
+ {{ filteredRows.length }} account(s) · {{ openedCount }} opened · {{ closedCount }} closed + ₹{{ shownBalanceOnDate | number: '1.2-2' }}₹{{ shownCurrentBalance | number: '1.2-2' }}
+
+ } +
diff --git a/src/app/cashier/account-register/account-register.component.scss b/src/app/cashier/account-register/account-register.component.scss new file mode 100644 index 000000000..702002e4e --- /dev/null +++ b/src/app/cashier/account-register/account-register.component.scss @@ -0,0 +1,144 @@ +/** + * 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/. + */ + +/* Page chrome comes from cashier-shared.scss; badges, filters and muted text are shared with the + day book. Only what belongs to the account register lives here. */ + +.ar-summary { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 14px; + + &__item { + flex: 1 1 180px; + padding: 12px 16px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + + &--in { + border-left: 4px solid #16a34a; + } + + &--out { + border-left: 4px solid #dc2626; + } + } + + &__label { + display: block; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + } + + &__value { + display: block; + margin-top: 4px; + font-size: 20px; + font-weight: 800; + font-variant-numeric: tabular-nums; + color: #0d1b2a; + } +} + +/* Checkbox sits inside a pg-field, which is laid out for inputs — this keeps the label and box on + one line without fighting that. */ +.ar-check { + &__box { + display: flex; + align-items: center; + gap: 6px; + height: 38px; + font-size: 12px; + color: #475569; + white-space: nowrap; + } +} + +/* Marks the accounts that actually moved on the chosen date — the reason someone opens a register + for a past day at all. */ +.ar-tag { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.04em; + + &--in { + background: #dcfce7; + color: #15803d; + } + + &--out { + background: #fee2e2; + color: #b91c1c; + } +} + +.ar-row--opened td:first-child { + box-shadow: inset 3px 0 0 #22c55e; +} + +.ar-row--closed td:first-child { + box-shadow: inset 3px 0 0 #ef4444; +} + +/* Only shown on paper: the screen already states the date in the toolbar. */ +.ar-printhead { + display: none; +} + +/* A register is meant to be printed and filed, so paper gets its own treatment: the toolbar and + filters go, the table gains borders, and rows are not split across pages. */ +@media print { + .ar-noprint { + display: none !important; + } + + .ar-printhead { + display: block; + margin: 0 0 10px; + font-size: 13px; + font-weight: 800; + color: #000; + } + + .pg { + padding: 0; + } + + .ar-summary__item { + border: 1px solid #999; + } + + .pg-table { + font-size: 10px; + + th, + td { + border: 1px solid #999; + padding: 4px 6px; + color: #000; + } + } + + tr { + page-break-inside: avoid; + } + + /* Table headers repeat on every page, so a multi-page register stays readable. */ + thead { + display: table-header-group; + } +} diff --git a/src/app/cashier/account-register/account-register.component.ts b/src/app/cashier/account-register/account-register.component.ts new file mode 100644 index 000000000..683841a11 --- /dev/null +++ b/src/app/cashier/account-register/account-register.component.ts @@ -0,0 +1,221 @@ +/** + * 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 { MatIconModule } from '@angular/material/icon'; +import { catchError, of } from 'rxjs'; +import { CashierService } from '../cashier.service'; + +interface RegisterRow { + accountId: number; + accountNo: string; + section: 'SAVINGS' | 'FD' | 'RD' | 'SHARE'; + clientName: string | null; + productName: string | null; + status: string; + openedOn: string | null; + closedOn: string | null; + /** True when this account was opened (or closed) on the very date being viewed. */ + openedOnDate: boolean; + closedOnDate: boolean; + /** The account's balance as it stood on the register's date, for every section. */ + balanceOnDate: number | null; + currentBalance: number | null; + sharesHeld?: number; +} + +/** + * Account register for one date: which accounts opened and closed that day, each account's balance + * as it stood then, and what it holds now. + */ +@Component({ + selector: 'mifosx-account-register', + templateUrl: './account-register.component.html', + styleUrls: [ + '../cashier-shared.scss', + '../day-book/day-book.component.scss', + './account-register.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule + ] +}) +export class AccountRegisterComponent implements OnInit { + private service = inject(CashierService); + + date = ''; + section: 'ALL' | 'SAVINGS' | 'FD' | 'RD' | 'SHARE' = 'ALL'; + search = ''; + /** Narrows to just the accounts that opened or closed on the chosen date. */ + movementOnly = false; + + rows: RegisterRow[] = []; + openedCount = 0; + closedCount = 0; + totalBalanceOnDate = 0; + totalCurrentBalance = 0; + loading = false; + loaded = false; + errorMsg = ''; + + readonly sections = [ + { value: 'ALL', label: 'All accounts' }, + { value: 'SAVINGS', label: 'Savings' }, + { value: 'FD', label: 'Fixed Deposit' }, + { value: 'RD', label: 'Recurring Deposit' }, + { value: 'SHARE', label: 'Share' } + ]; + + ngOnInit(): void { + this.date = this.today(); + this.load(); + } + + /** Local date — toISOString() would shift the day in any timezone behind UTC. */ + private today(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + load(): void { + if (!this.date) return; + this.loading = true; + this.errorMsg = ''; + this.service + .accountRegister(this.date, this.section) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.message ?? + 'Could not load the account register.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loading = false; + this.loaded = true; + this.rows = res?.accounts ?? []; + this.openedCount = Number(res?.openedCount ?? 0); + this.closedCount = Number(res?.closedCount ?? 0); + this.totalBalanceOnDate = Number(res?.totalBalanceOnDate ?? 0); + this.totalCurrentBalance = Number(res?.totalCurrentBalance ?? 0); + }); + } + + shiftDate(days: number): void { + const [ + y, + m, + d + ] = this.date.split('-').map(Number); + const dt = new Date(y, m - 1, d + days); + this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`; + this.load(); + } + + goToday(): void { + this.date = this.today(); + this.load(); + } + + /** Free-text narrowing over the rows already fetched — no round trip per keystroke. */ + get filteredRows(): RegisterRow[] { + const base = this.movementOnly ? this.rows.filter((r) => r.openedOnDate || r.closedOnDate) : this.rows; + const q = this.search.trim().toLowerCase(); + if (!q) return base; + return base.filter((r) => + [ + r.accountNo, + r.clientName, + r.productName, + r.status, + r.section + ] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(q)) + ); + } + + /** Totals for what is on screen, so a filter or search still reconciles. */ + get shownBalanceOnDate(): number { + return this.filteredRows.reduce((s, r) => s + Number(r.balanceOnDate ?? 0), 0); + } + + get shownCurrentBalance(): number { + return this.filteredRows.reduce((s, r) => s + Number(r.currentBalance ?? 0), 0); + } + + statusClass(status: string): string { + switch (status) { + case 'ACTIVE': + return 'db-badge db-badge--ok'; + case 'CLOSED': + case 'PREMATURELY CLOSED': + case 'REJECTED': + case 'WITHDRAWN': + return 'db-badge db-badge--rejected'; + case 'SUBMITTED': + case 'APPROVED': + return 'db-badge db-badge--pending'; + default: + return 'db-badge'; + } + } + + exportCsv(): void { + const header = [ + 'Account No', + 'Section', + 'Client', + 'Product', + 'Status', + 'Opened On', + 'Closed On', + `Balance on ${this.date}`, + 'Current Balance' + ]; + const lines = this.filteredRows.map((r) => + [ + r.accountNo, + r.section, + r.clientName ?? '', + r.productName ?? '', + r.status, + r.openedOn ?? '', + r.closedOn ?? '', + r.balanceOnDate == null ? '' : String(r.balanceOnDate), + String(r.currentBalance ?? 0) + ] + // Quote every field and double embedded quotes: client names routinely contain commas. + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(',') + ); + const csv = [ + header.join(','), + ...lines + ].join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `account-register-${this.date}.csv`; + a.click(); + URL.revokeObjectURL(url); + } + + /** Hands the page to the browser's print dialog; print styles drop the toolbar and filters. */ + printRegister(): void { + window.print(); + } +} diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html new file mode 100644 index 000000000..fad8bf35e --- /dev/null +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -0,0 +1,337 @@ + + +
+
+
+
BRANCH OPERATIONS · {{ missedOnly ? 'MISSED CLOSE' : 'DAY REGISTER' }}
+

{{ missedOnly ? 'Missed Close Register' : 'Day Open / Day Close' }}

+

+ @if (missedOnly) { + Business days that were opened and never closed. While a day stays open its closing balance never carries + forward, a back-dated entry can still be posted into it, and any cash difference on that date goes unnoticed. + } @else { + The branch's business day, opened each morning and closed each evening. A day left open never carries its + closing balance forward, and any cash difference on that date goes unnoticed — so the ones still hanging are + listed first. + } +

+
+
+ @if (canOpenToday && !missedOnly) { + + } + +
+
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + + @if (!canOperate && loaded) { +
+ info + You can see the register but not open or close a day — a Clerk, Head Cashier or Bank Manager does that, since a + cashier works inside the day and cannot declare it closed. +
+ } + + + @if (missed.length > 0) { +
+
+ warning + {{ missed.length }} business day{{ missed.length === 1 ? '' : 's' }} never closed +
+ @for (m of missed; track m.id) { +
+ {{ m.businessDate }} + open {{ m.daysOpen }} day{{ m.daysOpen === 1 ? '' : 's' }} + + opened {{ timeOnly(m.openedAt) }} by {{ m.openedByName }} · opening ₹{{ m.openingCash | number: '1.2-2' }} + + @if (canOperate) { + + } +
+ } +
+ } + + + @if (missedOnly && loaded && missed.length === 0) { +
+ check_circle +

No missed closes. Every business day opened for this branch has been closed.

+
+ } + + + @if (!missedOnly && openDay) { +
+
+ DAY OPEN + {{ openDay.businessDate }} + + opened {{ timeOnly(openDay.openedAt) }} by {{ openDay.openedByName }} · opening cash ₹{{ + openDay.openingCash | number: '1.2-2' + }} + +
+ @if (canOperate) { + + } +
+ } @else if (loaded && canOperate) { +
+
+ NO DAY OPEN + Open the business day before any cash transaction is accepted. +
+
+ } + + + @if (showOpenForm) { +
+
+ Open business day + +
+
+ + +
+

+ Left blank, yesterday's closing figure carries forward automatically — which is what keeps the opening balance + from drifting. +

+
+ + +
+
+ } + + + @if (closeTarget) { +
+
+ Close business day — {{ closeTarget.businessDate }} + +
+ + @if (isLateClose) { +

+ This day is being closed after its own date. It will be recorded as a + force close, with the reason below kept permanently against it. +

+ } + + @if (previewLoading) { +

Working out the day's figure…

+ } + + + @if (preview) { +
+
+ Opening cash (carried forward) + ₹{{ preview.openingCash | number: '1.2-2' }} +
+
+ + Cash deposits taken (approved) + ₹{{ preview.deposits | number: '1.2-2' }} +
+
+ + Cash drawn from vault + ₹{{ preview.vaultIn | number: '1.2-2' }} +
+
+ − Cash paid out (approved withdrawals) + ₹{{ preview.withdrawals | number: '1.2-2' }} +
+
+ − Cash settled back to vault + ₹{{ preview.settledOut | number: '1.2-2' }} +
+
+ Cash the branch should be holding + ₹{{ preview.expectedCash | number: '1.2-2' }} +
+
+ } + +
+ + @if (preview && liveDifference !== null) { + @if (liveDifference === 0) { +
+ Difference + Tallied +
+ } @else { +
+ Difference + + {{ liveDifference > 0 ? 'Excess' : 'Short' }} ₹{{ + liveDifference < 0 ? -liveDifference : (liveDifference | number: '1.2-2') + }} + +
+ } + } + @if (preview && liveDifference !== 0) { + + } +
+ +

+ Pre-filled with what the books expect — change it only if the drawer counted something different, and that + difference is then recorded against the day. +

+ + @if (isLateClose) { + + } + + + +

+ The figure above is stored against the record, so a difference stays readable later even if a transaction is + reversed afterwards. +

+ +
+ + +
+
+ } + + @if (loading) { +
Loading…
+ } + + @if (!loading && loaded && days.length === 0 && !missedOnly) { +
+ event_note +

No business day has been opened for this branch yet.

+
+ } + + + @if (days.length > 0 && !missedOnly) { +
+ + + + + + + + + + + + + + + + @for (d of days; track d.id) { + + + + + + + + + + + + } + +
Business DateStatusOpened ByOpening ₹Closed ByClosing ₹Book ₹DifferenceNote
+ {{ d.businessDate }} + + @if (d.status === 'OPEN') { + OPEN + } @else if (d.forceClosed) { + FORCE CLOSED + } @else { + CLOSED + } + + {{ d.openedByName ?? '—' }} + · {{ timeOnly(d.openedAt) }} + ₹{{ d.openingCash | number: '1.2-2' }} + {{ d.closedByName ?? '—' }} + @if (d.closedAt) { + · {{ timeOnly(d.closedAt) }} + } + {{ d.closingCash != null ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}{{ d.bookCash != null ? '₹' + (d.bookCash | number: '1.2-2') : '—' }} + @if (d.cashDifference == null) { + — + } @else if (d.cashDifference === 0) { + Tallied + } @else { + {{ differenceLabel(d.cashDifference) }} + } + + @if (d.forceCloseReason) { + Left open: {{ d.forceCloseReason }} + } + @if (d.remarks) { + {{ d.remarks }} + } +
+
+ } +
diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss new file mode 100644 index 000000000..4f7dae34e --- /dev/null +++ b/src/app/cashier/branch-day/branch-day.component.scss @@ -0,0 +1,172 @@ +/** + * 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/. + */ + +/* Page chrome comes from cashier-shared.scss; the badges, panel and comparison tiles are shared + with the day book. Only what is specific to the day register lives here. */ + +/* Read-only notice for a cashier. Defined here rather than borrowed from the exceptions screen so + this component does not depend on another component's stylesheet. */ +.ve-note { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + margin-bottom: 14px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 8px; + color: #1e40af; + font-size: 12px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.bd-success { + padding: 10px 14px; + margin-bottom: 14px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 8px; + color: #14532d; + font-size: 13px; +} + +/* One row per never-closed day, inside the shared red exception panel. */ +.bd-missed { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 7px 0; + font-size: 12px; + border-top: 1px dotted #fecaca; + + &__age { + color: #b91c1c; + } + + /* Pushed right so the date and age read before the action. */ + &__act { + margin-left: auto; + height: 28px; + font-size: 11px; + } +} + +/* The branch's current day — the one thing someone opening this screen is looking for. */ +.bd-current { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 14px 16px; + margin-bottom: 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #f59e0b; + border-radius: 8px; + + /* No day open at all: nothing is running, so the amber "in progress" edge would mislead. */ + &--shut { + border-left-color: #94a3b8; + background: #f8fafc; + } + + &__main { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + &__date { + font-size: 15px; + color: #0d1b2a; + } + + .pg-btn { + margin-left: auto; + } +} + +.bd-panel { + margin-bottom: 14px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + + /* A late close is a different act from an ordinary one, and looks like it. */ + &--late { + border-left: 4px solid #dc2626; + } + + &__wide { + display: block; + margin-top: 12px; + max-width: 640px; + } +} + +/* The closing figure's arithmetic, laid out as a running sum so each line can be checked against + the screen it came from. */ +.bd-calc { + max-width: 520px; + margin-bottom: 14px; + padding: 10px 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 12px; + + &__row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 5px 0; + color: #475569; + + &--in .bd-calc__amt { + color: #15803d; + } + + &--out .bd-calc__amt { + color: #b91c1c; + } + + &--total { + margin-top: 4px; + padding-top: 8px; + border-top: 2px solid #e2e8f0; + font-weight: 800; + font-size: 14px; + color: #0d1b2a; + } + } + + &__amt { + font-variant-numeric: tabular-nums; + font-weight: 700; + white-space: nowrap; + } +} + +.bd-hint { + margin: 10px 0 0; + font-size: 11px; + color: #94a3b8; +} + +.bd-note { + max-width: 280px; + font-size: 11px; + line-height: 1.4; +} diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts new file mode 100644 index 000000000..83b8d0dc0 --- /dev/null +++ b/src/app/cashier/branch-day/branch-day.component.ts @@ -0,0 +1,284 @@ +/** + * 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 { MatIconModule } from '@angular/material/icon'; +import { ActivatedRoute } from '@angular/router'; +import { catchError, of } from 'rxjs'; +import { CashierService } from '../cashier.service'; + +interface BranchDay { + id: number; + officeId: number; + officeName: string | null; + businessDate: string; + openedByName: string | null; + openedAt: string | null; + openingCash: number | null; + closedByName: string | null; + closedAt: string | null; + closingCash: number | null; + bookCash: number | null; + cashDifference: number | null; + forceClosed: boolean; + forceCloseReason: string | null; + remarks: string | null; + status: 'OPEN' | 'CLOSED'; + /** Only present on the missed-close list. */ + daysOpen?: number; +} + +/** + * Branch day register — opening and closing the branch's business day, and the list of days that + * were opened and never closed. + * + * A day left open is not a tidiness problem: its closing balance never carries forward, a + * back-dated entry can still land in it, and any cash difference on that date goes unnoticed. + */ +@Component({ + selector: 'mifosx-branch-day', + templateUrl: './branch-day.component.html', + styleUrls: [ + '../cashier-shared.scss', + '../day-book/day-book.component.scss', + './branch-day.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule + ] +}) +export class BranchDayComponent implements OnInit { + private service = inject(CashierService); + private route = inject(ActivatedRoute); + + /** + * Set from the route: the missed-close register is the same data as the day register, shown with + * only the never-closed days. Two menu entries used to point at one screen, so choosing "Missed + * Close Register" looked like nothing had happened whenever the branch was up to date. + */ + missedOnly = false; + + days: BranchDay[] = []; + missed: BranchDay[] = []; + openDay: any = null; + today = ''; + /** Set by the backend from the caller's roles — a cashier may read the register but not act on it. */ + canOperate = false; + loading = false; + loaded = false; + errorMsg = ''; + successMsg = ''; + + // Open-day form + showOpenForm = false; + openDate = ''; + openingCash: number | null = null; + opening = false; + + // Close-day form. The date is fixed by which day is being closed, never typed. + closeTarget: BranchDay | null = null; + closingCash: number | null = null; + closeRemarks = ''; + forceReason = ''; + closing = false; + + ngOnInit(): void { + this.missedOnly = !!this.route.snapshot.data['missedOnly']; + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.service + .branchDayRegister() + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day register.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loading = false; + this.loaded = true; + this.days = res?.days ?? []; + this.openDay = res?.openDay ?? null; + this.today = res?.today ?? ''; + this.canOperate = !!res?.canOperate; + this.openDate = this.today; + }); + + this.service + .branchDayMissed() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.missed = res?.missed ?? []; + }); + } + + /** True when the branch has no open day at all, so today can be opened. */ + get canOpenToday(): boolean { + return this.canOperate && !this.openDay; + } + + startOpen(): void { + this.showOpenForm = true; + this.openDate = this.today; + // Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure + // over it is how a carried balance drifts. + this.openingCash = null; + this.successMsg = ''; + this.errorMsg = ''; + } + + cancelOpen(): void { + this.showOpenForm = false; + } + + submitOpen(): void { + if (!this.openDate) return; + this.opening = true; + this.errorMsg = ''; + this.service + .openBranchDay(this.openDate, this.openingCash ?? undefined) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not open the day.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.opening = false; + if (res) { + this.showOpenForm = false; + this.successMsg = `Business day ${res.businessDate} opened.`; + this.load(); + } + }); + } + + /** The day's expected figure and the working behind it, fetched when the close form opens. */ + preview: any = null; + previewLoading = false; + + startClose(day: BranchDay): void { + this.closeTarget = day; + this.closingCash = null; + this.closeRemarks = ''; + this.forceReason = ''; + this.successMsg = ''; + this.errorMsg = ''; + this.preview = null; + + // Pre-fill the counted figure with what the books expect. It stays editable — the day is still + // closed on what was physically counted — but in the ordinary case where the drawer tallies + // there is nothing to type, and no chance of a slip of the keyboard inventing a difference. + this.previewLoading = true; + this.service + .branchDayClosePreview(day.businessDate) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.previewLoading = false; + this.preview = res; + if (res?.expectedCash != null && this.closingCash == null) { + this.closingCash = Number(res.expectedCash); + } + }); + } + + /** Difference the form would record right now, so it is visible before saving. */ + get liveDifference(): number | null { + if (!this.preview || this.closingCash == null) return null; + return Number(this.closingCash) - Number(this.preview.expectedCash ?? 0); + } + + /** Puts the expected figure back after an edit. */ + useExpected(): void { + if (this.preview?.expectedCash != null) { + this.closingCash = Number(this.preview.expectedCash); + } + } + + cancelClose(): void { + this.closeTarget = null; + } + + /** A date earlier than today is a late close, and has to say why it was left open. */ + get isLateClose(): boolean { + return !!this.closeTarget && this.closeTarget.businessDate < this.today; + } + + get canSubmitClose(): boolean { + if (!this.closeTarget || this.closingCash == null || this.closing) return false; + return !this.isLateClose || !!this.forceReason.trim(); + } + + submitClose(): void { + const day = this.closeTarget; + if (!day || this.closingCash == null) return; + + this.closing = true; + this.errorMsg = ''; + this.service + .closeBranchDay( + day.businessDate, + Number(this.closingCash), + this.closeRemarks.trim() || undefined, + this.isLateClose ? this.forceReason.trim() : undefined + ) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the day.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.closing = false; + if (res) { + this.closeTarget = null; + const diff = Number(res.cashDifference ?? 0); + this.successMsg = + diff === 0 + ? `Business day ${res.businessDate} closed — cash tallied.` + : `Business day ${res.businessDate} closed with a difference of ₹${diff.toFixed(2)}.`; + this.load(); + } + }); + } + + /** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */ + differenceLabel(d: number | null): string { + if (d == null) return '—'; + const n = Number(d); + if (n === 0) return 'Tallied'; + return n > 0 ? `Excess ₹${Math.abs(n).toFixed(2)}` : `Short ₹${Math.abs(n).toFixed(2)}`; + } + + stamp(iso: string | null): string { + if (!iso) return '—'; + const [ + d, + t + ] = iso.split('T'); + return t ? `${d} ${t.slice(0, 5)}` : d; + } + + timeOnly(iso: string | null): string { + if (!iso) return '—'; + const t = iso.split('T')[1]; + return t ? t.slice(0, 5) : '—'; + } +} diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index f5efc11bd..370356f54 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -19,6 +19,10 @@ import { InterCashierTransferComponent } from './inter-cashier-transfer/inter-ca import { CashierPositionComponent } from './cashier-position/cashier-position.component'; import { ReconciliationComponent } from './reconciliation/reconciliation.component'; import { CashHistoryComponent } from './cash-history/cash-history.component'; +import { DayBookComponent } from './day-book/day-book.component'; +import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component'; +import { BranchDayComponent } from './branch-day/branch-day.component'; +import { AccountRegisterComponent } from './account-register/account-register.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; import { ShareCollectionComponent } from './share-collection/share-collection.component'; @@ -128,6 +132,33 @@ const routes: Routes = [ } ] }, + { + // Branch-level operations, kept out of the cashier tree on purpose: opening and closing the + // day governs the whole branch, and is not something a cashier does. + path: 'branch-ops', + data: { title: 'Branch Operations', breadcrumb: 'Branch Operations' }, + children: [ + { + path: 'day-register', + component: BranchDayComponent, + data: { title: 'Day Open / Day Close', breadcrumb: 'Day Register' } + }, + { + // Same screen, narrowed to the days that were never closed. A separate route rather than + // a second link to the register, so choosing it visibly does something. + path: 'missed-close', + component: BranchDayComponent, + data: { title: 'Missed Close Register', breadcrumb: 'Missed Close', missedOnly: true } + }, + { + // Lives here alongside the day register because both answer "what did this branch look + // like on that date"; it is linked from the Reports menu, where people go to find it. + path: 'account-register', + component: AccountRegisterComponent, + data: { title: 'Account Register', breadcrumb: 'Account Register' } + } + ] + }, { path: 'cashier', data: { title: 'Cashier', breadcrumb: 'Cashier' }, @@ -172,6 +203,16 @@ const routes: Routes = [ component: CashHistoryComponent, data: { title: 'Cash History', breadcrumb: 'Cash History' } }, + { + path: 'day-book', + component: DayBookComponent, + data: { title: 'Day Book', breadcrumb: 'Day Book' } + }, + { + path: 'voucher-exceptions', + component: VoucherExceptionsComponent, + data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' } + }, { path: 'closing-report', component: ClosingCashReportComponent, diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 77f3af681..ebaab0617 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -113,6 +113,146 @@ export class CashierService { const params = new HttpParams().set('date', date); return this.http.get('/cashreports/closing', { params }); } + /** + * Every voucher raised on one date — deposits, withdrawals, disbursements, collections and + * bank transfers in a single list, with the day's credit and debit totals. + */ + dayBook(date: string, side?: 'CR' | 'DR', status?: string): Observable { + let params = new HttpParams().set('date', date); + if (side) params = params.set('side', side); + if (status) params = params.set('status', status); + return this.http.get('/cashreports/day-book', { params }); + } + + // ── Voucher physical verification & attachments ── + // The voucher number always travels in the body or a query parameter, never as a path segment: + // numbers are formatted CR/001/2108/0001 and Spring Security's strict firewall rejects the + // encoded slashes with a 400 before the request reaches the resource. + + /** Records the outcome of checking a voucher against the paper slip. */ + verifyVoucher( + voucherNo: string, + status: 'VERIFIED' | 'EXCEPTION', + slipAmount?: number, + remarks?: string + ): Observable { + return this.http.post('/vouchers/verify', { + voucherNo, + status, + slipAmount: slipAmount != null ? String(slipAmount) : null, + remarks: remarks ?? null, + locale: 'en' + }); + } + + uploadVoucherAttachment( + voucherNo: string, + fileName: string, + contentType: string, + fileData: string, + description?: string + ): Observable { + return this.http.post('/vouchers/attachments', { + voucherNo, + fileName, + contentType, + fileData, + description: description ?? null + }); + } + + listVoucherAttachments(voucherNo: string): Observable { + const params = new HttpParams().set('voucherNo', voucherNo); + return this.http.get('/vouchers/attachments', { params }); + } + + deleteVoucherAttachment(id: number): Observable { + return this.http.delete(`/vouchers/attachments/${id}`); + } + + /** Fetched as a blob through HttpClient so the interceptor's auth header travels with it. */ + downloadVoucherAttachment(id: number): Observable { + return this.http.get(`/vouchers/attachments/${id}/download`, { responseType: 'blob' }); + } + + /** + * Flagged vouchers awaiting (or carrying) a conclusion. Not scoped to a date — an exception + * raised on Friday still needs answering on Monday. + */ + voucherExceptions(status: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN'): Observable { + const params = new HttpParams().set('status', status); + return this.http.get('/vouchers/exceptions', { params }); + } + + /** Closes an exception. Manager / head cashier only, and a supporting document must exist. */ + resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable { + return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes }); + } + + /** + * Account register for one date: what opened and closed that day, each account's balance as it + * stood then, and what it holds now. + */ + accountRegister(date: string, section?: string): Observable { + let params = new HttpParams().set('date', date); + if (section && section !== 'ALL') params = params.set('section', section); + return this.http.get('/accountregister', { params }); + } + + // ── Branch day register ── + + /** The branch's day register, plus which day is currently open. Defaults to the caller's office. */ + branchDayRegister(officeId?: number, limit?: number): Observable { + let params = new HttpParams(); + if (officeId) params = params.set('officeId', String(officeId)); + if (limit) params = params.set('limit', String(limit)); + return this.http.get('/branchday', { params }); + } + + /** Days opened and never closed — today's own open day excluded. */ + branchDayMissed(officeId?: number): Observable { + let params = new HttpParams(); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get('/branchday/missed', { params }); + } + + /** + * What the day should close at, and the arithmetic behind it, so the closing figure does not + * have to be worked out by hand. + */ + branchDayClosePreview(businessDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (businessDate) params = params.set('businessDate', businessDate); + if (officeId) params = params.set('officeId', String(officeId)); + return this.http.get('/branchday/close-preview', { params }); + } + + openBranchDay(businessDate: string, openingCash?: number, officeId?: number): Observable { + return this.http.post('/branchday/open', { + businessDate, + openingCash: openingCash != null ? String(openingCash) : null, + officeId: officeId ?? null, + locale: 'en' + }); + } + + /** forceCloseReason is required when closing a date earlier than today. */ + closeBranchDay( + businessDate: string, + closingCash: number, + remarks?: string, + forceCloseReason?: string, + officeId?: number + ): Observable { + return this.http.post('/branchday/close', { + businessDate, + closingCash: String(closingCash), + remarks: remarks ?? null, + forceCloseReason: forceCloseReason ?? null, + officeId: officeId ?? null, + locale: 'en' + }); + } excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable { let params = new HttpParams().set('threshold', String(threshold)); if (fromDate) params = params.set('fromDate', fromDate); @@ -188,4 +328,71 @@ export class CashierService { getShareCollectionStatus(shareAccountId: number): Observable { return this.http.get(`/cashier/sharecollection/${shareAccountId}`); } + + /** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */ + getShareAdvanceByVoucher(voucherNo: string, clientId?: number): Observable { + // The voucher travels as a query parameter, not a path segment: numbers are formatted + // CR/001/2108/0001, and Spring Security's strict firewall rejects the encoded slashes + // (%2F) outright with a 400 before the request ever reaches the resource. + // clientId lets the backend adopt a cashier deposit made straight to the Share Sundry GL, + // which carries no member of its own. + let params = new HttpParams().set('voucherNo', voucherNo); + if (clientId) { + params = params.set('clientId', String(clientId)); + } + return this.http.get('/shareadvancecollection/by-voucher', { params }); + } + + /** Receipts still sitting in Share Sundry, optionally for a single member. */ + getUnappliedShareAdvances(clientId?: number): Observable { + const suffix = clientId ? `?clientId=${clientId}` : ''; + return this.http.get(`/shareadvancecollection${suffix}`); + } + + /** Moves a receipt out of Sundry into share capital for an activated account. */ + applyShareAdvance(collectionId: number, data: any): Observable { + return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data); + } + + /** + * Whether this share account can be settled from the member's linked savings, and for how much. + * + * Reports a shortfall before the clerk raises anything, so the member can be told at the counter + * rather than after a request has been submitted and has to be rejected. + * @returns {Observable} `{ eligible, reason, shareAmount, chargeAmount, totalRequired, + * savingsAccountNo, savingsBalance }`. + */ + getShareSavingsDebitEligibility(shareAccountId: number): Observable { + return this.http.get(`/sharesavingsdebit/eligibility/${shareAccountId}`); + } + + /** + * Records a request to settle a share application by debiting the member's savings. Nothing moves + * until a manager approves it — unlike the cash route, where the cashier's receipt is already the + * second pair of eyes. + */ + submitShareSavingsDebit(data: any): Observable { + return this.http.post('/sharesavingsdebit', data); + } + + /** Savings-debit requests, PENDING by default. */ + getShareSavingsDebitRequests(status?: string, shareAccountId?: number): Observable { + const params: string[] = []; + if (status) { + params.push(`status=${status}`); + } + if (shareAccountId) { + params.push(`shareAccountId=${shareAccountId}`); + } + return this.http.get(`/sharesavingsdebit${params.length ? '?' + params.join('&') : ''}`); + } + + /** Debits the member's savings and issues the shares. Manager only, and not the submitting clerk. */ + approveShareSavingsDebit(id: number): Observable { + return this.http.post(`/sharesavingsdebit/${id}/approve`, {}); + } + + rejectShareSavingsDebit(id: number, rejectionReason?: string): Observable { + return this.http.post(`/sharesavingsdebit/${id}/reject`, { rejectionReason }); + } } diff --git a/src/app/cashier/day-book/day-book.component.html b/src/app/cashier/day-book/day-book.component.html new file mode 100644 index 000000000..12b14a2d2 --- /dev/null +++ b/src/app/cashier/day-book/day-book.component.html @@ -0,0 +1,393 @@ + + +
+
+
+
CASHIER · DAY BOOK
+

Day Book

+

+ Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections and bank + transfers, with credit and debit totals. +

+
+
+ + + + + +
+
+ + +
+ + + + +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + + + @if (!loading && exceptionRows.length > 0) { +
+
+ warning + + {{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation + + @if (resolvedExceptionCount > 0) { + ({{ resolvedExceptionCount }} already closed) + } + +
+ @for (e of exceptionRows; track e.voucherNo) { +
+ {{ e.voucherNo }} + + System ₹{{ e.amount | number: '1.2-2' }} + @if (e.slipAmount != null && e.slipAmount !== e.amount) { + · Slip ₹{{ e.slipAmount | number: '1.2-2' }} + (diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }}) + } + + {{ e.verifyRemarks }} + — {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }} +
+ } +
+ } + + @if (loading) { +
Loading…
+ } + + @if (!loading && loaded && rows.length === 0) { +
+ receipt_long +

No vouchers were raised on {{ date }}.

+
+ } + + @if (!loading && filteredRows.length > 0) { + +
+
+ Credit · CR (money in) + ₹{{ shownCredit | number: '1.2-2' }} +
+
−
+
+ Debit · DR (money out) + ₹{{ shownDebit | number: '1.2-2' }} +
+
=
+
+ Net + ₹{{ shownCredit - shownDebit | number: '1.2-2' }} +
+
+ +

+ Totals count approved vouchers only — pending and rejected rows are listed but never move the money. +

+ +
+ + + + + + + + + + + + + + + + + + + + @for (r of filteredRows; track r.voucherNo ?? r.submittedAt) { + + + + + + + + + + + + + + + + + + @if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) { + + + + } + + + @if (verifyRow === r) { + + + + } + + + @if (attachRow === r) { + + + + } + } + + + + + + + + +
Voucher NoDr/CrTypeAccountPartyAmountPaymentStatusByApproved BySlip CheckTime
+ {{ r.voucherNo ?? '—' }} + + + {{ r.side }} + + {{ r.kind }} + {{ r.accountNo ?? '—' }} + @if (r.accountType) { + · {{ r.accountType }} + } + {{ r.party ?? '—' }}₹{{ r.amount | number: '1.2-2' }}{{ r.paymentTypeName ?? '—' }} + {{ r.status }} + {{ r.handledBy ?? '—' }} + {{ r.approvedBy ?? '—' }} + @if (r.approvedBy && r.actionedAt) { + · {{ timeOf(r.actionedAt) }} + } + + @if (r.verifyStatus === 'VERIFIED') { + ✓ MATCHED + } @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) { + + + ✓ RESOLVED + + } @else if (r.verifyStatus === 'EXCEPTION') { + + ⚠ EXCEPTION + + } @else { + Not checked + } + {{ timeOf(r.submittedAt) }} + + +
+ {{ r.resolutionStatus ? 'task_alt' : 'warning' }} + Exception + @if (r.slipAmount != null && r.slipAmount !== r.amount) { + — slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference + ₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }}) + } + — {{ r.verifyRemarks }} + · raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }} + + @if (r.resolutionStatus) { +
+ Closed — {{ resolutionLabel(r.resolutionStatus) }}: + {{ r.resolutionNotes }} + · by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }} +
+ } +
+
+
+ Slip check — {{ r.voucherNo }} + +
+
+
+ System recorded + ₹{{ r.amount | number: '1.2-2' }} +
+ + @if (verifySlipBlank) { +
+ Difference + — +
+ } @else if (verifyMismatch) { +
+ Difference + ₹{{ verifyDifference | number: '1.2-2' }} +
+ } @else { +
+ Match + Amounts agree +
+ } +
+ + @if (verifyMismatch) { +

+ Saving this marks the voucher as an exception for investigation — it does not + change the posted amount. +

+ } +
+ + + @if (!verifyMismatch) { + + } + +
+
+
+
+
+ Attachments — {{ r.voucherNo }} + +
+ + @if (attachUploading) { +

Uploading…

+ } + @if (attachLoading) { +

Loading…

+ } + @if (!attachLoading && attachments.length === 0) { +

Nothing attached to this voucher yet.

+ } + @for (a of attachments; track a.id) { +
+ description + {{ a.fileName }} + {{ a.fileSize }} bytes · {{ a.uploadedByName }} + + +
+ } +
+
Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)₹{{ shownCredit - shownDebit | number: '1.2-2' }}
+
+ } + + @if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) { +
No voucher matches “{{ search }}”.
+ } +
diff --git a/src/app/cashier/day-book/day-book.component.scss b/src/app/cashier/day-book/day-book.component.scss new file mode 100644 index 000000000..6f8b430d1 --- /dev/null +++ b/src/app/cashier/day-book/day-book.component.scss @@ -0,0 +1,393 @@ +/** + * 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/. + */ + +/* Layout and page chrome come from cashier-shared.scss; only what is specific to the day book + lives here. */ + +.db-filters { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; + padding: 12px 16px; + margin-bottom: 12px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + + /* The search box takes the slack so the two selects keep their natural width. */ + &__search { + flex: 1 1 260px; + } +} + +.db-note { + margin: 8px 0 12px; + font-size: 11px; + color: #94a3b8; +} + +/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */ +.db-side { + display: inline-block; + min-width: 28px; + text-align: center; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + background: #f1f5f9; + color: #475569; + + &--cr { + background: #dcfce7; + color: #15803d; + } + + &--dr { + background: #fee2e2; + color: #b91c1c; + } +} + +/* A hairline on the leading edge, tinted by side: readable without colouring the whole row, + which would fight the table's own hover state. */ +.db-row--cr td:first-child { + box-shadow: inset 3px 0 0 #22c55e; +} + +.db-row--dr td:first-child { + box-shadow: inset 3px 0 0 #ef4444; +} + +.db-muted { + color: #94a3b8; + font-size: 11px; +} + +.db-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + background: #f1f5f9; + color: #475569; + + &--ok { + background: #dcfce7; + color: #15803d; + } + + &--pending { + background: #fef3c7; + color: #b45309; + } + + &--rejected { + background: #fee2e2; + color: #b91c1c; + } +} + +/* Not defined in cashier-shared.scss, so the day book carries its own. */ +.pg-alert { + padding: 10px 14px; + margin-bottom: 12px; + border-radius: 6px; + font-size: 13px; + + &--error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #b91c1c; + } +} + +/* Exception summary, above the table: the day's flagged vouchers listed in full so nobody has to + hunt for a badge. */ +.db-exceptions { + margin-bottom: 14px; + padding: 12px 16px; + background: #fef2f2; + border: 1px solid #fecaca; + border-left: 4px solid #dc2626; + border-radius: 8px; + + &__head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + color: #b91c1c; + font-size: 13px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + + /* Pushed to the right so the count reads first. */ + .pg-btn { + margin-left: auto; + height: 30px; + font-size: 12px; + } + } + + &__item { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 6px 0; + font-size: 12px; + border-top: 1px dotted #fecaca; + } + + &__amt { + font-variant-numeric: tabular-nums; + color: #334155; + } + + &__diff { + color: #b91c1c; + } + + &__why { + color: #0d1b2a; + font-weight: 600; + } +} + +/* The reason spelled out under its own flagged row. */ +.db-exc-row td { + background: #fef2f2 !important; + border-bottom: 1px solid #fecaca !important; + color: #7f1d1d; + font-size: 12px; + padding: 8px 14px !important; + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + vertical-align: -3px; + margin-right: 4px; + } +} + +/* A closed exception stays on the row as history, but drops the alarm colouring — it is no longer + something anyone has to act on. */ +.db-exc-row--resolved td { + background: #f0fdf4 !important; + border-bottom: 1px solid #bbf7d0 !important; + color: #14532d; +} + +.db-exc-row__resolution { + margin-top: 4px; + padding-top: 4px; + border-top: 1px dotted #bbf7d0; +} + +.db-actions { + white-space: nowrap; +} + +/* Raising an exception is a deliberate, consequential act — styled to read that way without + looking destructive, since nothing is deleted. */ +.pg-btn--warn { + background: #fffbeb; + border-color: #fde68a; + color: #92400e; + + &:hover { + background: #fef3c7; + } +} + +.db-icon-btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #fff; + color: #475569; + cursor: pointer; + + &:hover { + background: #f1f5f9; + color: #0d1b2a; + } + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + /* Attachment count rides on the paperclip so a voucher with a scanned slip reads as different + at a glance, without a separate column. */ + &__count { + position: absolute; + top: -5px; + right: -5px; + min-width: 14px; + height: 14px; + padding: 0 3px; + border-radius: 999px; + background: #2563eb; + color: #fff; + font-size: 9px; + font-weight: 800; + line-height: 14px; + } +} + +/* Inline panel, opened under the row it belongs to. */ +.db-panel-row td { + background: #f8fafc !important; + padding: 0 !important; +} + +.db-panel { + padding: 14px 16px; + border-left: 3px solid #2563eb; + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + font-size: 13px; + color: #0d1b2a; + } + + &__body { + display: flex; + align-items: flex-end; + gap: 16px; + flex-wrap: wrap; + } + + &__remarks { + display: block; + margin-top: 12px; + max-width: 640px; + } + + &__warn { + margin: 10px 0 0; + padding: 8px 12px; + border-radius: 6px; + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; + font-size: 12px; + } + + &__actions { + display: flex; + gap: 8px; + margin-top: 14px; + } +} + +/* The two amounts being compared, side by side. */ +.db-cmp { + padding: 8px 14px; + border-radius: 6px; + background: #fff; + border: 1px solid #e2e8f0; + + &__label { + display: block; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + } + + &__value { + display: block; + margin-top: 2px; + font-size: 15px; + font-weight: 800; + font-variant-numeric: tabular-nums; + color: #0d1b2a; + } + + &--good { + background: #f0fdf4; + border-color: #bbf7d0; + + .db-cmp__value { + color: #15803d; + } + } + + &--bad { + background: #fef2f2; + border-color: #fecaca; + + .db-cmp__value { + color: #b91c1c; + } + } +} + +.db-upload { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.db-file { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + margin-bottom: 6px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 12px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #64748b; + } + + &__name { + font-weight: 700; + color: #0d1b2a; + } +} + +.pg-table tfoot td { + background: #f8fafc; + font-weight: 800; + font-size: 12px; + color: #334155; + border-top: 1px solid #e2e8f0; +} diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts new file mode 100644 index 000000000..d6fe2dbd4 --- /dev/null +++ b/src/app/cashier/day-book/day-book.component.ts @@ -0,0 +1,519 @@ +/** + * 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 { MatIconModule } from '@angular/material/icon'; +import { catchError, of } from 'rxjs'; +import { CashierService } from '../cashier.service'; + +interface DayBookRow { + voucherNo: string | null; + side: 'CR' | 'DR'; + kind: string; + accountNo: string | null; + accountType: string | null; + party: string | null; + amount: number | null; + paymentTypeName: string | null; + description: string | null; + status: string; + handledBy: string | null; + approvedBy: string | null; + txnId: string | null; + submittedAt: string | null; + actionedAt: string | null; + /** null until someone has physically checked the slip. */ + verifyStatus: 'VERIFIED' | 'EXCEPTION' | null; + slipAmount: number | null; + verifyRemarks: string | null; + verifiedByName: string | null; + verifiedAt: string | null; + /** Set once a clerk or manager has closed the exception; null while it is still open. */ + resolutionStatus: string | null; + resolutionNotes: string | null; + resolvedByName: string | null; + resolvedAt: string | null; + attachmentCount: number; +} + +/** + * Day book — every voucher raised on one date in a single list: cash deposits, withdrawals, + * disbursements, collections and bank transfers, with the day's credit and debit totals. + */ +@Component({ + selector: 'mifosx-day-book', + templateUrl: './day-book.component.html', + styleUrls: [ + '../cashier-shared.scss', + './day-book.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule + ] +}) +export class DayBookComponent implements OnInit { + private service = inject(CashierService); + + date = ''; + side: '' | 'CR' | 'DR' = ''; + status = ''; + search = ''; + + rows: DayBookRow[] = []; + creditTotal = 0; + debitTotal = 0; + netTotal = 0; + loading = false; + loaded = false; + errorMsg = ''; + + ngOnInit(): void { + this.date = this.today(); + this.load(); + } + + /** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */ + private today(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + load(): void { + if (!this.date) return; + this.loading = true; + this.errorMsg = ''; + this.service + .dayBook(this.date, this.side || undefined, this.status || undefined) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day book.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loading = false; + this.loaded = true; + this.rows = res?.rows ?? []; + this.creditTotal = Number(res?.creditTotal ?? 0); + this.debitTotal = Number(res?.debitTotal ?? 0); + this.netTotal = Number(res?.netTotal ?? 0); + }); + } + + /** Shifts the selected date by whole days, so yesterday is one click rather than a date picker. */ + shiftDate(days: number): void { + const [ + y, + m, + d + ] = this.date.split('-').map(Number); + const dt = new Date(y, m - 1, d + days); + this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`; + this.load(); + } + + goToday(): void { + this.date = this.today(); + this.load(); + } + + /** Show only the flagged vouchers — set from the exception banner. */ + exceptionsOnly = false; + + /** + * Vouchers still awaiting a conclusion. A closed exception drops out of the banner — it stays + * visible on its own row as history, but it is no longer something anyone has to act on. + */ + get exceptionRows(): DayBookRow[] { + return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus); + } + + /** Closed exceptions, kept for the record rather than for action. */ + get resolvedExceptionCount(): number { + return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length; + } + + /** Human label for a resolution code. */ + resolutionLabel(status: string | null): string { + switch (status) { + case 'RESOLVED_SYSTEM_ERROR': + return 'System entry was wrong'; + case 'RESOLVED_SLIP_ERROR': + return 'Slip was written wrong'; + case 'RESOLVED_NO_ACTION': + return 'No action needed'; + default: + return status ?? ''; + } + } + + /** Vouchers nobody has checked against a slip yet. */ + get uncheckedCount(): number { + return this.rows.filter((r) => !r.verifyStatus).length; + } + + toggleExceptionsOnly(): void { + this.exceptionsOnly = !this.exceptionsOnly; + } + + /** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */ + get filteredRows(): DayBookRow[] { + const base = this.exceptionsOnly ? this.exceptionRows : this.rows; + const q = this.search.trim().toLowerCase(); + if (!q) return base; + return base.filter((r) => + [ + r.voucherNo, + r.accountNo, + r.party, + r.kind, + r.paymentTypeName, + r.handledBy, + r.description + ] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(q)) + ); + } + + /** Totals for what is on screen, so a search or side filter still reconciles. */ + get shownCredit(): number { + return this.filteredRows + .filter((r) => r.side === 'CR' && this.countsAsMoney(r)) + .reduce((s, r) => s + Number(r.amount ?? 0), 0); + } + + get shownDebit(): number { + return this.filteredRows + .filter((r) => r.side === 'DR' && this.countsAsMoney(r)) + .reduce((s, r) => s + Number(r.amount ?? 0), 0); + } + + /** Only approved money is real money — a pending or rejected voucher must not move the totals. */ + private countsAsMoney(r: DayBookRow): boolean { + const s = (r.status ?? '').toUpperCase(); + return s === 'APPROVED' || s === 'COMPLETED'; + } + + statusClass(status: string): string { + switch ((status ?? '').toUpperCase()) { + case 'APPROVED': + case 'COMPLETED': + return 'db-badge db-badge--ok'; + case 'PENDING': + return 'db-badge db-badge--pending'; + case 'REJECTED': + return 'db-badge db-badge--rejected'; + default: + return 'db-badge'; + } + } + + /** Time of day only — the date is already the page's subject. */ + timeOf(iso: string | null): string { + if (!iso) return '—'; + const t = iso.split('T')[1]; + return t ? t.slice(0, 5) : '—'; + } + + // ── Physical slip verification ──────────────────────────────────────────── + + /** Row whose verification panel is open, or null when none is. */ + verifyRow: DayBookRow | null = null; + verifySlipAmount: number | null = null; + verifyRemarks = ''; + verifySaving = false; + + openVerify(r: DayBookRow): void { + this.verifyRow = r; + // Pre-filled with the system's own figure: the common case is that the two agree, and when + // they don't the cashier only has to change the digits that actually differ. + this.verifySlipAmount = Number(r.amount ?? 0); + this.verifyRemarks = r.verifyRemarks ?? ''; + this.attachRow = null; + } + + closeVerify(): void { + this.verifyRow = null; + this.verifySlipAmount = null; + this.verifyRemarks = ''; + } + + /** True when the amount typed off the slip differs from what the system recorded. */ + get verifyMismatch(): boolean { + if (!this.verifyRow || this.verifySlipAmount == null) return false; + return Number(this.verifySlipAmount) !== Number(this.verifyRow.amount ?? 0); + } + + /** + * True while the slip box is empty. Clearing the field must not read as "amounts agree" — an + * unanswered question is not a match, so the panel shows neither verdict until a figure is typed. + */ + get verifySlipBlank(): boolean { + return this.verifySlipAmount == null || String(this.verifySlipAmount).trim() === ''; + } + + get verifyDifference(): number { + if (!this.verifyRow || this.verifySlipAmount == null) return 0; + return Number(this.verifySlipAmount) - Number(this.verifyRow.amount ?? 0); + } + + /** + * Saves the verdict. The status is derived from the figures rather than picked by the user — + * if the slip and the system disagree it is an exception, whatever anyone intended to click. + */ + saveVerify(): void { + const row = this.verifyRow; + if (!row?.voucherNo) return; + + const isException = this.verifyMismatch; + if (isException && !this.verifyRemarks.trim()) { + this.errorMsg = 'The amounts differ — add a remark saying what you found before saving.'; + return; + } + + this.verifySaving = true; + this.errorMsg = ''; + this.service + .verifyVoucher( + row.voucherNo, + isException ? 'EXCEPTION' : 'VERIFIED', + isException ? Number(this.verifySlipAmount) : undefined, + this.verifyRemarks.trim() || undefined + ) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not save the verification.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.verifySaving = false; + if (res) { + this.closeVerify(); + this.load(); + } + }); + } + + /** + * Flags a voucher whose amounts agree — the problem is something other than the figure: a + * missing signature, a wrong account, a slip the member disputes. Deliberately not a "clear the + * check" action: erasing a verdict would erase the evidence that anything was ever wrong, which + * is exactly what an investigation needs. + */ + raiseException(): void { + const row = this.verifyRow; + if (!row?.voucherNo) return; + + if (!this.verifyRemarks.trim()) { + this.errorMsg = 'Say what the problem is before raising an exception.'; + return; + } + + this.verifySaving = true; + this.errorMsg = ''; + this.service + // The slip amount is sent as-is: the figures match, so it doubles as the record of what + // both the slip and the system said at the time the exception was raised. + .verifyVoucher( + row.voucherNo, + 'EXCEPTION', + Number(this.verifySlipAmount ?? row.amount ?? 0), + this.verifyRemarks.trim() + ) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not raise the exception.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.verifySaving = false; + if (res) { + this.closeVerify(); + this.load(); + } + }); + } + + // ── Attachments ─────────────────────────────────────────────────────────── + + attachRow: DayBookRow | null = null; + attachments: any[] = []; + attachLoading = false; + attachUploading = false; + + openAttach(r: DayBookRow): void { + this.attachRow = r; + this.verifyRow = null; + this.attachments = []; + if (!r.voucherNo) return; + this.attachLoading = true; + this.service + .listVoucherAttachments(r.voucherNo) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.attachLoading = false; + this.attachments = res?.attachments ?? []; + }); + } + + closeAttach(): void { + this.attachRow = null; + this.attachments = []; + } + + onFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + const row = this.attachRow; + if (!file || !row?.voucherNo) return; + + // 10 MB, matching the backend's own ceiling — rejecting here saves uploading the whole file + // only to have it refused. + if (file.size > 10 * 1024 * 1024) { + this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.'; + input.value = ''; + return; + } + + const reader = new FileReader(); + reader.onload = () => { + this.attachUploading = true; + this.errorMsg = ''; + this.service + .uploadVoucherAttachment( + row.voucherNo!, + file.name, + file.type || 'application/octet-stream', + String(reader.result) + ) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.attachUploading = false; + input.value = ''; + if (res) { + this.openAttach(row); + this.load(); + } + }); + }; + reader.readAsDataURL(file); + } + + /** + * Fetched through HttpClient rather than window.open: the download endpoint needs the auth + * header the interceptor adds, which a bare browser navigation would not carry. The blob is + * then handed to the browser as a normal save. + */ + downloadAttachment(a: any): void { + this.service + .downloadVoucherAttachment(a.id) + .pipe(catchError(() => of(null))) + .subscribe((blob: Blob | null) => { + if (!blob) { + this.errorMsg = 'Could not download that file.'; + return; + } + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = a.fileName ?? 'attachment'; + link.click(); + URL.revokeObjectURL(url); + }); + } + + deleteAttachment(a: any): void { + const row = this.attachRow; + this.service + .deleteVoucherAttachment(a.id) + .pipe(catchError(() => of(null))) + .subscribe(() => { + if (row) this.openAttach(row); + this.load(); + }); + } + + exportCsv(): void { + const header = [ + 'Voucher No', + 'Side', + 'Type', + 'Account', + 'Party', + 'Amount', + 'Payment Type', + 'Status', + 'Handled By', + 'Approved By', + 'Slip Check', + 'Slip Amount', + 'Remarks', + 'Checked By', + 'Resolution', + 'Resolution Notes', + 'Closed By', + 'Attachments', + 'Time' + ]; + const lines = this.filteredRows.map((r) => + [ + r.voucherNo ?? '', + r.side, + r.kind, + r.accountNo ?? '', + r.party ?? '', + String(r.amount ?? 0), + r.paymentTypeName ?? '', + r.status, + r.handledBy ?? '', + r.approvedBy ?? '', + r.verifyStatus ?? 'NOT CHECKED', + r.slipAmount != null ? String(r.slipAmount) : '', + r.verifyRemarks ?? '', + r.verifiedByName ?? '', + r.resolutionStatus ? this.resolutionLabel(r.resolutionStatus) : '', + r.resolutionNotes ?? '', + r.resolvedByName ?? '', + String(r.attachmentCount ?? 0), + this.timeOf(r.submittedAt) + ] + // Quote every field and double any embedded quote: names and narrations routinely + // contain commas, which would otherwise split into extra columns. + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(',') + ); + const csv = [ + header.join(','), + ...lines + ].join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `day-book-${this.date}.csv`; + a.click(); + URL.revokeObjectURL(url); + } +} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index e75976599..df16141b4 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -2317,8 +2317,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { alerts.push({ type: 'danger', message: 'Sanctions Match — DO NOT process' }); if (isYes(this.kycDetails?.willfulDefaulter)) alerts.push({ type: 'danger', message: 'Willful Defaulter — Refer to Manager' }); - if (this.kycDetails?.amlCheckStatus === 'FLAGGED') - alerts.push({ type: 'danger', message: 'AML Flag — Suspicious Activity' }); + // No AML-suspicion banner here by design. Sanctions and willful-defaulter above are legal + // prohibitions the cashier has to act on at the counter; AML suspicion is not — showing it + // risks tipping off the customer. It stays in the AML review queue and the STR/CTR reports. return alerts; } @@ -3220,21 +3221,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - const AML_GL = 200000; - if (amount > AML_GL && isCash) { - this.showConfirmAlert( - 'warning', - 'AML Alert — Large Cash Transaction', - [ - `Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, - 'This transaction requires manager approval.', - 'Have you obtained approval to proceed?' - ], - 'Yes, Proceed', - () => this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly) - ); - return; - } + // No AML prompt on the GL path either — see the note on the savings/current path below. this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly); return; } @@ -3314,23 +3301,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } } - // 6. AML large cash - const AML_THRESHOLD = 200000; - if (amount > AML_THRESHOLD && selectedPt?.isCashPayment) { - this.showConfirmAlert( - 'warning', - 'AML Alert — Large Cash Transaction', - [ - `Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, - 'This transaction requires manager approval.', - 'Have you obtained approval to proceed?' - ], - 'Yes, Proceed', - () => this.openConfirmDialog(row, amount, isDeposit, selectedPt) - ); - return; - } - + // AML monitoring is deliberately invisible here. Every posted transaction is evaluated + // server-side against the AML rule catalog (AmlRuleEvaluationService) after it succeeds, and + // anything it flags surfaces only in the AML reports — alerts, STR cases and CTR returns. + // The cashier is never prompted or warned at the counter: tipping off the customer defeats + // the purpose of suspicious-transaction monitoring, and a hardcoded client-side threshold + // duplicated none of the real rule set anyway. this.openConfirmDialog(row, amount, isDeposit, selectedPt); } diff --git a/src/app/cashier/voucher-exceptions/voucher-exceptions.component.html b/src/app/cashier/voucher-exceptions/voucher-exceptions.component.html new file mode 100644 index 000000000..6185d289f --- /dev/null +++ b/src/app/cashier/voucher-exceptions/voucher-exceptions.component.html @@ -0,0 +1,183 @@ + + +
+
+
+
CASHIER · EXCEPTIONS
+

Voucher Exceptions

+

+ Vouchers flagged at the counter — where the slip and the system disagree, or something else about the entry is + wrong. A clerk or manager reviews each one with the cashier, records what was found, and attaches the supporting + document. +

+
+
+ + +
+
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + + @if (!canResolve && loaded) { +
+ info + You can see these exceptions but not close them — a Clerk, Head Cashier or Bank Manager records the conclusion, so + that whoever raised the flag is not the one who rules on it. +
+ } + + @if (loading) { +
Loading…
+ } + + @if (!loading && loaded && exceptions.length === 0) { +
+ check_circle +

{{ filter === 'OPEN' ? 'No open exceptions. Everything checked so far agrees.' : 'Nothing to show.' }}

+
+ } + + @for (e of exceptions; track e.voucherNo) { +
+
+ {{ e.voucherNo }} + @if (e.resolutionStatus) { + RESOLVED + } @else { + ⚠ OPEN + } + + {{ e.accountNo }} · {{ e.party }} · raised by {{ e.raisedByName }} on {{ stamp(e.raisedAt) }} + + @if (!e.resolutionStatus && canResolve) { + + } +
+ +
+
+ System recorded + ₹{{ e.systemAmount | number: '1.2-2' }} +
+ @if (!amountsAgree(e)) { +
+ Written on slip + ₹{{ e.slipAmount | number: '1.2-2' }} +
+
+ Difference + ₹{{ difference(e) | number: '1.2-2' }} +
+ } @else { +
+ Amounts + Agree — flagged for another reason +
+ } +
+ +

Cashier reported: {{ e.remarks }}

+ + @if (e.resolutionStatus) { +
+ {{ resolutionLabel(e.resolutionStatus) }} — {{ e.resolutionNotes }} + · closed by {{ e.resolvedByName }} on {{ stamp(e.resolvedAt) }} +
+ } + + + @if (resolveRow === e) { +
+
+ Close exception — {{ e.voucherNo }} + +
+ + + + + +
+ Supporting document +

+ An exception cannot be closed on someone's word alone — attach the corrected slip, a signed note, or + whatever the decision rests on. +

+ + @if (attachUploading) { +

Uploading…

+ } + @if (attachLoading) { +

Loading…

+ } + @if (!attachLoading && attachments.length === 0) { +

No document attached yet — required before closing.

+ } + @for (a of attachments; track a.id) { +
+ description + {{ a.fileName }} + {{ a.uploadedByName }} + +
+ } +
+ +
+ + +
+
+ } +
+ } +
diff --git a/src/app/cashier/voucher-exceptions/voucher-exceptions.component.scss b/src/app/cashier/voucher-exceptions/voucher-exceptions.component.scss new file mode 100644 index 000000000..e01627b12 --- /dev/null +++ b/src/app/cashier/voucher-exceptions/voucher-exceptions.component.scss @@ -0,0 +1,120 @@ +/** + * 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/. + */ + +/* Page chrome comes from cashier-shared.scss, and the comparison tiles, panel and file rows are + shared with the day book. Only the exception card itself is defined here. */ + +.ve-note { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + margin-bottom: 14px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 8px; + color: #1e40af; + font-size: 12px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +/* One card per flagged voucher. Open ones carry a red edge; closed ones go green, so a queue + reads at a glance without opening anything. */ +.ve-card { + padding: 14px 16px; + margin-bottom: 12px; + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #dc2626; + border-radius: 8px; + + &--resolved { + border-left-color: #16a34a; + background: #fafffb; + } + + &__head { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 12px; + } + + /* Pushed right so the identity of the voucher reads first. */ + &__act { + margin-left: auto; + height: 32px; + font-size: 12px; + } + + &__amts { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; + } + + &__why { + margin: 0; + padding: 8px 12px; + background: #f8fafc; + border-radius: 6px; + font-size: 12px; + color: #0d1b2a; + } + + &__resolution { + margin-top: 10px; + padding: 8px 12px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 6px; + font-size: 12px; + color: #14532d; + } +} + +.ve-panel { + margin-top: 12px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + + &__field { + display: block; + margin-bottom: 12px; + max-width: 640px; + } + + &__docs { + padding: 12px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 12px; + + p { + margin: 4px 0 10px; + } + } + + &__warn { + padding: 6px 10px; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 4px; + color: #92400e; + } +} diff --git a/src/app/cashier/voucher-exceptions/voucher-exceptions.component.ts b/src/app/cashier/voucher-exceptions/voucher-exceptions.component.ts new file mode 100644 index 000000000..cc47dd41b --- /dev/null +++ b/src/app/cashier/voucher-exceptions/voucher-exceptions.component.ts @@ -0,0 +1,248 @@ +/** + * 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 { MatIconModule } from '@angular/material/icon'; +import { catchError, of } from 'rxjs'; +import { CashierService } from '../cashier.service'; + +interface VoucherException { + voucherNo: string; + systemAmount: number | null; + slipAmount: number | null; + remarks: string | null; + raisedByName: string | null; + raisedAt: string | null; + accountNo: string | null; + party: string | null; + raisedAgainst: string | null; + attachmentCount: number; + resolutionStatus: string | null; + resolutionNotes: string | null; + resolvedByName: string | null; + resolvedAt: string | null; +} + +/** + * Voucher exceptions raised at the counter, and the conclusion recorded on each. + * + * The cashier who spots a difference cannot close it — this screen is where a clerk, head cashier + * or manager sits down with them, records what was actually wrong, and attaches the paper that + * supports the decision. + */ +@Component({ + selector: 'mifosx-voucher-exceptions', + templateUrl: './voucher-exceptions.component.html', + styleUrls: [ + '../cashier-shared.scss', + '../day-book/day-book.component.scss', + './voucher-exceptions.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule + ] +}) +export class VoucherExceptionsComponent implements OnInit { + private service = inject(CashierService); + + filter: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN'; + exceptions: VoucherException[] = []; + /** Set by the backend from the caller's own roles — the cashier who raised a flag cannot close it. */ + canResolve = false; + loading = false; + loaded = false; + errorMsg = ''; + + // Resolution panel + resolveRow: VoucherException | null = null; + resolutionStatus = ''; + resolutionNotes = ''; + resolveSaving = false; + + // Attachments for the voucher being resolved + attachments: any[] = []; + attachLoading = false; + attachUploading = false; + + readonly resolutionOptions = [ + { value: 'RESOLVED_SYSTEM_ERROR', label: 'System entry was wrong' }, + { value: 'RESOLVED_SLIP_ERROR', label: 'Slip was written wrong' }, + { value: 'RESOLVED_NO_ACTION', label: 'No action needed' } + ]; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.service + .voucherExceptions(this.filter) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load voucher exceptions.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loading = false; + this.loaded = true; + this.exceptions = res?.exceptions ?? []; + this.canResolve = !!res?.canResolve; + }); + } + + difference(e: VoucherException): number { + if (e.slipAmount == null) return 0; + return Number(e.slipAmount) - Number(e.systemAmount ?? 0); + } + + /** True when the flag was about something other than the amount. */ + amountsAgree(e: VoucherException): boolean { + return e.slipAmount == null || Number(e.slipAmount) === Number(e.systemAmount ?? 0); + } + + openResolve(e: VoucherException): void { + this.resolveRow = e; + this.resolutionStatus = ''; + this.resolutionNotes = ''; + this.loadAttachments(e); + } + + closeResolve(): void { + this.resolveRow = null; + this.attachments = []; + } + + private loadAttachments(e: VoucherException): void { + this.attachments = []; + this.attachLoading = true; + this.service + .listVoucherAttachments(e.voucherNo) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.attachLoading = false; + this.attachments = res?.attachments ?? []; + }); + } + + onFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + const row = this.resolveRow; + if (!file || !row) return; + + // 10 MB, matching the backend — refusing here saves uploading the whole file to be rejected. + if (file.size > 10 * 1024 * 1024) { + this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.'; + input.value = ''; + return; + } + + const reader = new FileReader(); + reader.onload = () => { + this.attachUploading = true; + this.errorMsg = ''; + this.service + .uploadVoucherAttachment( + row.voucherNo, + file.name, + file.type || 'application/octet-stream', + String(reader.result), + 'Supporting document for exception resolution' + ) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.attachUploading = false; + input.value = ''; + if (res) { + this.loadAttachments(row); + this.load(); + } + }); + }; + reader.readAsDataURL(file); + } + + downloadAttachment(a: any): void { + this.service + .downloadVoucherAttachment(a.id) + .pipe(catchError(() => of(null))) + .subscribe((blob: Blob | null) => { + if (!blob) { + this.errorMsg = 'Could not download that file.'; + return; + } + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = a.fileName ?? 'attachment'; + link.click(); + URL.revokeObjectURL(url); + }); + } + + /** Mirrors the backend's own rule, so the button explains itself before it is pressed. */ + get canSubmitResolution(): boolean { + return ( + !!this.resolutionStatus && !!this.resolutionNotes.trim() && this.attachments.length > 0 && !this.resolveSaving + ); + } + + saveResolution(): void { + const row = this.resolveRow; + if (!row) return; + + this.resolveSaving = true; + this.errorMsg = ''; + this.service + .resolveVoucherException(row.voucherNo, this.resolutionStatus, this.resolutionNotes.trim()) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the exception.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.resolveSaving = false; + if (res) { + this.closeResolve(); + this.load(); + } + }); + } + + resolutionLabel(status: string | null): string { + if (!status) return ''; + return this.resolutionOptions.find((o) => o.value === status)?.label ?? status; + } + + /** Date and time — an exception queue spans days, so the day matters here. */ + stamp(iso: string | null): string { + if (!iso) return '—'; + const [ + d, + t + ] = iso.split('T'); + return t ? `${d} ${t.slice(0, 5)}` : d; + } +} diff --git a/src/app/clerk/dividend-claim-request.service.ts b/src/app/clerk/dividend-claim-request.service.ts index 2f87ead92..ada9c126d 100644 --- a/src/app/clerk/dividend-claim-request.service.ts +++ b/src/app/clerk/dividend-claim-request.service.ts @@ -20,37 +20,29 @@ import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token'; export class DividendClaimRequestService { private http = inject(HttpClient); + /** + * Records a claim for an unclaimed dividend, collected as a Demand Draft. + * + * A dividend is only ever paid out as a draft — the savings-credit, cash and loan-set-off routes + * this endpoint once accepted are retired, so the payload carries just what a draft needs: how + * much, drawn in whose favour, and payable where. + */ submit( lockId: number, - breakdown: { - loanId?: number; - loanApplyAmount?: number; - savingsAccountId?: number; - savingsApplyAmount?: number; - cashApplyAmount?: number; - tokNo?: string; + claim: { + ddApplyAmount: number; + ddBeneficiaryName: string; + ddPayableAtBranch: string; + ddPayableAtCity: string; } ): Observable { - const body: any = { lockId }; - if (breakdown.loanId != null) { - body.loanId = breakdown.loanId; - } - if (breakdown.loanApplyAmount != null) { - body.loanApplyAmount = String(breakdown.loanApplyAmount); - } - if (breakdown.savingsAccountId != null) { - body.savingsAccountId = breakdown.savingsAccountId; - } - if (breakdown.savingsApplyAmount != null) { - body.savingsApplyAmount = String(breakdown.savingsApplyAmount); - } - if (breakdown.cashApplyAmount != null) { - body.cashApplyAmount = String(breakdown.cashApplyAmount); - } - if (breakdown.tokNo != null) { - body.tokNo = breakdown.tokNo; - } - return this.http.post('/clerk/dividend-claim-requests', body); + return this.http.post('/clerk/dividend-claim-requests', { + lockId, + ddApplyAmount: String(claim.ddApplyAmount), + ddBeneficiaryName: claim.ddBeneficiaryName, + ddPayableAtBranch: claim.ddPayableAtBranch, + ddPayableAtCity: claim.ddPayableAtCity + }); } // Silenced: onTokNoBlur() in Transaction Entry probes this only after the withdrawal-request @@ -68,10 +60,6 @@ export class DividendClaimRequestService { }); } - getOverdueLoans(clientId: number): Observable { - return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`); - } - list(status = 'PENDING'): Observable { return this.http.get('/clerk/dividend-claim-requests', { params: { status } }); } diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html index 9871b11eb..4a0ccd746 100644 --- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html +++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html @@ -200,11 +200,47 @@ + + + @if (lockYears.length > 0) { +
+ Financial year +
+ + @for (year of lockYears; track year) { + + } +
+ + {{ visibleLocks.length }} lock{{ visibleLocks.length === 1 ? '' : 's' }} · + {{ visibleLocksTotal | number: '1.2-2' }} + +
+ } - + + @@ -218,16 +254,23 @@ - } @else if (locksLoaded && locks.length === 0) { + } @else if (locksLoaded && visibleLocks.length === 0) { } @else { - @for (l of locks; track l.lockId) { + @for (l of visibleLocks; track l.lockId) { @@ -249,48 +292,33 @@ - - - - + + + + - + @if (claimRequestsLoading) { - + } @else if (claimRequestsLoaded && claimRequests.length === 0) { - + } @else { @for (req of claimRequests; track req.id) { + + - - - - + @if (expandedRejectClaimId === req.id) { - } + + + + + + + @if (sharesAccountData.resolutionDate) { + + } + @if (!sharesAccountData.resolutionDate) { + + } + @if (sharesAccountData.savingsAccountNumber) { diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html index 80264c9de..c21ac09b1 100644 --- a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html @@ -103,6 +103,12 @@
{{ resolutionNumber }}
} + @if (resolutionDate) { +
+
Resolution Date
+
{{ resolutionDate | dateFormat }}
+
+ } @@ -133,64 +139,71 @@ @if (filteredTransactions.length > 0) { -
ClientSavings AccountDividend Amount Locked Date Release Due
Loading…
No dividend locks match this filter.
{{ l.clientName }} - {{ l.savingsAccountNo }} + @if (l.financialYear) { +
FY {{ l.financialYear }}
+
+ {{ l.productName }} · {{ l.periodStartDate }} → {{ l.periodEndDate }} +
+ } @else { + — + }
{{ l.amount | number: '1.2-2' }} {{ l.lockedDate }}
- Unclaimed amount: {{ l.amount | number: '1.2-2' }} — split it below (loan, savings, cash) + Unclaimed amount: {{ l.amount | number: '1.2-2' }} + @if (l.financialYear) { + (FY {{ l.financialYear }} dividend) + } + — issue as a Demand Draft
- @if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) { - - - } @else { -
No overdue loan for this client.
- } + - @if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) { - - } + + - @if (claimCashAmount[l.lockId] > 0) { - - }
- Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }} + Claiming: {{ claimDdAmount[l.lockId] || 0 | number: '1.2-2' }} / + {{ l.amount | number: '1.2-2' }}
@if (claimError) {
{{ claimError }}
@@ -315,6 +343,21 @@
+ + + @if (lastIssuedDd) { +
+
+ Demand Draft {{ lastIssuedDd.ddNumber }} issued to {{ lastIssuedDd.clientName }} for + {{ lastIssuedDd.amount | number: '1.2-2' }} +
+ +
+ }
ClientSavings AccountTo LoanTo SavingsCashDD AmountBeneficiaryPayable At Unclaimed BalanceClient Loan StatusRequested By
Loading…Loading…
No dividend claim requests pending review.No dividend claim requests pending review.
{{ req.client_name }}{{ req.dd_apply_amount | number: '1.2-2' }}{{ req.dd_beneficiary_name || '—' }} - {{ req.savings_account_no }} - - {{ - req.loan_apply_amount > 0 - ? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')' - : '—' - }} - {{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }} - {{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }} - @if (req.cash_apply_amount > 0 && req.tok_no) { -
Tok: {{ req.tok_no }}
+ @if (req.dd_payable_at_branch || req.dd_payable_at_city) { + {{ req.dd_payable_at_branch }} + @if (req.dd_payable_at_branch && req.dd_payable_at_city) { + , + } + {{ req.dd_payable_at_city }} + } @else { + — }
{{ req.locked_amount | number: '1.2-2' }} - @if (req.has_overdue_loan) { - Has Overdue EMI - } @else { - No Overdue Loan - } - {{ req.clerk_name }}
+
+ diff --git a/src/app/shared/footer/footer.component.ts b/src/app/shared/footer/footer.component.ts index c7f71bbe6..c895dc100 100644 --- a/src/app/shared/footer/footer.component.ts +++ b/src/app/shared/footer/footer.component.ts @@ -183,9 +183,13 @@ export class FooterComponent implements OnInit, OnDestroy { */ getConfigurations(): void { if (this.authenticationService.isAuthenticated()) { - this.systemService - .getConfigurationByName(SettingsService.businessDateConfigName) - .subscribe((configurationData: any) => { + // Reading global configuration needs the READ_CONFIGURATION permission, which teller-style + // roles (cashier, clerk) do not carry. This runs from the footer on every authenticated + // page, so an unpermitted role was greeted by an "Unauthorized Request" modal at login for + // a check it never asked for. The footer only uses this to decide whether to show a + // business date, so a denied read is not an error here — it degrades to the system date. + this.systemService.getConfigurationByName(SettingsService.businessDateConfigName, true).subscribe({ + next: (configurationData: any) => { this.isBusinessDateEnabled = configurationData.enabled; this.settingsService.setBusinessDateConfig(configurationData.enabled); if (this.isBusinessDateEnabled) { @@ -194,7 +198,17 @@ export class FooterComponent implements OnInit, OnDestroy { this.getConfigurations(); }, 60000); } - }); + }, + error: () => { + // No visibility of the config (403) or it is unavailable — leave the business date off + // and stop polling, rather than re-checking once a minute for a read this role will + // never be allowed to make. The stored config is deliberately left untouched: a denied + // read yields no value to record, and overwriting it here would let a cashier's session + // clobber what an authorised user established. + this.isBusinessDateEnabled = false; + clearTimeout(this.timer); + } + }); } else { clearTimeout(this.timer); } diff --git a/src/app/shares/create-shares-account/create-shares-account.component.ts b/src/app/shares/create-shares-account/create-shares-account.component.ts index dce90cc9a..302754f98 100644 --- a/src/app/shares/create-shares-account/create-shares-account.component.ts +++ b/src/app/shares/create-shares-account/create-shares-account.component.ts @@ -132,6 +132,7 @@ export class CreateSharesAccountComponent { charges: this.sharesAccount.charges.map((charge: any) => ({ chargeId: charge.id, amount: charge.amount })), applicationDate: this.dateUtils.formatDate(this.sharesAccount.applicationDate, dateFormat), submittedDate: this.dateUtils.formatDate(this.sharesAccount.submittedDate, dateFormat), + resolutionDate: this.dateUtils.formatDate(this.sharesAccount.resolutionDate, dateFormat), unitPrice: this.sharesAccountTermsForm.get('unitPrice').value, dateFormat, locale diff --git a/src/app/shares/edit-share-account-details/edit-share-account-details.component.ts b/src/app/shares/edit-share-account-details/edit-share-account-details.component.ts index 0168971d7..968e931b9 100644 --- a/src/app/shares/edit-share-account-details/edit-share-account-details.component.ts +++ b/src/app/shares/edit-share-account-details/edit-share-account-details.component.ts @@ -140,9 +140,13 @@ export class EditShareAccountDetailsComponent { const payload: any = { locale, dateFormat: 'yyyy-MM-dd', - savingsAccountId: terms.savingsAccountId, allowDividendCalculationForInactiveClients: terms.allowDividendCalculationForInactiveClients }; + // Optional: only sent when a savings account is actually linked, so an unlinked account + // does not post a null the update validator would reject. + if (terms.savingsAccountId) { + payload.savingsAccountId = terms.savingsAccountId; + } if (terms.minimumActivePeriod) { payload.minimumActivePeriod = terms.minimumActivePeriod; } diff --git a/src/app/shares/edit-shares-account/edit-shares-account.component.ts b/src/app/shares/edit-shares-account/edit-shares-account.component.ts index aa706ecc5..0c0d9cb2b 100644 --- a/src/app/shares/edit-shares-account/edit-shares-account.component.ts +++ b/src/app/shares/edit-shares-account/edit-shares-account.component.ts @@ -138,6 +138,7 @@ export class EditSharesAccountComponent { charges: this.sharesAccount.charges.map((charge: any) => ({ chargeId: charge.id, amount: charge.amount })), applicationDate: this.dateUtils.formatDate(this.sharesAccount.applicationDate, dateFormat), submittedDate: this.dateUtils.formatDate(this.sharesAccount.submittedDate, dateFormat), + resolutionDate: this.dateUtils.formatDate(this.sharesAccount.resolutionDate, dateFormat), unitPrice: this.sharesAccountTermsForm.get('unitPrice').value, dateFormat, locale diff --git a/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.html b/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.html index b0d1b0416..61be98c60 100644 --- a/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.html +++ b/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.html @@ -54,6 +54,24 @@ {{ 'labels.commons.required' | translate }} + + + {{ 'labels.inputs.Resolution Date' | translate }} + + + + + {{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + +
@@ -68,7 +86,8 @@ !( sharesAccountDetailsForm.get('productId')?.value && sharesAccountDetailsForm.get('submittedDate')?.value && - sharesAccountDetailsForm.get('resolutionNumber')?.value + sharesAccountDetailsForm.get('resolutionNumber')?.value && + sharesAccountDetailsForm.get('resolutionDate')?.value ) " > diff --git a/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.ts b/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.ts index 154f5cd7a..89c6f9bf3 100644 --- a/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.ts +++ b/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.ts @@ -76,7 +76,9 @@ export class SharesAccountDetailsStepComponent implements OnInit { this.sharesAccountTemplate.timeline.submittedOnDate && new Date(this.sharesAccountTemplate.timeline.submittedOnDate), externalId: this.sharesAccountTemplate.externalId, - resolutionNumber: this.sharesAccountTemplate.resolutionNumber + resolutionNumber: this.sharesAccountTemplate.resolutionNumber, + resolutionDate: + this.sharesAccountTemplate.resolutionDate && new Date(this.sharesAccountTemplate.resolutionDate) }); } } @@ -102,6 +104,10 @@ export class SharesAccountDetailsStepComponent implements OnInit { resolutionNumber: [ '', Validators.required + ], + resolutionDate: [ + '', + Validators.required ] }); } diff --git a/src/app/shares/shares-account-stepper/shares-account-preview-step/shares-account-preview-step.component.html b/src/app/shares/shares-account-stepper/shares-account-preview-step/shares-account-preview-step.component.html index e00b156cd..dd6998c0c 100644 --- a/src/app/shares/shares-account-stepper/shares-account-preview-step/shares-account-preview-step.component.html +++ b/src/app/shares/shares-account-stepper/shares-account-preview-step/shares-account-preview-step.component.html @@ -41,6 +41,11 @@ {{ sharesAccount.resolutionNumber }}
+
+ {{ 'labels.inputs.Resolution Date' | translate }} + {{ sharesAccount.resolutionDate | dateFormat }} +
+

{{ 'labels.heading.Terms' | translate }}

@@ -71,9 +76,15 @@
{{ 'labels.inputs.Default Savings Account' | translate }} - {{ - sharesAccount.savingsAccountId | find: sharesAccountProductTemplate.clientSavingsAccounts : 'id' : 'accountNo' - }} + + @if (sharesAccount.savingsAccountId) { + {{ + sharesAccount.savingsAccountId | find: sharesAccountProductTemplate.clientSavingsAccounts : 'id' : 'accountNo' + }} + } @else { + {{ 'labels.inputs.Unassigned' | translate }} + } +
@if (sharesAccount.minimumActivePeriod) { diff --git a/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.html b/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.html index e6889c63a..cb7278805 100644 --- a/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.html +++ b/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.html @@ -30,6 +30,9 @@ {{ calculateCurrenValue() | currency: currency.code : 'symbol-narrow' : '1.2-2' }} } + {{ 'labels.inputs.Default Savings Account' | translate }} @@ -42,6 +45,12 @@ {{ 'labels.commons.required' | translate }} + @if (savingsAccountsData?.length === 0) { +

+ This client has no active savings account in the share product's currency. Open one first — a share account + cannot be created without it. +

+ } {{ 'labels.inputs.Application Date' | translate }} @@ -67,6 +76,15 @@

{{ 'labels.heading.Minimum Active Period' | translate }}

+ +

+ Enter the minimum number of days a member must hold these shares before they qualify for a dividend. Shares held + for fewer days than this are excluded from the dividend calculation and earn nothing for that period. +

+ {{ 'labels.inputs.Frequency' | translate }} @@ -88,6 +106,15 @@

{{ 'labels.heading.Lock-in Period' | translate }}

+ +

+ Enter the minimum number of days a member must wait before they can redeem (sell back) these shares. This is + counted separately from each purchase, and does not affect dividends — for that, use Minimum Active Period above. +

+ {{ 'labels.inputs.Frequency' | translate }} diff --git a/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.scss b/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.scss index 658758d7d..d44f5eb1c 100644 --- a/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.scss +++ b/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.scss @@ -14,3 +14,14 @@ h4 { .margin-t { margin-top: 1em; } + +/* Red because both fields it marks fail silently — a wrong Minimum Active Period pays the member + nothing months later, a wrong Lock-in Period only surfaces when a redemption is refused. Neither + raises a validation error at the time. Sits under the heading and above the inputs, so it is read + before the number is typed rather than after. */ +.sats-field-note { + margin: -0.5em 0 1em; + color: #b42318; + font-size: 12.5px; + line-height: 1.5; +} diff --git a/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.ts b/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.ts index 74853b52c..f73f9d2fe 100644 --- a/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.ts +++ b/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.ts @@ -128,6 +128,13 @@ export class SharesAccountTermsStepComponent implements OnChanges, OnInit { ], unitPrice: [{ value: '', disabled: true }], currencyMultiple: [{ value: '', disabled: true }], + // Required to match the server, which has always rejected a share account without a linked + // savings account. The form allowed one through, so the failure surfaced as a validation + // error on submit after the whole stepper had been filled in. + // + // It also gives an over-payment somewhere to go: a member who hands over more cash than the + // application costs has the difference credited to this account, rather than being left in a + // suspense GL that nobody can attribute back to them. savingsAccountId: [ '', Validators.required diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html new file mode 100644 index 000000000..a0f7778d3 --- /dev/null +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html @@ -0,0 +1,123 @@ + + +

Apply Share Advance Collection

+ + +

+ Enter the voucher number the member was given, or tick the receipts below, to settle against share account + {{ data.accountNo }}. Tick more than one where the member paid in instalments. The amount is moved out of Share Sundry into share + capital, and any entry fee is posted to fee income. +

+ +
+ + +
+ + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
+ } + + @if (loading) { +
+ + Loading receipts… +
+ } @else if (receipts.length === 0) { +
+ info_outline +
+ No unapplied receipt for this member. +
+ Collect the cash first from Cashier › Transaction Entry as a deposit to the Share Sundry GL. Once a + manager approves it, enter the voucher number above. +
+
+
+ } @else { + + + + + + + + + + + @for (r of receipts; track r.id) { + + + + + + + } + + @if (selectedIds.length > 1) { + + + + + + + + } +
Voucher / ReceiptDateAmount
+ + + {{ r.voucher_no || r.receipt_no }}{{ r.collection_date }} + {{ r.amount }} +
{{ selectedIds.length }} receipts selected + {{ selectedTotal | number: '1.2-2' }} +
+ +
+ Select every receipt that makes up this payment. Their total must cover the account's share value plus any entry + fee — anything paid over that is credited to the member's savings account. +
+ } +
+ + + + + diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss new file mode 100644 index 000000000..ba7e9deeb --- /dev/null +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss @@ -0,0 +1,151 @@ +/** + * 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/. + */ + +$primary: #1a3a6b; +$border: #d8e0ec; +$muted: #6b7a90; +$danger: #b3261e; + +.intro { + font-size: 13px; + color: $muted; + margin: 0 0 12px; +} + +.voucher-lookup { + display: flex; + gap: 8px; + align-items: center; + margin: 0 0 14px; +} + +.voucher-input { + flex: 1; + min-width: 0; + padding: 9px 11px; + border: 1px solid $border; + border-radius: 6px; + font-family: 'Roboto Mono', monospace; + font-size: 13px; + letter-spacing: 0.4px; + color: $primary; + + &:focus { + outline: none; + border-color: $primary; + } + + &:disabled { + background: #f4f7fb; + color: $muted; + } +} + +.loading { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 0; + font-size: 13px; + color: $muted; +} + +.empty { + display: flex; + gap: 10px; + align-items: flex-start; + background: #f4f7fb; + border: 1px solid $border; + border-radius: 6px; + padding: 12px; + font-size: 13px; +} + +.empty-hint { + color: $muted; + margin-top: 4px; +} + +.receipt-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + background: #eef2f8; + color: $primary; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 8px; + } + + td { + padding: 8px; + border-bottom: 1px solid #eceff4; + } + + tbody tr { + cursor: pointer; + + &:hover { + background: #f4f7fb; + } + + &.selected { + background: #e7effb; + } + } + + /* Only appears once a second receipt is ticked, when adding the rows up by eye stops being + reliable — and the total is what decides whether the settlement is accepted. */ + tfoot .total-row { + border-top: 2px solid #cbd5e1; + + td { + padding-top: 10px; + font-size: 13px; + color: #1f3d7a; + } + } +} + +.ta-left { + text-align: left; +} + +.ta-right { + text-align: right; +} + +.ta-center { + text-align: center; +} + +.mono { + font-family: 'Courier New', monospace; +} + +.note { + margin-top: 10px; + font-size: 12px; + color: $muted; +} + +.alert-error { + display: flex; + align-items: center; + gap: 8px; + background: #fdecea; + color: $danger; + border: 1px solid #f5c6c2; + border-radius: 6px; + padding: 10px 12px; + font-size: 13px; + margin-bottom: 12px; +} diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts new file mode 100644 index 000000000..5546e11e3 --- /dev/null +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts @@ -0,0 +1,194 @@ +/** + * 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, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { CashierService } from 'app/cashier/cashier.service'; + +/** + * Applies a Share Advance receipt to an activated share account. + * + * The member's cash is already sitting in the Share Sundry liability from the cashier's advance + * collection. Applying a receipt here posts Dr Sundry / Cr Share Capital (+ Cr Income from Fees + * when an entry fee applies) and reveals the purchase entry in the transactions and passbook. + */ +@Component({ + selector: 'mifosx-apply-collection-dialog', + templateUrl: './apply-collection-dialog.component.html', + styleUrls: ['./apply-collection-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule + ] +}) +export class ApplyCollectionDialogComponent implements OnInit { + private cashierService = inject(CashierService); + + loading = true; + submitting = false; + errorMsg = ''; + receipts: any[] = []; + + /** + * Receipts chosen for this settlement, in the order they were picked. + * + * More than one because a member often pays an application in instalments — ₹4,500 today, the + * ₹500 balance tomorrow — and neither receipt settles the account on its own. They are applied + * together as a single settlement rather than forcing the member back to the counter for a fresh + * receipt covering the whole amount. + */ + selectedIds: number[] = []; + + /** Voucher number quoted by the member, as an alternative to picking from the list. */ + voucherNo = ''; + lookingUp = false; + + public dialogRef = inject(MatDialogRef); + public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA); + + ngOnInit(): void { + this.cashierService + .getUnappliedShareAdvances(this.data.clientId) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, 'Could not load the member’s receipts.'); + this.loading = false; + return of(null); + }) + ) + .subscribe((res: any) => { + if (res) { + this.receipts = res.unappliedCollections || []; + if (this.receipts.length === 1) { + this.selectedIds = [this.receipts[0].id]; + } + } + this.loading = false; + }); + } + + isSelected(id: number): boolean { + return this.selectedIds.includes(id); + } + + toggle(id: number): void { + this.selectedIds = this.isSelected(id) ? this.selectedIds.filter((x) => x !== id) : [ + ...this.selectedIds, + id + ]; + } + + /** + * What the selected receipts come to. + * + * Shown live as receipts are ticked, because the settlement only goes through when the total + * covers the account — checking that by adding up the rows by eye is exactly what leads to a + * rejected submission. + */ + get selectedTotal(): number { + return this.receipts.filter((r) => this.isSelected(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0); + } + + /** + * Resolves the voucher number the member quotes into a receipt. The lookup is not restricted to + * this member, so a voucher belonging to somebody else is surfaced here as a clear mismatch + * rather than failing later inside apply(). + */ + lookupVoucher(): void { + const voucher = this.voucherNo.trim(); + if (!voucher || this.lookingUp) { + return; + } + this.lookingUp = true; + this.errorMsg = ''; + + this.cashierService + .getShareAdvanceByVoucher(voucher, this.data.clientId) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`); + this.lookingUp = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.lookingUp = false; + if (!res) { + return; + } + if (Number(res.client_id) !== Number(this.data.clientId)) { + this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`; + return; + } + // Show it in the table (replacing any stale copy) and add it to the selection, rather than + // replacing what is already ticked — a looked-up voucher is usually the second half of a + // part-payment, not a change of mind about the first. + this.receipts = [ + res, + ...this.receipts.filter((r) => r.id !== res.id) + ]; + if (!this.isSelected(res.id)) { + this.selectedIds = [ + ...this.selectedIds, + res.id + ]; + } + }); + } + + apply(): void { + if (this.selectedIds.length === 0 || this.submitting) { + return; + } + this.submitting = true; + this.errorMsg = ''; + + // The first receipt leads on the URL; the rest ride along in the body, and the server settles + // them as one transaction. + const [ + leadId, + ...additionalCollectionIds + ] = this.selectedIds; + + this.cashierService + .applyShareAdvance(leadId, { shareAccountId: this.data.shareAccountId, additionalCollectionIds }) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, 'Applying the collection failed.'); + this.submitting = false; + return of(null); + }) + ) + .subscribe((res: any) => { + if (res) { + this.dialogRef.close(res); + } + }); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.html b/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.html new file mode 100644 index 000000000..857d24cf7 --- /dev/null +++ b/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.html @@ -0,0 +1,75 @@ + + +

Debit from Savings

+ + + @if (loading) { +
+ + Checking the member's savings account… +
+ } @else if (errorMsg && !eligibility) { +
{{ errorMsg }}
+ } @else if (eligibility) { + +
+
+ Share value + {{ eligibility.shareAmount | number: '1.2-2' }} +
+ @if (eligibility.chargeAmount > 0) { +
+ Entry fee + {{ eligibility.chargeAmount | number: '1.2-2' }} +
+ } +
+ To debit from savings + {{ eligibility.totalRequired | number: '1.2-2' }} +
+ @if (eligibility.savingsAccountNo) { +
+ Savings account {{ eligibility.savingsAccountNo }} + Balance {{ eligibility.savingsBalance | number: '1.2-2' }} +
+ } +
+ + @if (!eligibility.eligible) { +
{{ eligibility.reason }}
+ } @else { + + +

+ Submitting records a request for a manager to approve. The member's savings are debited only once it is + approved. +

+ @if (errorMsg) { +
{{ errorMsg }}
+ } + } + } +
+ + + + + diff --git a/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.scss b/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.scss new file mode 100644 index 000000000..9c1c102b2 --- /dev/null +++ b/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.scss @@ -0,0 +1,98 @@ +/** + * 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/. + */ + +.dfs-content { + min-width: 420px; +} + +.dfs-loading { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 0; + color: #475569; + font-size: 13.5px; +} + +/* What the member is being charged, laid out as label/amount pairs so the total reads down the + column the way the posting splits it. */ +.dfs-breakdown { + margin: 8px 0 16px; + padding: 12px 14px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #f8fafc; +} + +.dfs-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 3px 0; + font-size: 13.5px; + color: #334155; + + > span:last-child { + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + &--total { + margin-top: 6px; + padding-top: 8px; + border-top: 1px solid #cbd5e1; + font-weight: 600; + color: #0f172a; + } + + &--detail { + margin-top: 6px; + font-size: 12px; + color: #64748b; + } +} + +.dfs-field { + display: block; + margin-bottom: 10px; + + > span { + display: block; + margin-bottom: 4px; + font-size: 12.5px; + color: #475569; + } + + input { + width: 100%; + padding: 8px 10px; + border: 1px solid #cbd5e1; + border-radius: 4px; + font-size: 13.5px; + + &:focus { + outline: none; + border-color: #2563eb; + } + } +} + +.dfs-hint { + margin: 0 0 4px; + font-size: 12px; + color: #64748b; +} + +.dfs-error { + margin: 8px 0 0; + padding: 9px 12px; + border-radius: 6px; + background: #fdecea; + color: #b42318; + font-size: 13px; +} diff --git a/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.ts b/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.ts new file mode 100644 index 000000000..d01c54e67 --- /dev/null +++ b/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.ts @@ -0,0 +1,121 @@ +/** + * 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, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { CashierService } from 'app/cashier/cashier.service'; + +/** + * Settles a share application out of the member's own savings account. + * + * The cash route (Apply Collection) starts at the counter, with money the cashier has already + * received sitting in Share Sundry against a receipt. A member who banks with the society has no + * reason to withdraw cash and hand it straight back, so this debits their savings directly for the + * share value plus any entry fee. + * + * Nothing moves when this dialog is submitted: it records a request for a manager to approve. The + * cash route needs no checker because the cashier's receipt is already a second pair of eyes; here + * nobody has counted anything, so the approval is the first moment the member's balance changes. + */ +@Component({ + selector: 'mifosx-debit-from-savings-dialog', + templateUrl: './debit-from-savings-dialog.component.html', + styleUrls: ['./debit-from-savings-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule + ] +}) +export class DebitFromSavingsDialogComponent implements OnInit { + private cashierService = inject(CashierService); + + loading = true; + submitting = false; + errorMsg = ''; + + /** + * What the settlement would cost and whether the savings account can cover it. `eligible` false + * carries a `reason` naming the blocker — no linked savings, already settled, short balance. + */ + eligibility: { + eligible: boolean; + reason: string | null; + shareAmount: number; + chargeAmount: number; + totalRequired: number; + savingsAccountNo: string | null; + savingsBalance: number | null; + } | null = null; + + note = ''; + + public dialogRef = inject(MatDialogRef); + public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA); + + ngOnInit(): void { + this.cashierService + .getShareSavingsDebitEligibility(this.data.shareAccountId) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.loading = false; + if (!res) { + this.errorMsg = 'Could not check whether this account can be settled from savings.'; + return; + } + this.eligibility = { + eligible: !!res.eligible, + reason: res.reason ?? null, + shareAmount: Number(res.shareAmount) || 0, + chargeAmount: Number(res.chargeAmount) || 0, + totalRequired: Number(res.totalRequired) || 0, + savingsAccountNo: res.savingsAccountNo ?? null, + savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance) + }; + }); + } + + submit(): void { + if (!this.eligibility?.eligible || this.submitting) { + return; + } + this.submitting = true; + this.errorMsg = ''; + this.cashierService + .submitShareSavingsDebit({ + shareAccountId: this.data.shareAccountId, + note: this.note?.trim() || undefined + }) + .subscribe({ + next: (res: any) => { + this.submitting = false; + this.dialogRef.close(res); + }, + error: (err: any) => { + this.submitting = false; + this.errorMsg = + err?.error?.defaultUserMessage || err?.error?.message || 'Could not submit the savings-debit request.'; + } + }); + } + + cancel(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/shares/shares-account-view/general-tab/general-tab.component.html b/src/app/shares/shares-account-view/general-tab/general-tab.component.html index beced4602..3d1be02a3 100644 --- a/src/app/shares/shares-account-view/general-tab/general-tab.component.html +++ b/src/app/shares/shares-account-view/general-tab/general-tab.component.html @@ -51,6 +51,19 @@
{{ 'labels.inputs.Resolution Number' | translate }}{{ sharesAccountData.resolutionNumber || ('labels.inputs.Unassigned' | translate) }}
{{ 'labels.inputs.Resolution Date' | translate }}{{ sharesAccountData.resolutionDate | dateFormat }}{{ 'labels.inputs.Unassigned' | translate }}
{{ 'labels.inputs.Linked Savings Account(Dividend Posting)' | translate }}
- - - - - - - - - - - - - - - @for (txn of filteredTransactions; track txn.id; let i = $index) { - - - - - - - - - - +
+
#DateDescriptionRef No.Shares PurchasedShares RedeemedShare BalanceAmount (₹)Resolution No. / Meeting
{{ i + 1 }}{{ txn.purchasedDate | dateFormat }} - {{ txn.type.value }} ({{ txn.status.value }}) - {{ txn.id }} - @if (!isRedeem(txn)) { - {{ txn.numberOfShares }} - } - - @if (isRedeem(txn)) { - {{ txn.numberOfShares }} - } - {{ txn.runningBalance }} - {{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }} - - @if (isRedeem(txn)) { - {{ txn.resolutionNumber || '—' }} - @if (txn.meetingDate) { - ({{ txn.meetingDate | dateFormat }}) - } - } @else { - — - } -
+ + + + + + + + + + + - } - - - - - - - - -
#DateDescriptionRef No.Shares PurchasedShares RedeemedShare BalanceAmount (₹)Resolution No. / Meeting
Closing Share Balance{{ totalApprovedShares }}
+ + + @for (txn of filteredTransactions; track txn.id; let i = $index) { + + {{ i + 1 }} + {{ txn.purchasedDate | dateFormat }} + + {{ txn.type.value }} ({{ txn.status.value }}) + + {{ txn.id }} + + @if (!isRedeem(txn)) { + {{ txn.numberOfShares }} + } + + + @if (isRedeem(txn)) { + {{ txn.numberOfShares }} + } + + {{ txn.runningBalance }} + + {{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }} + + + @if (isRedeem(txn)) { + {{ txn.resolutionNumber || '—' }} + @if (txn.meetingDate) { + ({{ txn.meetingDate | dateFormat }}) + } + } @else { + + {{ resolutionNumber || '—' }} + @if (resolutionDate) { + ({{ resolutionDate | dateFormat }}) + } + } + + + } + + + + Closing Share Balance + {{ totalApprovedShares }} + + + + + } @else {
inbox diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss index cfe41963e..6532f148e 100644 --- a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss @@ -214,6 +214,14 @@ $row-alt: #f4f7fb; } // ─── Transactions Table ─────────────────────────────────────────────────────── +// The columns below are fixed-width so each cell stays under its own header. On a narrow +// viewport that total exceeds the card, so the table scrolls here rather than being clipped +// by the wrapper's overflow:hidden. +.statement-table-scroll { + width: 100%; + overflow-x: auto; +} + .statement-table { width: 100%; border-collapse: collapse; @@ -225,6 +233,10 @@ $row-alt: #f4f7fb; border-bottom: 2px solid $bank-primary; } + // Headers default to left so each one starts exactly where its cell's text starts; the + // numeric columns opt into right alignment via .text-right below. Without this the browser + // (and Material's table styles) can centre the heading while the cell stays left, which + // reads as the whole column being out of position. thead th { padding: 9px 10px; font-size: 11px; @@ -233,6 +245,25 @@ $row-alt: #f4f7fb; text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; + text-align: left; + } + + // Alignment helpers must win over the thead default above, for header and cell alike, so + // a column's heading and its values always share one alignment. + thead th, + tbody td, + tfoot td { + &.text-right { + text-align: right; + } + + &.text-center { + text-align: center; + } + + &.text-left { + text-align: left; + } } tbody tr { @@ -274,20 +305,36 @@ $row-alt: #f4f7fb; white-space: nowrap; } +// Description is the only flexible column: it absorbs the leftover width so every other +// column keeps the fixed size its header needs and stays aligned with its cells. .col-desc { - min-width: 140px; + width: auto; + min-width: 160px; +} + +// Board Resolution No. / Meeting Date — wide enough to hold "00RK2 (14 August 2026)" +// on one line so it does not crowd the right-aligned Amount column beside it. +.col-resolution { + width: 190px; + white-space: nowrap; + padding-left: 16px; } .col-ref { - width: 70px; + width: 80px; + white-space: nowrap; } +// Sized to the widest header label ("Shares Purchased") so the numeric cells sit directly +// under their own heading rather than drifting toward the neighbouring column. .col-amount { - width: 100px; + width: 128px; + white-space: nowrap; } .col-balance { - width: 90px; + width: 112px; + white-space: nowrap; } // ─── Amount colors ──────────────────────────────────────────────────────────── @@ -335,6 +382,10 @@ $row-alt: #f4f7fb; text-align: center; } +.text-left { + text-align: left; +} + .text-muted { color: #999; } @@ -450,6 +501,12 @@ $row-alt: #f4f7fb; border-radius: 0; } + // On paper there is nothing to scroll — let the table lay out at full width so no + // column is cut off at the page edge. + .statement-table-scroll { + overflow-x: visible; + } + .bank-header { -webkit-print-color-adjust: exact; print-color-adjust: exact; diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts index 4b882635f..f9cca00b8 100644 --- a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts @@ -47,6 +47,7 @@ export class SharesPassbookTabComponent implements OnInit { status = ''; activatedOnDate: any = null; resolutionNumber = ''; + resolutionDate: any = null; savingsAccountNumber = ''; totalApprovedShares = 0; currentMarketPrice = 0; @@ -75,6 +76,7 @@ export class SharesPassbookTabComponent implements OnInit { this.status = account.status?.value || ''; this.activatedOnDate = account.timeline?.activatedDate || null; this.resolutionNumber = account.resolutionNumber || ''; + this.resolutionDate = account.resolutionDate || null; this.savingsAccountNumber = account.savingsAccountNumber || ''; this.totalApprovedShares = account.summary?.totalApprovedShares || 0; this.currentMarketPrice = account.currentMarketPrice || 0; diff --git a/src/app/shares/shares-account-view/settled-account.guard.ts b/src/app/shares/shares-account-view/settled-account.guard.ts new file mode 100644 index 000000000..e033a51a7 --- /dev/null +++ b/src/app/shares/shares-account-view/settled-account.guard.ts @@ -0,0 +1,61 @@ +/** + * 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 { ActivatedRouteSnapshot, CanActivateFn, Router } from '@angular/router'; + +/** + * Keeps the Transactions, Passbook and Charges tabs closed until the share application is settled. + * + * Those tabs describe money that has moved. Until the member has actually been debited and the + * shares issued, nothing has: the purchase sits pending approval, and showing a purchase entry, a + * passbook line and an entry fee reads as though the member has paid and holds shares when they + * have done neither. + * + * Hiding the tab links alone would leave the pages reachable by typing the URL, which is exactly + * where someone verifying an account is likely to go. Anyone who does is sent back to General. + * + * Judged by `collectionApplied` rather than the account's status or the purchase transaction's + * status: an account is Approved and then Activated before anything is settled, and the purchase + * reads Approved from the moment the account is approved. Only this marks money actually taken. + */ +export const settledAccountGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { + const router = inject(Router); + + // The account resolves on the parent (SharesAccountViewComponent) route; these tabs are its + // children. Angular merges resolved data down the tree, so route.data carries it too — read from + // the parent explicitly so this does not depend on that merge. + const shareAccount = route.parent?.data?.['sharesAccountData'] ?? route.data?.['sharesAccountData']; + + if (shareAccount?.collectionApplied) { + return true; + } + // Redirect rather than block outright, so the officer lands somewhere useful instead of a blank + // page with no explanation. Built from the route's own params rather than a relative path, + // because a guard only has the snapshot and createUrlTree's relativeTo needs a live route. + // Walk up for the ids rather than assuming a depth: both live on ancestors of this tab route + // (shareAccountId on the ':shareAccountId' route, clientId further up under /clients). + let node: ActivatedRouteSnapshot | null = route; + let clientId: string | null = null; + let accountId: string | null = null; + while (node && !(clientId && accountId)) { + accountId = accountId ?? node.paramMap.get('shareAccountId'); + clientId = clientId ?? node.paramMap.get('clientId'); + node = node.parent; + } + if (clientId && accountId) { + return router.createUrlTree([ + '/clients', + clientId, + 'shares-accounts', + accountId, + 'general' + ]); + } + return false; +}; diff --git a/src/app/shares/shares-account-view/shares-account-view.component.html b/src/app/shares/shares-account-view/shares-account-view.component.html index 4402b9160..816b8bd5d 100644 --- a/src/app/shares/shares-account-view/shares-account-view.component.html +++ b/src/app/shares/shares-account-view/shares-account-view.component.html @@ -135,36 +135,41 @@ > {{ 'labels.inputs.General' | translate }} - - {{ 'labels.inputs.Transactions' | translate }} - - - Passbook - - - {{ 'labels.inputs.Charges' | translate }} - + + @if (isSettled) { + + {{ 'labels.inputs.Transactions' | translate }} + + + Passbook + + + {{ 'labels.inputs.Charges' | translate }} + + } { + if (result) { + // Account data comes from a route resolver, so re-running the route is what refreshes the + // newly revealed transaction and the now-paid charges. + this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { + this.router.navigate([ + '/clients', + this.sharesAccountData.clientId, + 'shares-accounts', + this.sharesAccountData.id + ]); + }); + } + }); + } + + /** + * Whether the member's money has actually been taken and the shares issued. + * + * Until then the account is only an application: nothing has been debited and the member holds + * nothing. The Transactions, Passbook and Charges tabs all describe money that has moved, so + * showing them at that point reads as though the member has paid and holds shares when they have + * done neither. + * + * Neither the account's status nor the purchase transaction's status can stand in for this: an + * account goes Approved then Activated before anything is settled, and the purchase reads + * Approved from the moment the account is approved. Only `collectionApplied` marks the point at + * which the money was actually taken — by cash collection or by debiting savings. + */ + get isSettled(): boolean { + return !!this.sharesAccountData?.collectionApplied; + } + + /** + * Settles this account out of the member's linked savings instead of cash at the counter. + * + * Only records a request — a manager's approval is what debits the member and issues the shares — + * so unlike Apply Collection there is nothing to refresh here until that approval happens. + */ + private debitFromSavings() { + this.dialog.open(DebitFromSavingsDialogComponent, { + width: '560px', + data: { + shareAccountId: this.sharesAccountData.id, + clientId: this.sharesAccountData.clientId, + accountNo: this.sharesAccountData.accountNo + } + }); + } + /** * Deletes Shares Account. */ diff --git a/src/app/shares/shares-account-view/shares-buttons.config.ts b/src/app/shares/shares-account-view/shares-buttons.config.ts index 5228477ba..539336cbb 100644 --- a/src/app/shares/shares-account-view/shares-buttons.config.ts +++ b/src/app/shares/shares-account-view/shares-buttons.config.ts @@ -36,6 +36,21 @@ export class SharesButtonsConfiguration { switch (status) { case 'Active': this.buttonsArray = [ + { + // Settles the member's Share Advance receipt: moves the money out of Share Sundry + // into share capital and reveals the purchase in the transactions/passbook. + name: 'Apply Collection', + icon: 'hand-holding-usd', + taskPermissionName: 'CREATE_JOURNALENTRY' + }, + { + // The same settlement for a member who banks here: debits their linked savings for the + // share value plus entry fee instead of taking cash at the counter. Raises a request + // rather than posting, because no cashier has counted anything to check it against. + name: 'Debit from Savings', + icon: 'money-check-alt', + taskPermissionName: 'CREATE_JOURNALENTRY' + }, { name: 'Apply Additional Shares', icon: 'arrow-right', diff --git a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html index 769d76d05..cb819bf7e 100644 --- a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html @@ -68,7 +68,12 @@ ({{ transaction.meetingDate | dateFormat }}) } } @else { - — + + {{ shareAccountData.resolutionNumber || '—' }} + @if (shareAccountData.resolutionDate) { + ({{ shareAccountData.resolutionDate | dateFormat }}) + } } diff --git a/src/app/shares/shares-routing.module.ts b/src/app/shares/shares-routing.module.ts index d12979e83..83cb8e0fc 100644 --- a/src/app/shares/shares-routing.module.ts +++ b/src/app/shares/shares-routing.module.ts @@ -27,6 +27,7 @@ import { SharesAccountTemplateResolver } from './common-resolvers/shares-account import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver'; import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component'; import { SharesPassbookTabComponent } from './shares-account-view/passbook-tab/passbook-tab.component'; +import { settledAccountGuard } from './shares-account-view/settled-account.guard'; /** Shares Routes */ const routes: Routes = [ @@ -71,19 +72,25 @@ const routes: Routes = [ sharesAccountData: SharesAccountViewResolver } }, + // Guarded as well as hidden from the tab bar: these three describe money that has + // moved, and until the application is settled none has. Hiding the links alone would + // leave the pages reachable by typing the URL. { path: 'transactions', component: TransactionsTabComponent, + canActivate: [settledAccountGuard], data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false } }, { path: 'passbook', component: SharesPassbookTabComponent, + canActivate: [settledAccountGuard], data: { title: 'Shares Account Passbook', breadcrumb: 'Passbook', routeParamBreadcrumb: false } }, { path: 'charges', component: ChargesTabComponent, + canActivate: [settledAccountGuard], data: { title: 'Shares Account Charges', breadcrumb: 'Charges', routeParamBreadcrumb: false } }, { diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.html b/src/app/sponsor-dd/dd-list/dd-list.component.html index ab18d5ec0..1fe03e4c3 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.html +++ b/src/app/sponsor-dd/dd-list/dd-list.component.html @@ -75,7 +75,12 @@ Purchaser / Client - {{ element.issuedToClientName || '-' }} + + + {{ element.issuedToClientName || element.walkinPurchaserName || '-' }} + diff --git a/src/app/system/system.service.ts b/src/app/system/system.service.ts index 388c0c3be..b5e69a37d 100644 --- a/src/app/system/system.service.ts +++ b/src/app/system/system.service.ts @@ -8,10 +8,12 @@ /** Angular Imports */ import { Injectable, inject } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpContext, HttpParams } from '@angular/common/http'; /** rxjs Imports */ import { Observable } from 'rxjs'; + +import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token'; import { RunJobWithParamPayloadType } from './manage-jobs/scheduler-jobs/custom-parameters-popover/custom-parameters-popover.component'; /** @@ -446,8 +448,11 @@ export class SystemService { * @param {string} configurationId Configuration ID of configuration. * @returns {Observable} Configuration. */ - getConfigurationByName(configurationName: string): Observable { - return this.http.get(`/configurations/name/${configurationName}`); + getConfigurationByName(configurationName: string, silent = false): Observable { + return this.http.get( + `/configurations/name/${configurationName}`, + silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {} + ); } /** diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.html new file mode 100644 index 000000000..94d00bc26 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.html @@ -0,0 +1,109 @@ + + +
+ +
+ +
+ + + + + + + + + + + + + + + @if (loading) { + + + + } @else if (loaded && rows.length === 0) { + + + + } @else { + @for (row of rows; track row.id) { + + + + + + + + + + + @if (isShort(row)) { + + + + } + @if (actionError[row.id]) { + + + + } + @if (expandedRejectId === row.id) { + + + + } + } + } + +
MemberShare AccountSavings AccountSharesEntry FeeTo DebitRequested By
Loading…
No savings-debit requests are waiting for approval.
{{ row.client_name }}{{ row.share_account_no }} + {{ row.savings_account_no }} + +
+ Balance {{ row.savings_balance | number: '1.2-2' }} +
+
{{ row.share_amount | number: '1.2-2' }}{{ row.charge_amount | number: '1.2-2' }} + {{ row.total_amount | number: '1.2-2' }} + + {{ row.clerk_name }} +
{{ row.submitted_at | slice: 0 : 10 }}
+
+ + +
+ Savings holds {{ row.savings_balance | number: '1.2-2' }} but + {{ row.total_amount | number: '1.2-2' }} is required. Take a deposit first, or reject this and settle by + cash collection. +
{{ actionError[row.id] }}
+
+ + +
+
+
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.scss new file mode 100644 index 000000000..d4cd29ac7 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.scss @@ -0,0 +1,32 @@ +/** + * 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/. + */ + +/* A savings account that no longer covers the request, and any error the server returned. Both + read as blocking conditions on the row, so they share the same treatment. */ +.ssd-warn { + color: #b42318; +} + +.ssd-warn-row { + padding: 8px 12px; + background: #fdecea; + color: #b42318; + font-size: 12.5px; +} + +.ssd-sub { + font-size: 12px; + color: #64748b; +} + +.ssd-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + white-space: nowrap; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.ts new file mode 100644 index 000000000..bbe485811 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.ts @@ -0,0 +1,120 @@ +/** + * 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 { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { CashierService } from '../../../cashier/cashier.service'; + +/** + * Approval Center tab — share applications a clerk has asked to settle out of the member's own + * savings, awaiting a manager. + * + * Approving is the moment the member's balance actually changes: it withdraws the share value plus + * entry fee from their savings and issues the shares. Nothing has moved before that, which is why + * this step exists at all — the cash route needs no checker because the cashier's receipt is + * already a second pair of eyes, whereas here nobody has counted anything. + * + * A manager cannot approve a request they raised themselves; the server rejects that, and the + * message is surfaced on the row rather than swallowed. + */ +@Component({ + selector: 'mifosx-share-savings-debit-requests', + templateUrl: './share-savings-debit-requests.component.html', + styleUrls: [ + '../../../cashier/cashier-shared.scss', + '../../../clerk/clerk-shared.scss', + './share-savings-debit-requests.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule + ] +}) +export class ShareSavingsDebitRequestsComponent implements OnInit { + private cashierService = inject(CashierService); + + rows: any[] = []; + loading = false; + loaded = false; + + actioningId: number | null = null; + actionError: Record = {}; + + /** Which row has its reject box open, and what reason has been typed into it. */ + expandedRejectId: number | null = null; + rejectReason: Record = {}; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.cashierService + .getShareSavingsDebitRequests('PENDING') + .pipe(catchError(() => of([]))) + .subscribe((res: any) => { + this.rows = res ?? []; + this.loading = false; + this.loaded = true; + }); + } + + approve(row: any): void { + this.actioningId = row.id; + delete this.actionError[row.id]; + this.cashierService.approveShareSavingsDebit(row.id).subscribe({ + next: () => { + this.actioningId = null; + this.load(); + }, + error: (err: any) => { + this.actioningId = null; + this.actionError[row.id] = + err?.error?.defaultUserMessage || err?.error?.message || 'Could not approve this request.'; + } + }); + } + + toggleReject(row: any): void { + this.expandedRejectId = this.expandedRejectId === row.id ? null : row.id; + } + + reject(row: any): void { + this.actioningId = row.id; + delete this.actionError[row.id]; + this.cashierService.rejectShareSavingsDebit(row.id, this.rejectReason[row.id]?.trim()).subscribe({ + next: () => { + this.actioningId = null; + this.expandedRejectId = null; + this.load(); + }, + error: (err: any) => { + this.actioningId = null; + this.actionError[row.id] = + err?.error?.defaultUserMessage || err?.error?.message || 'Could not reject this request.'; + } + }); + } + + /** + * Whether the savings account still covers what the request asks for. + * + * The balance can move between a clerk raising the request and a manager reaching it, and the + * server refuses a short account — showing it on the row means the manager sees why before + * clicking rather than after. + */ + isShort(row: any): boolean { + return Number(row.savings_balance ?? 0) < Number(row.total_amount ?? 0); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index e47b8257a..ffcc701fa 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -153,6 +153,15 @@ Dividend Requests
+ + + Share Savings Debit +