From cc5cd4c6abd08011a592ec5abd59847c6f7eff30 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 5 Aug 2026 16:55:21 +0530 Subject: [PATCH] feat(shares): add Share Withdrawal maker-checker workflow with GL settlement Clerk submits a governed share redemption request (shares, Board Resolution No., Meeting Date, settlement Payment Type); Bank Manager reviews with a live backend-validated checklist (account/GL state, not just submit-time snapshot) before approving, which redeems the shares and settles the linked savings account in one transaction. Adds a Passbook tab to the Share Account view, a reusable search-select control, and a Savings Product "Map Payment Types to Specific Funds" fund-source override (only Loan Products had this before) so settlement can be GL-mapped correctly. Co-Authored-By: Claude Sonnet 5 --- src/app/cashier/cashier-routing.module.ts | 16 + .../cashier/clerk-share-withdrawal.service.ts | 53 ++ .../transaction-entry.component.html | 129 ++++- .../transaction-entry.component.scss | 8 + .../transaction-entry.component.ts | 344 ++++++++++++- src/app/clerk/clerk-shared.scss | 77 +++ ...y-share-withdrawal-requests.component.html | 73 +++ .../my-share-withdrawal-requests.component.ts | 112 +++++ .../share-withdrawal-request.component.html | 393 +++++++++++++++ .../share-withdrawal-request.component.scss | 410 ++++++++++++++++ .../share-withdrawal-request.component.ts | 413 ++++++++++++++++ src/app/core/shell/shell.component.html | 13 + src/app/core/shell/shell.component.ts | 4 +- ...ing-product-accounting-step.component.html | 45 ++ ...aving-product-accounting-step.component.ts | 53 +- ...share-product-accounting-step.component.ts | 11 +- .../search-select/search-select.component.ts | 236 +++++++++ .../passbook-tab/passbook-tab.component.html | 210 ++++++++ .../passbook-tab/passbook-tab.component.scss | 462 ++++++++++++++++++ .../passbook-tab/passbook-tab.component.ts | 182 +++++++ .../shares-account-view.component.html | 10 + .../shares-buttons.config.ts | 8 +- .../transactions-tab.component.html | 19 + .../transactions-tab.component.ts | 24 +- src/app/shares/shares-routing.module.ts | 6 + .../share-withdrawal-requests.component.html | 86 ++++ .../share-withdrawal-requests.component.scss | 60 +++ .../share-withdrawal-requests.component.ts | 128 +++++ .../checker-inbox-and-tasks.component.html | 12 + src/app/tasks/tasks-routing.module.ts | 19 + src/environments/.env.ts | 4 +- 31 files changed, 3565 insertions(+), 55 deletions(-) create mode 100644 src/app/cashier/clerk-share-withdrawal.service.ts create mode 100644 src/app/clerk/clerk-shared.scss create mode 100644 src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.html create mode 100644 src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.ts create mode 100644 src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html create mode 100644 src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss create mode 100644 src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts create mode 100644 src/app/shared/search-select/search-select.component.ts create mode 100644 src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html create mode 100644 src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss create mode 100644 src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.ts diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 86568fc2a..fca321b4b 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -62,6 +62,22 @@ const routes: Routes = [ ), data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' } }, + { + path: 'share-withdrawal-request', + loadComponent: () => + import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then( + (m) => m.ShareWithdrawalRequestComponent + ), + data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' } + }, + { + path: 'share-withdrawal-requests', + loadComponent: () => + import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then( + (m) => m.MyShareWithdrawalRequestsComponent + ), + data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' } + }, { path: 'account-facilities', loadComponent: () => diff --git a/src/app/cashier/clerk-share-withdrawal.service.ts b/src/app/cashier/clerk-share-withdrawal.service.ts new file mode 100644 index 000000000..fde90ad02 --- /dev/null +++ b/src/app/cashier/clerk-share-withdrawal.service.ts @@ -0,0 +1,53 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow β€” mirrors + * ClerkWithdrawalService (cash withdrawal), adapted for shares. */ +@Injectable({ providedIn: 'root' }) +export class ClerkShareWithdrawalService { + private http = inject(HttpClient); + + submit(data: any): Observable { + return this.http.post('/clerk/share-withdrawal-requests', data); + } + + getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable { + let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit)); + if (status) params = params.set('status', status); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + return this.http.get('/clerk/share-withdrawal-requests/my', { params }); + } + + getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable { + let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit)); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + return this.http.get('/clerk/share-withdrawal-requests', { params }); + } + + getById(id: number): Observable { + return this.http.get(`/clerk/share-withdrawal-requests/${id}`); + } + + validate(id: number): Observable { + return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {}); + } + + approve(id: number): Observable { + return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {}); + } + + reject(id: number, reason: string): Observable { + return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason }); + } +} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 9275e0c3e..0b62d5029 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -146,7 +146,7 @@ [class.ws-f__ctl--req]="!row.paymentTypeId" > - @for (pt of paymentTypes; track pt.id) { + @for (pt of paymentTypesForRow(row); track pt.id) { } @@ -231,8 +231,11 @@ Payment instrument
-
+ @if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) { + = β‚Ή{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ β‚Ή{{ + rowSharePrices[row.acNo] | number: '1.2-2' + }}/share + }
- @if (row.typ !== 'Withdrawal Slip') { + @if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') { +
+ + +
+
+ + +
+ } @else if (row.typ !== 'Withdrawal Slip') {
+ @if (selectedRow?.acTyp === 'SHR') { +
+ No. of Shares Held{{ accountDetails.summary?.totalApprovedShares ?? 0 }} +
+
+ Market Price / Share{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }} +
+
+ Resolution No.{{ accountDetails.resolutionNumber || 'β€”' }} +
+
+ Linked Savings A/C No.{{ accountDetails.savingsAccountNumber || 'β€”' }} +
+ @if (accountDetails.linkedLoanAccountNo) { +
+ Linked Loan A/C No.{{ accountDetails.linkedLoanAccountNo }} +
+ } + }
@@ -724,7 +781,13 @@
1. - {{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }} + {{ + selectedRow?.acTyp === 'SHR' + ? 'Share Balance' + : accountDetails?.glCode + ? 'GL Balance' + : 'Current Balance' + }} {{ currencySymbol }}{{ currentBalance | number: '1.2-2' }} @@ -1281,7 +1344,7 @@ [class.ws-f__ctl--req]="!row.paymentTypeId" > - @for (pt of paymentTypes; track pt.id) { + @for (pt of paymentTypesForRow(row); track pt.id) { } @@ -1360,7 +1423,7 @@
+ @if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) { + = β‚Ή{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ β‚Ή{{ + rowSharePrices[row.acNo] | number: '1.2-2' + }}/share + }
-
- - -
-
- - -
+ @if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') { +
+ + +
+
+ + +
+ } @else { +
+ + +
+
+ + +
+ }
!pt.isCashPayment); + } + const isShareSettlementCredit = + row.typ === 'Enter Bank Transfer' && + row.dbCr === 'Cr' && + this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR'); + if (isShareSettlementCredit) { + return this.paymentTypes.filter((pt: any) => !pt.isCashPayment); + } + return this.paymentTypes; + } + // Per-row account id/name map: acNo β†’ Fineract id/name (used for multi-row disbursement) rowAccountIds: Record = {}; rowAccountNames: Record = {}; + // Per-row Share account market price, keyed by acNo β€” lets a Share row's "SHARE NO." field + // auto-fill the settlement amount on any other row added alongside it (calculator convenience + // only; a Share row still can never itself be saved from this screen β€” see accountTypeConfig). + rowSharePrices: Record = {}; + // Per-row client id, keyed by acNo β€” needed to submit a Share Withdrawal Request from a row + // that isn't necessarily the currently "selected" one. + rowClientIds: Record = {}; + // Per-row linked savings account number, keyed by the Share account's own acNo β€” used to make + // sure a Share-to-linked-account Bank Transfer actually credits the right account. + rowLinkedSavingsAcNo: Record = {}; // Full account + client data accountDetails: any = null; @@ -1205,6 +1240,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }; this.rows.push(mirror); this.selectedRow = mirror; + if (active.acTyp === 'SHR') { + this.syncShareLinkedAmounts(); + } return; } const row: TxnRow = { @@ -1499,6 +1537,182 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }); } + /** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its + * "SHARE NO." field), CR row must be that account's own linked savings account. Submits a + * governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) β€” + * approval both redeems the shares and settles the linked account automatically. */ + private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void { + // Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings) + // row (see paymentTypesForRow()), but re-check here too in case its payment type was set before + // that filter applied β€” settlement is always a book-transfer to the linked savings account, and + // it's the CR row's payment type that actually governs how that credit posts, never the DR + // (Share) row's, which has no real payment-type concept. + const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId); + if (!savingsRow.paymentTypeId) { + this.showAlert('error', 'Settlement Payment Type Required', [ + 'Select a Payment Type on the CR (savings account) row β€” it governs how the settlement credit posts.' + ]); + return; + } + if (settlementPaymentType?.isCashPayment) { + this.showAlert('error', 'Cash Not Allowed for Share Transactions', [ + 'Share redemption settlement is always a book-transfer to the linked savings account, never cash.', + 'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).' + ]); + return; + } + + const shareAccountId = this.rowAccountIds[shareRow.acNo]; + if (!shareAccountId) { + this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]); + return; + } + if (!this.rowAccountIds[savingsRow.acNo]) { + this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]); + return; + } + + const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo]; + const enteredAcNo = savingsRow.acNo?.trim(); + const matchesLinked = + !!linkedAcNo && + (linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo); + if (linkedAcNo && !matchesLinked) { + this.showAlert('error', 'Wrong Credit Account', [ + `The CR account must be this Share account's own linked savings account: ${linkedAcNo}`, + `You entered: ${enteredAcNo}` + ]); + return; + } + if (!linkedAcNo) { + this.showAlert('error', 'No Linked Savings Account', [ + 'This Share account has no linked savings account configured β€” settlement has nowhere to go.' + ]); + return; + } + + const shares = Number(shareRow.amount) || 0; + if (shares <= 0) { + this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']); + return; + } + if (!shareRow.resolutionNumber?.trim()) { + this.showAlert('error', 'Board Resolution Number Required', [ + 'A share withdrawal must be authorised by a Board Resolution.' + ]); + return; + } + if (!shareRow.meetingDate) { + this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']); + return; + } + + const today = new Date(); + const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + // Full snapshot of the CR (settlement) row exactly as the clerk filled it in β€” saved verbatim + // for audit purposes. Business logic still only uses settlementPaymentTypeId above; the rest of + // these fields are not re-read by the backend, they are the audit record of what was on screen. + const settlementRowSnapshot = { + branch: savingsRow.br, + accountType: savingsRow.acTyp, + accountNo: savingsRow.acNo, + ecsAccountNo: savingsRow.ecsAcNo || null, + dbCr: savingsRow.dbCr, + amount: savingsRow.amount ?? null, + chequeNo: savingsRow.chequeNo || null, + chequeDate: savingsRow.cheqDate || null, + tokenNo: savingsRow.tokNo || null, + description: savingsRow.description || null, + bankName: savingsRow.bankName || null, + routingCode: savingsRow.routingCode || null, + paymentTypeId: savingsRow.paymentTypeId, + paymentTypeName: settlementPaymentType?.name ?? null + }; + + const payload = { + shareAccountId, + accountNo: shareRow.acNo, + clientId: this.rowClientIds[shareRow.acNo] ?? null, + clientName: this.rowAccountNames[shareRow.acNo] ?? null, + requestedShares: shares, + requestedDate, + resolutionNumber: shareRow.resolutionNumber.trim(), + meetingDate: shareRow.meetingDate, + description: shareRow.description || null, + settlementPaymentTypeId: savingsRow.paymentTypeId, + settlementRowSnapshot + }; + + const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0; + const confirmLines = [ + `Share Account: ${shareRow.acNo} Β· Client: ${this.rowAccountNames[shareRow.acNo] ?? 'β€”'}`, + `Shares to Withdraw: ${shares} @ β‚Ή${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = β‚Ή${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, + `Board Resolution No.: ${payload.resolutionNumber}`, + `Date of Meeting: ${payload.meetingDate}`, + `Settlement Credit To: ${linkedAcNo}`, + `Settlement Payment Type: ${settlementPaymentType?.name ?? 'β€”'}`, + ...(settlementRowSnapshot.ecsAccountNo ? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`] : []), + ...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []), + ...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []), + ...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []), + ...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []), + '', + 'Validation checks:', + ` βœ“ Share account and linked savings account both loaded`, + ` βœ“ CR account matches this Share account's linked savings account`, + ` βœ“ Settlement Payment Type is non-cash`, + ` βœ“ Board Resolution Number and Date of Meeting provided`, + '', + 'Pending Manager approval β€” redemption and settlement both post together on approval.' + ]; + this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () => + this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo) + ); + } + + private doSubmitShareRedemptionTransfer( + payload: Record, + shareRow: TxnRow, + shares: number, + linkedAcNo: string + ): void { + this.saving = true; + this.clerkShareWithdrawalService + .submit(payload) + .pipe( + catchError((err: any) => { + const msg = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + err?.error?.defaultUserMessage ?? + 'Share withdrawal submission failed. Please try again.'; + this.showAlert('error', 'Submission Failed', [msg]); + this.saving = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (res) { + this.showAlert('success', 'Share Withdrawal Submitted', [ + `${shares} share(s) from ${shareRow.acNo} β†’ settlement to ${linkedAcNo} on approval.`, + 'Pending Manager approval. Track status in "My Share Requests".' + ]); + this.rows = []; + this.rowAccountIds = {}; + this.rowAccountNames = {}; + this.rowSharePrices = {}; + this.rowClientIds = {}; + this.rowLinkedSavingsAcNo = {}; + this.addRow(); + this.selectedRow = this.rows[0]; + this.accountDetails = null; + this.clientDetails = null; + } + }); + } + /** Cashier enters a token number β†’ look up the APPROVED withdrawal request for auto-fill. */ onTokNoBlur(row: TxnRow): void { if (this.isClerk || !row.tokNo?.trim()) return; @@ -1535,6 +1749,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }); } + /** When exactly one row on screen is a Share account with a known market price, auto-fills every + * OTHER row's amount as (shares entered on that row) Γ— (its market price) β€” a calculator + * convenience for filling the paired settlement entry. The Share row itself still can never be + * saved from this screen (see the SHR guard in confirmSubmit()) β€” this only helps compute the + * correct amount to carry over into the separate manual Deposit Slip entry. */ + syncShareLinkedAmounts(): void { + const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]); + if (shareRows.length !== 1) return; + const shareRow = shareRows[0]; + const price = this.rowSharePrices[shareRow.acNo] ?? 0; + const shares = Number(shareRow.amount) || 0; + const total = Math.round(shares * price * 100) / 100; + this.rows.forEach((r) => { + if (r !== shareRow) { + r.amount = total; + } + }); + } + onAcNoBlur(row: TxnRow, index: number): void { if (!row.acNo.trim()) return; this.selectedRow = row; @@ -1633,12 +1866,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { if (token !== this.searchToken) return; // stale result β€” newer search started const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []); const f = cfg.matchField; - // Exact match first, then suffix/partial match (backend may return by last-N-digits) + // Exact match first, then suffix/partial match. Some account types (e.g. Share β€” its + // list endpoint only accepts offset/limit, no accountNo filter at all) always return the + // FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be + // gated on "exactly one row came back" β€” it never would be for those types. const exactMatch = allItems.find((x: any) => x[f] === acNo); - const partialMatch = - !exactMatch && allItems.length === 1 - ? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo)) - : null; + const partialMatch = !exactMatch + ? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo))) + : null; const match = exactMatch ?? partialMatch ?? null; if (match?.id) { const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null; @@ -1659,6 +1894,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { siAccountType: number; txnBase: string; txnSearchSupported: boolean; + /** True when this account type has no separate REST transactions sub-resource at all (e.g. + * Share accounts β€” Fineract returns transactions embedded on the account detail response + * itself via ?associations=all, never at /accounts/share/{id}/transactions). Skips the + * "today's transactions" HTTP call entirely instead of hitting a 404. */ + txnFetchUnsupported?: boolean; + /** True when this account type has no standing-instruction "from account type" mapping on + * the backend at all (PortfolioAccountType only defines LOAN/SAVINGS/CURRENT β€” Share isn't + * a valid standing-instruction source account). Skips that HTTP call entirely instead of + * sending an fromAccountType value the backend's query-builder silently can't match, + * which otherwise produces a malformed "WHERE " SQL statement server-side. */ + siFetchUnsupported?: boolean; maxLimit: number; skipGlobalSearch: boolean; listQueryParam: string; @@ -1693,6 +1939,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { siAccountType: 4, txnBase: '/accounts/share', txnSearchSupported: false, + txnFetchUnsupported: true, + siFetchUnsupported: true, maxLimit: 500, skipGlobalSearch: true, listQueryParam: 'accountNo', @@ -1805,6 +2053,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { siAccountType: number; txnBase: string; txnSearchSupported: boolean; + txnFetchUnsupported?: boolean; + siFetchUnsupported?: boolean; detailParams?: string; }, token: number, @@ -1837,13 +2087,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.rowAccountIds[this.selectedRow.acNo] = acc.id; this.rowAccountNames[this.selectedRow.acNo] = acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? ''; + this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null; } if (this.selectedRow) { - this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? ''; + // Share accounts: ECS field is the share account's own number β€” acc.savingsAccountNumber + // is a different, unrelated account (the linked settlement savings account) and must + // never be shown here. + this.selectedRow.ecsAcNo = + acTyp?.toUpperCase() === 'SHR' + ? (acc.accountNo ?? '') + : (acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? ''); } if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp); this.computeSameDayNeftRtgs(acc.transactions); this.computeSameDayChequeClearing(acc.transactions); + if (acTyp?.toUpperCase() === 'SHR' && this.selectedRow?.acNo) { + // Keyed by the row's own typed acNo (e.g. "0011"), not the resolved full account number + // (e.g. "000000011") β€” rowAccountIds/rowAccountNames above use the same key, and every + // lookup elsewhere (syncShareLinkedAmounts, submitShareRedemptionTransfer, the template's + // rowSharePrices[row.acNo]) reads via row.acNo, so this must match or every lookup misses. + this.rowSharePrices[this.selectedRow.acNo] = Number(acc.currentMarketPrice) || 0; + this.rowLinkedSavingsAcNo[this.selectedRow.acNo] = acc.savingsAccountNumber ?? ''; + this.syncShareLinkedAmounts(); + } return true; }; @@ -1913,18 +2179,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { documents: this.http.get(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))), notes: this.http.get(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))), linkedAcc: this.http.get(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))), - si: this.http - .get(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`) - .pipe(catchError(() => of({ pageItems: [] }))), - todayTxns: cfg.txnSearchSupported - ? this.http - .get( - `${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20` - ) - .pipe(catchError(() => of({ pageItems: [] }))) + si: cfg.siFetchUnsupported + ? of({ pageItems: [] }) : this.http - .get(`${cfg.txnBase}/${accountId}/transactions?limit=50`) - .pipe(catchError(() => of({ pageItems: [] }))) + .get(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`) + .pipe(catchError(() => of({ pageItems: [] }))), + todayTxns: cfg.txnFetchUnsupported + ? of({ pageItems: [] }) + : cfg.txnSearchSupported + ? this.http + .get( + `${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20` + ) + .pipe(catchError(() => of({ pageItems: [] }))) + : this.http + .get(`${cfg.txnBase}/${accountId}/transactions?limit=50`) + .pipe(catchError(() => of({ pageItems: [] }))) }); if (clientIdHint) { @@ -2089,6 +2359,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // ── Balance getters ──────────────────────────────────────────────────────── get currentBalance(): number { + if (this.selectedRow?.acTyp === 'SHR') { + const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0); + const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0); + return shares * unitPrice; + } return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0; } @@ -2502,6 +2777,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } + // Share redemption via linked-account transfer: DR=Share account, CR=its linked savings + // account. A Share account can never be debited by a plain ledger transfer (no such Fineract + // command exists) β€” so this exact one-DR/one-CR shape is routed to submitShareRedemptionTransfer() + // instead, which submits a governed Share Withdrawal Request through the same maker-checker + // table as the dedicated screen; approval both redeems the shares and settles the linked account. + const shareDrRows = drRows.filter((r) => r.acTyp === 'SHR'); + if (shareDrRows.length > 0) { + if (drRows.length !== 1 || crRows.length !== 1) { + this.showAlert('error', 'Unsupported Share Transfer', [ + 'A Share redemption transfer supports exactly one DR row (the Share account) and one CR row (its linked savings account).' + ]); + return; + } + this.submitShareRedemptionTransfer(shareDrRows[0], crRows[0]); + return; + } + // Bank transfer: only savings (SD) or current (CA/CB) accounts allowed const allowedTypes = new Set([ 'SD', @@ -3004,6 +3296,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { const accountId = this.accountDetails.id; const cfg = this.accountTypeConfig(row.acTyp); + // Share accounts must never go through this generic Deposit/Withdrawal Slip flow β€” both + // buildDepositPayload() and buildWithdrawalPayload() hardcode accountType: 'SAVINGS' and send + // savingsAccountId: this.accountDetails.id, which for a Share account is the SHARE account's + // own numeric id, not a real savings account id. Approving that request would call + // savingsAccountWithdrawal/Deposit against whatever savings account happens to share that same + // numeric id β€” a completely unrelated account. Share purchase/redemption must go through the + // dedicated Buy Shares / Share Withdrawal Request screens, which carry the correct governance + // (resolution number, meeting date, minimum-tenure and linked-loan checks). + if (row.acTyp === 'SHR') { + this.showAlert('error', 'Not Supported for Share Accounts', [ + isDeposit + ? 'Share purchases must be made from the Buy Shares screen on the Share Account itself.' + : 'Share withdrawal (redeem shares) must be submitted from "New Share Withdrawal Request" in the Clerk Dashboard.', + 'This generic Deposit/Withdrawal Slip does not carry the Board Resolution Number or the governance checks required for shares.' + ]); + return; + } + // GL withdrawal β†’ direct journal entry (no maker-checker) // GL deposit β†’ falls through to maker-checker block below if (row.acTyp === 'GL' && !isDeposit) { diff --git a/src/app/clerk/clerk-shared.scss b/src/app/clerk/clerk-shared.scss new file mode 100644 index 000000000..0695f3600 --- /dev/null +++ b/src/app/clerk/clerk-shared.scss @@ -0,0 +1,77 @@ +/** + * 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/. + */ + +/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens β€” + * self-contained (this branch has no separate "investment" module to borrow them from). */ + +.inv-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +.inv-form-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 11px; + font-weight: 600; + color: #64748b; + + input, + select, + textarea { + height: 36px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + color: #0d1b2a; + font-family: inherit; + background: #fff; + box-sizing: border-box; + } + + textarea { + height: auto; + padding: 8px 10px; + resize: vertical; + } +} + +.inv-form-actions { + display: flex; + gap: 10px; + padding: 12px 16px 16px; +} + +.inv-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: #f1f5f9; + color: #475569; + + &--active { + background: #fef3c7; + color: #92400e; + } + + &--matured { + background: #dcfce7; + color: #166534; + } + + &--written_off { + background: #fee2e2; + color: #991b1b; + } +} diff --git a/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.html b/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.html new file mode 100644 index 000000000..9d57ea8f4 --- /dev/null +++ b/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.html @@ -0,0 +1,73 @@ + + +
+
+
+
CLERK Β· SHARE WITHDRAWAL
+

My Share Requests

+

Share withdrawal requests you have submitted, and their approval status.

+
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + @if (loading) { + + + + } @else if (loaded && requests.length === 0) { + + + + } @else { + @for (r of requests; track r.id) { + + + + + + + + + + } + } + +
Account NoClientSharesResolution No.Meeting DateSubmitted AtStatus
Loading…
No share withdrawal requests found.
+ {{ r.account_no }} + {{ r.client_name }}{{ r.requested_shares }}{{ r.resolution_number }}{{ formatDateTime(r.meeting_date) }}{{ formatDateTime(r.submitted_at) }} + {{ statusLabel(r.status) }} +
+
+
diff --git a/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.ts b/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.ts new file mode 100644 index 000000000..3d19c8b57 --- /dev/null +++ b/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.ts @@ -0,0 +1,112 @@ +/** + * 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 { Router, RouterModule } from '@angular/router'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service'; + +/** Clerk Dashboard "My Share Requests" β€” lists the share withdrawal requests this clerk has + * submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */ +@Component({ + selector: 'mifosx-my-share-withdrawal-requests', + templateUrl: './my-share-withdrawal-requests.component.html', + styleUrls: [ + '../../cashier/cashier-shared.scss', + '../clerk-shared.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule + ] +}) +export class MyShareWithdrawalRequestsComponent implements OnInit { + private service = inject(ClerkShareWithdrawalService); + private router = inject(Router); + + requests: any[] = []; + totalRecords = 0; + loading = false; + loaded = false; + + filterStatus = ''; + + get statusOptions(): { value: string; label: string }[] { + return [ + { value: '', label: 'All' }, + { value: 'PENDING', label: 'Pending' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' } + ]; + } + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.service + .getMyRequests(this.filterStatus || undefined, 0, 50) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) + .subscribe((res: any) => { + this.requests = res?.pageItems ?? []; + this.totalRecords = Number(res?.totalFilteredRecords ?? 0); + this.loading = false; + this.loaded = true; + }); + } + + onFilterChange(): void { + this.load(); + } + + newRequest(): void { + this.router.navigate(['/clerk/share-withdrawal-request']); + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'inv-badge--active', + APPROVED: 'inv-badge--matured', + REJECTED: 'inv-badge--written_off' + }; + return `inv-badge ${map[status] ?? ''}`; + } + + statusLabel(status: string): string { + const map: Record = { + PENDING: 'Pending Approval', + APPROVED: 'Approved', + REJECTED: 'Rejected' + }; + return map[status] ?? status; + } + + formatDateTime(val: any): string { + if (!val) return 'β€”'; + try { + const d = new Date(val); + return d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return String(val); + } + } +} diff --git a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html new file mode 100644 index 000000000..58810efaf --- /dev/null +++ b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html @@ -0,0 +1,393 @@ + + +
+
+
+
{{ isReview ? 'CHECKER Β· APPROVAL' : 'CLERK Β· SHARE WITHDRAWAL' }}
+

{{ pageTitle }}

+

{{ pageSubtitle }}

+
+
+ +
+
+ +
+ +
+ @if (!isReview) { + + } + @if (account) { + + + + + } + @if (loadingAccount) { +
Loading account…
+ } +
+
+ + @if (account) { +
+ +
+ + + + + + + + +
+ + @if (!isReview) { +
+ +
+ } +
+ } + + @if (isReview && reviewRequest?.status === 'PENDING') { +
+ +
+
+ fact_check +
+
+
Run a live backend validation
+
+ Re-checks account status, share balance, linked savings account and GL mapping before you approve. +
+
+ +
+ +
+ + +
+
+ } + + @if (showValidateModal) { +
+
+
+
+ fact_check + Validate Share Withdrawal Request +
+ +
+ +
+

+ Re-checking {{ requestedShares }} share(s) withdrawal from + {{ account?.accountNo }} ({{ account?.clientName }}) against the live account, savings link + and GL configuration β€” not just what was captured at submission time. +

+ + @if (backendChecks && !validating && !validateAllPassed) { +
+ warning +
+ Some checks need review before approving. + Scroll down to see which ones failed and why. +
+
+ } + +
    + @for (step of validateSteps; track step.label; let i = $index) { +
  1. +
    + @if (step.status === 'done') { + check + } @else if (step.status === 'active') { + autorenew + } @else if (step.status === 'failed') { + close + } @else { + {{ i + 1 }} + } +
    +
    +
    {{ step.label }}
    +
    {{ step.detail }}
    +
    +
  2. + } +
+ + @if (validating && validateSteps.length === 0) { +
Connecting to server…
+ } + + @if (backendChecks && !validating && validateAllPassed) { +
+ verified + All checks passed β€” safe to approve. +
+ } +
+ + +
+
+ } + + @if (isReview && reviewRequest?.status === 'APPROVED') { +
+ +
+ @if (reviewRequest?.settlement_amount) { + + + + } @else { +
+ Settlement is processed automatically on approval. Details aren't available when reopening an + already-approved request β€” check the linked savings account's transaction history to confirm. +
+ } +
+
+ } + + @if (isReview && activityLog.length > 0) { +
+ + + + + + + + + + + + @for (entry of activityLog; track entry.timestamp) { + + + + + + + } + +
TimestampActionUserNote
{{ entry.timestamp | date: 'medium' }}{{ entry.action }}{{ entry.user }}{{ entry.note || 'β€”' }}
+
+ } + + @if (showConfirmDialog) { +
+
+

Confirm Share Withdrawal Request

+

Review the details below before submitting for Bank Manager approval.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account No / Client{{ account?.accountNo }} β€” {{ account?.clientName }}
Shares to Withdraw + {{ requestedShares }} @ β‚Ή{{ unitPrice | number: '1.2-2' }} = β‚Ή{{ withdrawalValue | number: '1.2-2' }} +
Remaining Shares After Withdrawal{{ remainingShares }}
Requested Date{{ requestedDate }}
Board Resolution Number{{ resolutionNumber }}
Date of Meeting{{ meetingDate }}
Settlement Payment Type{{ settlementPaymentTypeName }}
Linked Savings Account{{ account?.savingsAccountNumber || 'None β€” settlement will not run automatically' }}
+ + + + + @for (check of verificationChecks; track check.label) { + + + + + } + +
{{ check.label }} + {{ check.pass ? 'Pass β€” ' : 'Review β€” ' }}{{ check.detail }} +
+ +
+ + +
+
+
+ } + + @if (showRejectDialog) { +
+
+

Reject Share Withdrawal Request

+ +
+ + +
+
+
+ } +
diff --git a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss new file mode 100644 index 000000000..619822696 --- /dev/null +++ b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss @@ -0,0 +1,410 @@ +/** + * 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/. + */ + +.swr-modal-backdrop { + position: fixed; + inset: 0; + background: rgb(13 27 42 / 55%); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.swr-modal { + background: #fff; + border-radius: 12px; + padding: 20px; + width: 420px; + max-width: 90vw; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 12px 32px rgb(15 23 42 / 20%); + + h3 { + margin: 0 0 12px; + font-size: 16px; + color: #0d1b2a; + } + + textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-family: inherit; + font-size: 13px; + resize: vertical; + box-sizing: border-box; + } + + &:has(table) { + width: 560px; + } +} + +.swr-check-pass { + color: #1a7f37; + font-weight: 600; +} + +.swr-check-fail { + color: #b42318; + font-weight: 600; +} + +/* ---------- Verification trigger card ---------- */ +.swrv-trigger { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; + flex-wrap: wrap; + + &__icon { + width: 42px; + height: 42px; + border-radius: 12px; + background: linear-gradient(135deg, #3457d5, #6c8cff); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 4px 12px rgb(52 87 213 / 30%); + } + + &__text { + flex: 1; + min-width: 200px; + } + + &__title { + font-size: 13px; + font-weight: 700; + color: #0d1b2a; + } + + &__sub { + font-size: 12px; + color: #64748b; + margin-top: 2px; + line-height: 1.4; + } +} + +/* ---------- Validate pipeline modal ---------- */ +@keyframes swrv-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes swrv-zoom { + from { + opacity: 0; + transform: scale(0.95) translateY(8px); + } + + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes swrv-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.swrv-spin { + animation: swrv-spin 0.9s linear infinite; +} + +.swrv-modal { + position: fixed; + inset: 0; + background: rgb(15 25 45 / 55%); + backdrop-filter: blur(3px); + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: swrv-fade 0.2s ease-out; + + &__card { + background: #fff; + border-radius: 16px; + width: 100%; + max-width: 520px; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 24px 60px rgb(0 0 0 / 25%); + animation: swrv-zoom 0.25s ease-out; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px; + border-bottom: 1px solid #eef1f5; + } + + &__title { + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; + font-weight: 700; + color: #0d1b2a; + } + + &__title-icon { + width: 34px; + height: 34px; + border-radius: 10px; + background: linear-gradient(135deg, #3457d5, #6c8cff); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + &__close { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid #e2e8f0; + background: #fff; + color: #64748b; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.12s; + + &:not(:disabled):hover { + color: #b42318; + border-color: #b42318; + background: #fdecea; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + + &__body { + padding: 20px 22px; + overflow-y: auto; + flex: 1; + } + + &__intro { + font-size: 13px; + color: #475569; + line-height: 1.55; + margin: 0 0 16px; + + strong { + color: #0d1b2a; + font-weight: 700; + } + } + + &__warn { + display: flex; + gap: 10px; + padding: 12px 14px; + background: #fef3e2; + border: 1px solid rgb(217 119 6 / 30%); + border-left: 3px solid #d97706; + border-radius: 8px; + margin-bottom: 16px; + font-size: 12px; + color: #633806; + line-height: 1.5; + + mat-icon { + color: #d97706; + font-size: 18px; + width: 18px; + height: 18px; + flex-shrink: 0; + } + + strong { + display: block; + font-weight: 700; + color: #4d2a05; + } + } + + &__result { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + padding: 12px 14px; + background: #eafaf0; + border: 1px solid rgb(26 127 55 / 25%); + border-radius: 8px; + font-size: 13px; + font-weight: 600; + color: #1a7f37; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } + + &__footer { + padding: 16px 22px; + border-top: 1px solid #eef1f5; + display: flex; + justify-content: flex-end; + gap: 10px; + background: #f8fafc; + } +} + +.swrv-steps { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +.swrv-step { + display: flex; + gap: 14px; + padding: 10px 0; + position: relative; + + &:not(:last-child)::after { + content: ''; + position: absolute; + left: 15px; + top: 38px; + bottom: -4px; + width: 2px; + background: #e2e8f0; + } + + &__indicator { + width: 32px; + height: 32px; + border-radius: 50%; + background: #fff; + border: 2px solid #e2e8f0; + color: #64748b; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + flex-shrink: 0; + position: relative; + z-index: 1; + transition: all 0.25s; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + } + + &__content { + padding-top: 5px; + flex: 1; + min-width: 0; + } + + &__label { + font-size: 13px; + font-weight: 600; + color: #475569; + } + + &__msg { + font-size: 11px; + color: #94a3b8; + margin-top: 2px; + } + + &--active { + .swrv-step__indicator { + background: #3457d5; + border-color: #3457d5; + color: #fff; + box-shadow: 0 0 0 4px rgb(52 87 213 / 15%); + } + + .swrv-step__label { + color: #3457d5; + } + } + + &--done { + .swrv-step__indicator { + background: #1a7f37; + border-color: #1a7f37; + color: #fff; + } + + &::after { + background: #1a7f37; + } + + .swrv-step__label { + color: #0d1b2a; + } + } + + &--failed { + .swrv-step__indicator { + background: #b42318; + border-color: #b42318; + color: #fff; + } + + .swrv-step__label { + color: #b42318; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .swrv-spin { + animation: none; + } + + .swrv-modal, + .swrv-modal__card { + animation: none; + } +} diff --git a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts new file mode 100644 index 000000000..836bfb920 --- /dev/null +++ b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts @@ -0,0 +1,413 @@ +/** + * 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, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { SharesService } from '../../shares/shares.service'; +import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service'; +import { CashierService } from '../../cashier/cashier.service'; +import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component'; + +export type ShareWdMode = 'create' | 'review'; + +interface ActivityEntry { + timestamp: string; + action: string; + user: string; + note?: string; +} + +/** + * Share Withdrawal (redeem shares) request β€” maker screen in the Clerk Dashboard. A clerk finds + * the member's Share Account, enters how many shares to withdraw plus the governing Board + * Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY + * entry point a share withdrawal should go through β€” approval server-side executes the real + * "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend). + */ +@Component({ + selector: 'mifosx-share-withdrawal-request', + templateUrl: './share-withdrawal-request.component.html', + styleUrls: [ + '../../cashier/cashier-shared.scss', + '../clerk-shared.scss', + './share-withdrawal-request.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule, + MatIconModule, + MatSnackBarModule, + SearchSelectComponent + ] +}) +export class ShareWithdrawalRequestComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private sharesService = inject(SharesService); + private wdService = inject(ClerkShareWithdrawalService); + private cashierService = inject(CashierService); + private snackBar = inject(MatSnackBar); + + /** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode + * and requestId come from these inputs rather than the route, and success/close actions emit + * events instead of navigating away. */ + @Input() embedded = false; + @Input() overrideMode: ShareWdMode | null = null; + @Input() overrideRequestId: number | null = null; + @Output() closeRequested = new EventEmitter(); + @Output() actionCompleted = new EventEmitter(); + + mode: ShareWdMode = 'create'; + requestId: number | null = null; + + submitting = false; + loadingAccount = false; + + selectedAccountId = ''; + accountOptions: SearchSelectOption[] = []; + + account: any = null; + + requestedShares: number | null = null; + requestedDate = ''; + resolutionNumber = ''; + meetingDate = ''; + description = ''; + + paymentTypes: any[] = []; + settlementPaymentTypeId: number | null = null; + + activityLog: ActivityEntry[] = []; + reviewRequest: any = null; + showRejectDialog = false; + rejectionReason = ''; + showConfirmDialog = false; + + showValidateModal = false; + validating = false; + validateAllPassed = false; + backendChecks: { label: string; pass: boolean; detail: string }[] | null = null; + validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] = + []; + + get remainingShares(): number { + const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0); + return held - (this.requestedShares || 0); + } + + get unitPrice(): number { + return Number(this.account?.currentMarketPrice ?? 0); + } + + get withdrawalValue(): number { + return (this.requestedShares || 0) * this.unitPrice; + } + + get paymentTypeOptions(): any[] { + return this.paymentTypes.filter((pt: any) => !pt.isCashPayment); + } + + get selectedSettlementPaymentType(): any { + return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId); + } + + get settlementPaymentTypeName(): string { + if (this.isReview) { + return this.reviewRequest?.settlement_payment_type_name || 'β€”'; + } + return this.selectedSettlementPaymentType?.name || 'β€”'; + } + + get isFormValid(): boolean { + if (!this.account) return false; + if (!this.requestedShares || this.requestedShares <= 0) return false; + const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0); + if (this.requestedShares > held) return false; + if (!this.requestedDate) return false; + if (!this.resolutionNumber.trim()) return false; + if (!this.meetingDate) return false; + if (!this.settlementPaymentTypeId) return false; + return true; + } + + /** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */ + get verificationChecks(): { label: string; pass: boolean; detail: string }[] { + const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0); + const shares = this.requestedShares || 0; + return [ + { + label: 'Board Resolution Number provided', + pass: !!this.resolutionNumber?.trim(), + detail: this.resolutionNumber?.trim() || 'Missing' + }, + { label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' }, + { + label: 'Shares requested within shares held', + pass: shares > 0 && shares <= held, + detail: `${shares} of ${held} shares held` + }, + { + label: 'Linked savings account confirmed', + pass: !!this.account?.savingsAccountNumber, + detail: this.account?.savingsAccountNumber || 'No linked savings account β€” settlement cannot run automatically' + }, + { + label: 'Settlement Payment Type selected (non-cash)', + pass: this.settlementPaymentTypeName !== 'β€”', + detail: this.settlementPaymentTypeName + } + ]; + } + + get isReview(): boolean { + return this.mode === 'review'; + } + + get pageTitle(): string { + return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request'; + } + + get pageSubtitle(): string { + return this.isReview + ? "Approve or reject the clerk's share withdrawal request." + : 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.'; + } + + ngOnInit(): void { + this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create'; + const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id']; + this.requestId = idParam ? Number(idParam) : null; + + const today = new Date(); + this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + this.meetingDate = this.requestedDate; + + this.sharesService + .getShareAccountSummaries() + .pipe(catchError(() => of([]))) + .subscribe((rows: any[]) => { + this.accountOptions = (rows || []) + .filter((r) => (r.status?.value || '').toLowerCase() === 'active') + .map((r) => ({ + value: String(r.id), + label: `${r.accountNo} β€” ${r.clientName} (${r.totalApprovedShares} shares)`, + searchText: `${r.accountNo} ${r.clientName}` + })); + }); + + this.cashierService + .getPaymentTypes() + .pipe(catchError(() => of([]))) + .subscribe((types: any[]) => (this.paymentTypes = types ?? [])); + + if (this.requestId && this.mode === 'review') { + this.loadReviewRequest(this.requestId); + } + } + + onAccountSelected(accountId: string): void { + this.selectedAccountId = accountId; + if (!accountId) { + this.account = null; + return; + } + this.loadingAccount = true; + this.sharesService + .getSharesAccountData(accountId, false) + .pipe(catchError(() => of(null))) + .subscribe((acc: any) => { + this.account = acc; + this.loadingAccount = false; + }); + } + + private loadReviewRequest(id: number): void { + this.wdService.getById(id).subscribe({ + next: (req: any) => { + this.reviewRequest = req; + this.requestedShares = Number(req.requested_shares); + this.requestedDate = req.requested_date + ? String(req.requested_date).split('T')[0].split(' ')[0] + : this.requestedDate; + this.resolutionNumber = req.resolution_number ?? ''; + this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate; + this.description = req.description ?? ''; + + if (req.activity_log) { + try { + this.activityLog = JSON.parse(req.activity_log); + } catch { + this.activityLog = []; + } + } + + this.sharesService + .getSharesAccountData(String(req.share_account_id), false) + .pipe(catchError(() => of(null))) + .subscribe((acc: any) => (this.account = acc)); + }, + error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 }) + }); + } + + /** "Review before Save" per requirement β€” opens the confirm dialog instead of submitting directly. */ + openConfirmDialog(): void { + if (!this.isFormValid || this.submitting) { + return; + } + this.showConfirmDialog = true; + } + + cancelConfirmDialog(): void { + this.showConfirmDialog = false; + } + + confirmSubmit(): void { + this.showConfirmDialog = false; + this.submitting = true; + const payload = { + shareAccountId: Number(this.selectedAccountId), + accountNo: this.account?.accountNo, + clientId: this.account?.clientId, + clientName: this.account?.clientName, + requestedShares: this.requestedShares, + requestedDate: this.requestedDate, + resolutionNumber: this.resolutionNumber.trim(), + meetingDate: this.meetingDate, + description: this.description, + settlementPaymentTypeId: this.settlementPaymentTypeId + }; + this.wdService.submit(payload).subscribe({ + next: () => { + this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 }); + if (this.embedded) { + this.actionCompleted.emit(); + } else { + this.router.navigate(['/clerk/dashboard']); + } + }, + error: (err) => { + this.submitting = false; + this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', { + duration: 6000 + }); + } + }); + } + + openValidateModal(): void { + if (!this.reviewRequest) return; + this.showValidateModal = true; + this.runBackendValidation(); + } + + closeValidateModal(): void { + if (this.validating) return; + this.showValidateModal = false; + } + + /** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather + * than relying on data already loaded in the browser β€” catches drift since submission. Results + * are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */ + runBackendValidation(): void { + if (!this.reviewRequest || this.validating) return; + this.validating = true; + this.backendChecks = null; + this.validateSteps = []; + this.wdService.validate(this.reviewRequest.id).subscribe({ + next: (res: any) => { + const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[]; + this.backendChecks = checks; + this.validateAllPassed = !!res?.allPassed; + this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const })); + this.revealValidateSteps(0); + }, + error: (err) => { + this.validating = false; + this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', { + duration: 6000 + }); + } + }); + } + + private revealValidateSteps(index: number): void { + if (index >= this.validateSteps.length) { + this.validating = false; + return; + } + this.validateSteps[index].status = 'active'; + setTimeout(() => { + this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed'; + this.revealValidateSteps(index + 1); + }, 320); + } + + approveReview(): void { + if (!this.reviewRequest) return; + this.wdService.approve(this.reviewRequest.id).subscribe({ + next: (res: any) => { + const message = + res?.settlementAmount != null + ? `Share withdrawal approved. β‚Ή${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.` + : (res?.settlementWarning ?? 'Share withdrawal approved and processed.'); + this.snackBar.open(message, 'Close', { duration: 6000 }); + if (this.embedded) { + this.actionCompleted.emit(); + } else { + this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']); + } + }, + error: (err) => + this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', { + duration: 6000 + }) + }); + } + + openRejectDialog(): void { + this.showRejectDialog = true; + this.rejectionReason = ''; + } + + confirmRejectReview(): void { + if (!this.reviewRequest || !this.rejectionReason.trim()) return; + this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({ + next: () => { + this.showRejectDialog = false; + this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 }); + if (this.embedded) { + this.actionCompleted.emit(); + } else { + this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']); + } + }, + error: (err) => + this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', { + duration: 6000 + }) + }); + } + + goBack(): void { + if (this.embedded) { + this.closeRequested.emit(); + return; + } + this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']); + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 46f3a0f4d..06e76311b 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -99,6 +99,19 @@ My Disbursements + + + + + + My Share Requests + @if (clerkAccountLoaded) { } + +
+
+

Map Payment Types to Specific Funds

+ +
+
+ @for (row of paymentChannelToFundSourceMappings.controls; track $index; let i = $index) { +
+
+
+ + Payment Type + + @for (paymentType of paymentTypeOptions; track paymentType.id) { + {{ paymentType.name }} + } + + +
+ +
+ + Fund Source + + @for (glAccount of fundSourceAccountOptions; track glAccount.id) { + + {{ '(' + glAccount.glCode + ') ' + glAccount.name }} + + } + + +
+
+ +
+ } +
+
}
diff --git a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts index 88e88a51d..986472df6 100644 --- a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts +++ b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts @@ -50,6 +50,11 @@ type OtherGlMappingValue = { glAccountId: number | null; }; +type PaymentFundSourceValue = { + paymentTypeId: number | null; + fundSourceAccountId: number | null; +}; + @Component({ selector: 'mifosx-saving-product-accounting-step', templateUrl: './saving-product-accounting-step.component.html', @@ -78,6 +83,11 @@ export class SavingProductAccountingStepComponent implements OnInit { expenseAccountOptions: GLAccount[] = []; incomeAccountOptions: GLAccount[] = []; assetAccountOptions: GLAccount[] = []; + paymentTypeOptions: any[] = []; + /** A payment-type Fund Source override can legitimately be a suspense/clearing Liability account + * (e.g. an existing "Sundry" account bridging to another module) rather than only a cash-like + * Asset account, so this field accepts either. */ + fundSourceAccountOptions: GLAccount[] = []; accountingSectionConfigs: AccountingSectionConfig[] = []; constructor() { @@ -98,6 +108,8 @@ export class SavingProductAccountingStepComponent implements OnInit { this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || []; this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || []; this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || []; + this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || []; + this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions); this.buildAccountingSectionConfigs(); this.savingProductAccountingForm.patchValue({ accountingRule: accountingRuleId @@ -105,6 +117,7 @@ export class SavingProductAccountingStepComponent implements OnInit { this.syncAccountingControls(accountingRuleId); this.patchAccountingValues(mappings); + this.patchPaymentFundSourceMappings(this.savingProductsTemplate?.paymentChannelToFundSourceMappings || []); this.setOverdraftControl(); this.setDormancyControl(); } @@ -115,10 +128,48 @@ export class SavingProductAccountingStepComponent implements OnInit { assetsOtherGlMappings: this.formBuilder.array([]), liabilitiesOtherGlMappings: this.formBuilder.array([]), expensesOtherGlMappings: this.formBuilder.array([]), - incomeOtherGlMappings: this.formBuilder.array([]) + incomeOtherGlMappings: this.formBuilder.array([]), + paymentChannelToFundSourceMappings: this.formBuilder.array([]) }); } + get paymentChannelToFundSourceMappings(): UntypedFormArray { + return this.savingProductAccountingForm.get('paymentChannelToFundSourceMappings') as UntypedFormArray; + } + + private createPaymentFundSourceGroup(value: Partial = {}) { + return this.formBuilder.group({ + paymentTypeId: [ + value.paymentTypeId ?? this.paymentTypeOptions[0]?.id ?? null, + Validators.required + ], + fundSourceAccountId: [ + value.fundSourceAccountId ?? this.fundSourceAccountOptions[0]?.id ?? null, + Validators.required + ] + }); + } + + private patchPaymentFundSourceMappings(mappings: any[]): void { + this.paymentChannelToFundSourceMappings.clear(); + (mappings || []).forEach((mapping) => { + this.paymentChannelToFundSourceMappings.push( + this.createPaymentFundSourceGroup({ + paymentTypeId: mapping.paymentType?.id, + fundSourceAccountId: mapping.fundSourceAccount?.id + }) + ); + }); + } + + addPaymentFundSourceMapping(): void { + this.paymentChannelToFundSourceMappings.push(this.createPaymentFundSourceGroup()); + } + + removePaymentFundSourceMapping(index: number): void { + this.paymentChannelToFundSourceMappings.removeAt(index); + } + private setConditionalControls() { this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => { this.syncAccountingControls(accountingRule); diff --git a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts index cd20da7bc..14693833d 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts +++ b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts @@ -74,6 +74,12 @@ export class ShareProductAccountingStepComponent implements OnInit { liabilityAccountData: GLAccount[] = []; incomeAccountData: GLAccount[] = []; equityAccountData: GLAccount[] = []; + /** "Share Reference" is where a redemption's credit side lands pending settlement β€” some banks + * model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default), + * others as a payable-like Liability suspense account (e.g. an existing "Sundry" account + * already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts + * either instead of Asset-only. */ + referenceAccountData: GLAccount[] = []; constructor() { this.createShareProductAccountingForm(); @@ -85,6 +91,7 @@ export class ShareProductAccountingStepComponent implements OnInit { this.liabilityAccountData = opts.liabilityAccountOptions || []; this.incomeAccountData = opts.incomeAccountOptions || []; this.equityAccountData = opts.equityAccountOptions || []; + this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData); this.buildAccountingSectionConfigs(); @@ -135,10 +142,10 @@ export class ShareProductAccountingStepComponent implements OnInit { rows: [ { key: 'shareReferenceId', - label: 'Share Reference (Cash Vault)', + label: 'Share Reference (Cash Vault or Suspense)', inputLabel: 'Share reference', controlName: 'shareReferenceId', - options: this.assetAccountData, + options: this.referenceAccountData, required: true } ], diff --git a/src/app/shared/search-select/search-select.component.ts b/src/app/shared/search-select/search-select.component.ts new file mode 100644 index 000000000..2a7e27558 --- /dev/null +++ b/src/app/shared/search-select/search-select.component.ts @@ -0,0 +1,236 @@ +/** + * 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, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; + +export interface SearchSelectOption { + value: string; + label: string; + /** What typed text is matched against β€” defaults to `label` when omitted, but a caller can + * pass a richer string (e.g. account no + customer name + branch) so a search hits fields + * that aren't all visible in the collapsed label. */ + searchText?: string; +} + +/** + * Drop-in replacement for a plain ` + @if (open) { +
    + @for (o of filteredOptions; track o.value) { +
  • + {{ o.label }} +
  • + } @empty { +
  • No matches
  • + } +
+ } +
+ `, + styles: [ + ` + /* Angular view encapsulation stops the host page's shared styles from reaching inside this + * component's own template, so its look is re-declared here rather than inherited. */ + :host { + display: block; + width: 100%; + } + .inv-search-select { + position: relative; + width: 100%; + } + .inv-search-select__input { + width: 100%; + height: 36px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + color: #0d1b2a; + font-family: inherit; + background: #fff; + box-sizing: border-box; + } + /* position: fixed, anchored via JS to the input's own screen coords (see + * updateListPosition()) rather than absolute inside this component β€” most of this app's + * cards use overflow: hidden for rounded corners, which would otherwise clip a dropdown + * that extends past the card's edge. */ + .inv-search-select__list { + position: fixed; + z-index: 2000; + max-height: 260px; + overflow-y: auto; + margin: 0; + padding: 4px; + list-style: none; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18); + } + .inv-search-select__option { + padding: 8px 10px; + border-radius: 6px; + font-size: 13px; + color: #0d1b2a; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .inv-search-select__option:hover, + .inv-search-select__option--active { + background: #eef2ff; + } + .inv-search-select__empty { + padding: 8px 10px; + font-size: 12px; + color: #94a3b8; + } + ` + ] +}) +export class SearchSelectComponent implements ControlValueAccessor { + @Input() options: SearchSelectOption[] = []; + @Input() placeholder = 'Search…'; + + @ViewChild('inputEl') private inputEl?: ElementRef; + + value = ''; + query = ''; + open = false; + disabled = false; + + listTop = 0; + listLeft = 0; + listWidth = 0; + + private onChangeFn: (v: string) => void = () => {}; + onTouched: () => void = () => {}; + + get filteredOptions(): SearchSelectOption[] { + const q = this.query.trim().toLowerCase(); + if (!q) { + return this.options; + } + return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q)); + } + + private labelFor(value: string): string { + return this.options.find((o) => o.value === value)?.label ?? ''; + } + + writeValue(v: string): void { + this.value = v ?? ''; + this.query = this.labelFor(this.value); + } + + registerOnChange(fn: (v: string) => void): void { + this.onChangeFn = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + /** Anchors the fixed-position list to the input's current on-screen box. */ + private updateListPosition(): void { + const el = this.inputEl?.nativeElement; + if (!el) { + return; + } + const rect = el.getBoundingClientRect(); + this.listTop = rect.bottom + 4; + this.listLeft = rect.left; + this.listWidth = rect.width; + } + + @HostListener('window:scroll') + @HostListener('window:resize') + onWindowChange(): void { + if (this.open) { + this.updateListPosition(); + } + } + + onInput(event: Event): void { + this.query = (event.target as HTMLInputElement).value; + this.open = true; + this.updateListPosition(); + } + + onFocus(): void { + this.open = true; + this.updateListPosition(); + } + + onBlur(): void { + this.open = false; + this.onTouched(); + // Discard any uncommitted typed text β€” only a clicked option ever changes `value`. + this.query = this.labelFor(this.value); + } + + selectOption(o: SearchSelectOption): void { + this.value = o.value; + this.query = o.label; + this.open = false; + this.onChangeFn(this.value); + this.onTouched(); + } +} 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 new file mode 100644 index 000000000..80264c9de --- /dev/null +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html @@ -0,0 +1,210 @@ + + +
+ +
+
+ + {{ 'labels.inputs.From Date' | translate }} + + + + + + + {{ 'labels.inputs.To Date' | translate }} + + + + + + + + @if (isFiltered) { + + } + + + + +
+
+ + +
+ +
+
+
+ account_balance +
+
+
LincPay Co-operative Bank Ltd.
+
Share Account Passbook
+
+
+
+
SHARE HOLDING STATEMENT
+
As on: {{ maxDate | dateFormat }}
+
+
+ + + + + +
+
+
Approved Shares
+
{{ totalApprovedShares }}
+
+
+
+
Share Value
+
+ {{ currentMarketPrice | currency: currency?.code : 'symbol-narrow' : '1.2-2' }} +
+
+
+
+
Current Value
+
{{ currentValue | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
+
+
+
+
Total Transactions
+
{{ filteredTransactions.length }}
+
+
+ + + @if (filteredTransactions.length > 0) { + + + + + + + + + + + + + + + + @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 { + β€” + } +
Closing Share Balance{{ totalApprovedShares }}
+ } @else { +
+ inbox +

No transactions found for the selected date range.

+
+ } + + + +
+
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 new file mode 100644 index 000000000..cfe41963e --- /dev/null +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss @@ -0,0 +1,462 @@ +/** + * 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/. + */ + +// ─── Variables ────────────────────────────────────────────────────────────── +$bank-primary: #1a3a6b; // deep navy +$bank-accent: #c8a951; // gold trim +$debit-color: #c0392b; +$credit-color: #1a7a3f; +$balance-color: #1a3a6b; +$border-color: #d0d7e3; +$row-alt: #f4f7fb; + +// ─── Container ────────────────────────────────────────────────────────────── +.passbook-container { + padding: 16px; + background: #eef1f7; + min-height: 100%; +} + +// ─── Filter Bar ───────────────────────────────────────────────────────────── +.passbook-filter-bar { + margin-bottom: 16px; + background: #fff; + border-radius: 8px; + padding: 12px 16px; + border: 1px solid $border-color; +} + +.passbook-filter-form { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.passbook-date-field { + width: 180px; +} + +.passbook-filter-spacer { + flex: 1; +} + +// ─── Statement Wrapper ─────────────────────────────────────────────────────── +.statement-wrapper { + background: #fff; + border: 1px solid $border-color; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 12px rgb(0 0 0 / 8%); + font-family: 'Segoe UI', Arial, sans-serif; +} + +// ─── Bank Header ───────────────────────────────────────────────────────────── +.bank-header { + background: $bank-primary; + padding: 18px 24px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 4px solid $bank-accent; +} + +.bank-logo-area { + display: flex; + align-items: center; + gap: 14px; +} + +.bank-logo-circle { + width: 52px; + height: 52px; + border-radius: 50%; + background: $bank-accent; + display: flex; + align-items: center; + justify-content: center; + + mat-icon { + color: $bank-primary; + font-size: 28px; + width: 28px; + height: 28px; + } +} + +.bank-name-block { + .bank-title { + color: #fff; + font-size: 18px; + font-weight: 700; + letter-spacing: 0.3px; + } + + .bank-subtitle { + color: $bank-accent; + font-size: 11px; + font-weight: 500; + letter-spacing: 1.5px; + text-transform: uppercase; + margin-top: 2px; + } +} + +.statement-label { + text-align: right; + + .stmt-label-text { + color: $bank-accent; + font-size: 13px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + } + + .stmt-period { + color: rgb(255 255 255 / 75%); + font-size: 11px; + margin-top: 4px; + } +} + +// ─── Account Details Band ──────────────────────────────────────────────────── +.account-details-band { + background: #f0f3f9; + border-bottom: 1px solid $border-color; + padding: 14px 24px; +} + +.account-details-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px 24px; +} + +.detail-block { + .detail-label { + font-size: 9px; + font-weight: 700; + color: #7a8aaa; + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 3px; + } + + .detail-value { + font-size: 13px; + color: #222; + + &.bold { + font-weight: 600; + } + + &.mono { + font-family: 'Courier New', monospace; + letter-spacing: 1px; + } + + &.status-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + background: #eee; + color: #555; + + &.active { + background: #d4edda; + color: #155724; + } + } + } +} + +// ─── Balance Summary Strip ─────────────────────────────────────────────────── +.balance-summary-strip { + display: flex; + align-items: stretch; + background: $bank-primary; + padding: 0; +} + +.balance-box { + flex: 1; + padding: 12px 20px; + text-align: center; + + .balance-box-label { + font-size: 10px; + font-weight: 600; + color: rgb(255 255 255 / 65%); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 4px; + } + + .balance-box-value { + font-size: 16px; + font-weight: 700; + color: $bank-accent; + } +} + +.balance-divider { + width: 1px; + background: rgb(255 255 255 / 15%); + margin: 8px 0; +} + +// ─── Transactions Table ─────────────────────────────────────────────────────── +.statement-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + thead tr { + background: #e8edf5; + border-top: 1px solid $border-color; + border-bottom: 2px solid $bank-primary; + } + + thead th { + padding: 9px 10px; + font-size: 11px; + font-weight: 700; + color: $bank-primary; + text-transform: uppercase; + letter-spacing: 0.4px; + white-space: nowrap; + } + + tbody tr { + border-bottom: 1px solid #e8ecf3; + transition: background 0.1s; + + &:nth-child(even) { + background: $row-alt; + } + + &:hover { + background: #dce6f5; + } + } + + tbody td { + padding: 7px 10px; + vertical-align: middle; + color: #333; + } + + tfoot .total-row { + background: #e8edf5; + border-top: 2px solid $bank-primary; + + td { + padding: 9px 10px; + } + } +} + +// ─── Column widths ──────────────────────────────────────────────────────────── +.col-sr { + width: 38px; +} + +.col-date { + width: 100px; + white-space: nowrap; +} + +.col-desc { + min-width: 140px; +} + +.col-ref { + width: 70px; +} + +.col-amount { + width: 100px; +} + +.col-balance { + width: 90px; +} + +// ─── Amount colors ──────────────────────────────────────────────────────────── +.debit-amount { + color: $debit-color; + font-weight: 500; +} + +.credit-amount { + color: $credit-color; + font-weight: 500; +} + +.balance-amount { + color: $balance-color; + font-weight: 600; + font-family: 'Courier New', monospace; +} + +.total-label { + font-size: 12px; + font-weight: 700; + color: $bank-primary; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.closing-balance { + font-size: 14px; + font-weight: 700; + color: $bank-primary; + font-family: 'Courier New', monospace; +} + +// ─── Badges & misc ──────────────────────────────────────────────────────────── +.txn-desc { + font-weight: 500; +} + +.text-right { + text-align: right; +} + +.text-center { + text-align: center; +} + +.text-muted { + color: #999; +} + +.mono { + font-family: 'Courier New', monospace; + font-size: 11px; +} + +// ─── No Data ────────────────────────────────────────────────────────────────── +.no-data-block { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px; + color: #aaa; + + mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + margin-bottom: 12px; + } + + p { + font-size: 14px; + margin: 0; + } +} + +// ─── Statement Footer ───────────────────────────────────────────────────────── +.statement-footer { + background: #f0f3f9; + border-top: 1px solid $border-color; + padding: 12px 24px; + text-align: center; + + .footer-note { + font-size: 10px; + color: #888; + margin-bottom: 4px; + font-style: italic; + } + + .footer-meta { + font-size: 10px; + color: #aaa; + letter-spacing: 0.3px; + } +} + +// ─── Responsive ─────────────────────────────────────────────────────────────── +@media (width <= 768px) { + .account-details-grid { + grid-template-columns: 1fr 1fr; + } + + .bank-header { + flex-direction: column; + gap: 12px; + align-items: flex-start; + } + + .statement-label { + text-align: left; + } + + .balance-summary-strip { + flex-direction: column; + } + + .balance-divider { + width: auto; + height: 1px; + margin: 0 20px; + } + + .passbook-filter-form { + flex-direction: column; + align-items: stretch; + } + + .passbook-date-field { + width: 100%; + } + + .passbook-filter-spacer { + display: none; + } +} + +@media (width <= 480px) { + .account-details-grid { + grid-template-columns: 1fr; + } +} + +// ─── Print ──────────────────────────────────────────────────────────────────── +@media print { + .no-print { + display: none !important; + } + + .passbook-container { + background: #fff; + padding: 0; + } + + .statement-wrapper { + border: none; + box-shadow: none; + border-radius: 0; + } + + .bank-header { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .balance-summary-strip { + -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 new file mode 100644 index 000000000..4b882635f --- /dev/null +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts @@ -0,0 +1,182 @@ +/** + * 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/. + */ + +/** Angular Imports */ +import { Component, OnInit, ElementRef, ViewChild, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { UntypedFormBuilder, Validators } from '@angular/forms'; +import { CurrencyPipe } from '@angular/common'; +import { MatIcon } from '@angular/material/icon'; +import { DateFormatPipe } from '../../../pipes/date-format.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Share Account Passbook β€” a printable, chronological (oldest-first) ledger of every + * Purchase/Redeem entry, mirroring PassbookTabComponent (Savings) but for share holdings: + * shares purchased/redeemed instead of amount deposited/withdrawn, plus the Board Resolution + * Number / Date of Meeting on redeem rows. + */ +@Component({ + selector: 'mifosx-shares-passbook-tab', + templateUrl: './passbook-tab.component.html', + styleUrls: ['./passbook-tab.component.scss'], + standalone: true, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIcon, + DateFormatPipe, + CurrencyPipe + ] +}) +export class SharesPassbookTabComponent implements OnInit { + private route = inject(ActivatedRoute); + private formBuilder = inject(UntypedFormBuilder); + + @ViewChild('passbookContent') passbookContent: ElementRef; + + /** Account data */ + accountNo = ''; + clientName = ''; + productName = ''; + currency: any = null; + status = ''; + activatedOnDate: any = null; + resolutionNumber = ''; + savingsAccountNumber = ''; + totalApprovedShares = 0; + currentMarketPrice = 0; + + /** Transactions */ + allTransactions: any[] = []; + filteredTransactions: any[] = []; + + /** Date range form */ + minDate = new Date(2000, 0, 1); + maxDate = new Date(); + dateRangeForm: any; + isFiltered = false; + + get currentValue(): number { + return this.totalApprovedShares * this.currentMarketPrice; + } + + ngOnInit(): void { + this.route.parent.data.subscribe((data: { sharesAccountData: any }) => { + const account = data.sharesAccountData; + this.accountNo = account.accountNo || ''; + this.clientName = account.clientName || ''; + this.productName = account.productName || ''; + this.currency = account.currency || null; + this.status = account.status?.value || ''; + this.activatedOnDate = account.timeline?.activatedDate || null; + this.resolutionNumber = account.resolutionNumber || ''; + this.savingsAccountNumber = account.savingsAccountNumber || ''; + this.totalApprovedShares = account.summary?.totalApprovedShares || 0; + this.currentMarketPrice = account.currentMarketPrice || 0; + + const transactions = (account.purchasedShares || []).filter( + (transaction: any) => transaction.type.value !== 'Charge Payment' + ); + this.allTransactions = this.recalculateRunningBalances(this.sortAscending(transactions)); + this.filteredTransactions = [...this.allTransactions]; + }); + + this.dateRangeForm = this.formBuilder.group({ + fromDate: [ + '', + Validators.required + ], + toDate: [ + '', + Validators.required + ] + }); + } + + isRedeem(transaction: any): boolean { + return transaction.type?.value === 'Redeem'; + } + + filterTransactions(): void { + const fromDate = this.dateRangeForm.value.fromDate; + const toDate = this.dateRangeForm.value.toDate; + + if (!fromDate || !toDate) return; + + const from = new Date(fromDate).getTime(); + const to = new Date(toDate).getTime(); + + this.filteredTransactions = this.allTransactions.filter((txn: any) => { + const txnDate = this.getTransactionDateAsTime(txn.purchasedDate); + return txnDate >= from && txnDate <= to; + }); + this.isFiltered = true; + } + + clearFilter(): void { + this.dateRangeForm.reset(); + this.filteredTransactions = [...this.allTransactions]; + this.isFiltered = false; + } + + private getTransactionDateAsTime(date: any): number { + if (Array.isArray(date) && date.length >= 3) { + return new Date(date[0], date[1] - 1, date[2]).getTime(); + } + return new Date(date).getTime(); + } + + printPassbook(): void { + const content = this.passbookContent?.nativeElement?.innerHTML; + if (!content) return; + + const printWindow = window.open('', 'SharePassbook', 'height=700,width=900'); + printWindow.document.write(` + + + Share Account Passbook - ${this.accountNo} + + + + ${content} + + + `); + printWindow.document.close(); + printWindow.focus(); + printWindow.print(); + printWindow.close(); + } + + private recalculateRunningBalances(transactions: any[]): any[] { + let balance = 0; + return transactions.map((txn) => { + const shares = Number(txn.numberOfShares) || 0; + balance += this.isRedeem(txn) ? -shares : shares; + return { ...txn, runningBalance: balance }; + }); + } + + private sortAscending(transactions: any[]): any[] { + return [...transactions].sort((a, b) => { + const dateA = this.getTransactionDateAsTime(a.purchasedDate); + const dateB = this.getTransactionDateAsTime(b.purchasedDate); + if (dateA !== dateB) return dateA - dateB; + return (a.id as number) - (b.id as number); + }); + } +} 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 9761aeeef..4402b9160 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 @@ -145,6 +145,16 @@ > {{ 'labels.inputs.Transactions' | translate }}
+ + Passbook + My Share Requests), + // never a direct one-step action from this view. ]; break; case 'Submitted and pending approval': 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 f3941f52b..769d76d05 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 @@ -54,6 +54,25 @@ + + Share Balance + {{ transaction.runningBalance }} + + + + Resolution No. / Meeting Date + + @if (transaction.type.value === 'Redeem') { + {{ transaction.resolutionNumber || 'β€”' }} + @if (transaction.meetingDate) { + ({{ transaction.meetingDate | dateFormat }}) + } + } @else { + β€” + } + + + diff --git a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts index c28640324..46a7e3db0 100644 --- a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts +++ b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts @@ -56,14 +56,18 @@ export class TransactionsTabComponent implements OnInit { transactionsData: any; /** Data source for transactions table. */ dataSource: MatTableDataSource; - /** Columns to be displayed in transactions table. */ + /** Columns to be displayed in transactions table β€” a share account's passbook: every + * purchase/redeem entry plus the running share balance and, for a withdrawal, the Board + * Resolution Number / Date of Meeting that authorised it. */ displayedColumns: string[] = [ 'transactionDate', 'transactionType', 'totalShares', 'purchasedOrRedeemedPrice', 'chargeAmount', - 'amountRecievedOrReturned' + 'amountRecievedOrReturned', + 'runningBalance', + 'resolutionMeeting' ]; /** @@ -73,9 +77,19 @@ export class TransactionsTabComponent implements OnInit { constructor() { this.route.parent.data.subscribe((data: { sharesAccountData: any }) => { this.shareAccountData = data.sharesAccountData; - this.transactionsData = this.shareAccountData.purchasedShares.filter( - (transaction: any) => transaction.type.value !== 'Charge Payment' - ); + this.transactionsData = this.shareAccountData.purchasedShares + .filter((transaction: any) => transaction.type.value !== 'Charge Payment') + .slice() + .sort((a: any, b: any) => new Date(a.purchasedDate).getTime() - new Date(b.purchasedDate).getTime()); + + let runningBalance = 0; + this.transactionsData.forEach((transaction: any) => { + const shares = Number(transaction.numberOfShares) || 0; + runningBalance += transaction.type.value === 'Redeem' ? -shares : shares; + transaction.runningBalance = runningBalance; + }); + // Passbook reads newest-first. + this.transactionsData.reverse(); }); } diff --git a/src/app/shares/shares-routing.module.ts b/src/app/shares/shares-routing.module.ts index 4c5193aef..d12979e83 100644 --- a/src/app/shares/shares-routing.module.ts +++ b/src/app/shares/shares-routing.module.ts @@ -26,6 +26,7 @@ import { ShareAccountActionsResolver } from './common-resolvers/share-account-ac import { SharesAccountTemplateResolver } from './common-resolvers/shares-account-template.resolver'; 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'; /** Shares Routes */ const routes: Routes = [ @@ -75,6 +76,11 @@ const routes: Routes = [ component: TransactionsTabComponent, data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false } }, + { + path: 'passbook', + component: SharesPassbookTabComponent, + data: { title: 'Shares Account Passbook', breadcrumb: 'Passbook', routeParamBreadcrumb: false } + }, { path: 'charges', component: ChargesTabComponent, diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.html new file mode 100644 index 000000000..347691d98 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.html @@ -0,0 +1,86 @@ + + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + @if (loading) { + + + + } @else if (loaded && requests.length === 0) { + + + + } @else { + @for (r of requests; track r.id) { + + + + + + + + + + + + + } + } + +
Account NoClientSharesResolution No.Meeting DateClerkManagerSubmitted AtStatus
Loading…
No {{ filterStatus | lowercase }} share withdrawal requests.
+ {{ r.account_no }} + {{ r.client_name }}{{ r.requested_shares }}{{ r.resolution_number }}{{ formatDateTime(r.meeting_date) }}{{ r.clerk_name }}{{ r.manager_name || r.manager_username || 'β€”' }}{{ formatDateTime(r.submitted_at) }} + {{ statusLabel(r.status) }} + + +
+
+ +@if (showReviewModal && reviewRequestId) { +
+
+ +
+
+} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss new file mode 100644 index 000000000..93afc827a --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss @@ -0,0 +1,60 @@ +/** + * 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/. + */ + +@keyframes swr-list-popup-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes swr-list-popup-zoom { + from { + opacity: 0; + transform: scale(0.97) translateY(10px); + } + + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.swr-list-popup-backdrop { + position: fixed; + inset: 0; + background: rgb(15 25 45 / 55%); + backdrop-filter: blur(3px); + z-index: 1100; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 32px 20px; + overflow-y: auto; + animation: swr-list-popup-fade 0.2s ease-out; +} + +.swr-list-popup-panel { + background: #f7f9fc; + border-radius: 16px; + width: 100%; + max-width: 1100px; + box-shadow: 0 24px 60px rgb(0 0 0 / 25%); + animation: swr-list-popup-zoom 0.25s ease-out; + padding: 4px; +} + +@media (prefers-reduced-motion: reduce) { + .swr-list-popup-backdrop, + .swr-list-popup-panel { + animation: none; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.ts new file mode 100644 index 000000000..3c21d21ad --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.ts @@ -0,0 +1,128 @@ +/** + * 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 { ClerkShareWithdrawalService } from '../../../cashier/clerk-share-withdrawal.service'; +import { ShareWithdrawalRequestComponent } from '../../../clerk/share-withdrawal-request/share-withdrawal-request.component'; + +/** Approval Center β€” Share Withdrawal Requests tab. A Bank Manager (checker) reviews, approves + * or rejects share withdrawal requests submitted from the Clerk Dashboard. Mirrors + * WithdrawalRequestsComponent (cash withdrawal), adapted for shares. Clicking "Review" opens the + * review screen in a popup on top of this list, rather than navigating away, so approving/rejecting + * drops the manager straight back into the (now refreshed) list. */ +@Component({ + selector: 'mifosx-share-withdrawal-requests', + templateUrl: './share-withdrawal-requests.component.html', + styleUrls: [ + '../../../cashier/cashier-shared.scss', + '../../../clerk/clerk-shared.scss', + './share-withdrawal-requests.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + ShareWithdrawalRequestComponent + ] +}) +export class ShareWithdrawalRequestsComponent implements OnInit { + private service = inject(ClerkShareWithdrawalService); + + requests: any[] = []; + totalRecords = 0; + loading = false; + loaded = false; + + filterStatus = 'PENDING'; + + showReviewModal = false; + reviewRequestId: number | null = null; + + get statusOptions(): { value: string; label: string }[] { + return [ + { value: 'PENDING', label: 'Pending' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' } + ]; + } + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.service + .getPendingRequests(this.filterStatus, 0, 50) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) + .subscribe((res: any) => { + this.requests = res?.pageItems ?? []; + this.totalRecords = Number(res?.totalFilteredRecords ?? 0); + this.loading = false; + this.loaded = true; + }); + } + + onFilterChange(): void { + this.load(); + } + + review(req: any): void { + this.reviewRequestId = req.id; + this.showReviewModal = true; + } + + closeReviewModal(): void { + this.showReviewModal = false; + this.reviewRequestId = null; + } + + onReviewActionCompleted(): void { + this.closeReviewModal(); + this.load(); + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'inv-badge--active', + APPROVED: 'inv-badge--matured', + REJECTED: 'inv-badge--written_off' + }; + return `inv-badge ${map[status] ?? ''}`; + } + + statusLabel(status: string): string { + const map: Record = { + PENDING: 'Pending', + APPROVED: 'Approved', + REJECTED: 'Rejected' + }; + return map[status] ?? status; + } + + formatDateTime(val: any): string { + if (!val) return 'β€”'; + try { + const d = new Date(val); + return d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return String(val); + } + } +} 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 c5a2bfd23..af2a9cd43 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 @@ -178,6 +178,18 @@ Withdrawal Requests
+ + + Share Withdrawal Requests + + import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then( + (m) => m.ShareWithdrawalRequestComponent + ), + data: { title: 'Review Share Withdrawal Request', breadcrumb: 'Review Share Withdrawal', mode: 'review' } + } + ] + }, { path: 'checker-inbox-and-tasks/disbursement-requests', children: [ diff --git a/src/environments/.env.ts b/src/environments/.env.ts index f94dce0fc..e81c75f5f 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260731', - 'hash': 'ca85bae4' + 'version': '260805', + 'hash': '8c7d8287' }, 'allow_switching_backend_instance': true };