diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 0200e5559..a2f5cf910 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -424,45 +424,16 @@ export class CashierService { return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data); } - /** - * Whether this share account can be settled from the member's linked savings, and for how much. - * - * Reports a shortfall before the clerk raises anything, so the member can be told at the counter - * rather than after a request has been submitted and has to be rejected. - * @returns {Observable} `{ eligible, reason, shareAmount, chargeAmount, totalRequired, - * savingsAccountNo, savingsBalance }`. - */ - getShareSavingsDebitEligibility(shareAccountId: number): Observable { - return this.http.get(`/sharesavingsdebit/eligibility/${shareAccountId}`); + /** Every additional-share tranche awaiting approval, across every share account. */ + getShareAdditionRequests(): Observable { + return this.http.get('/shareadditionrequests'); } /** - * Records a request to settle a share application by debiting the member's savings. Nothing moves - * until a manager approves it — unlike the cash route, where the cashier's receipt is already the - * second pair of eyes. + * Full detail for one pending tranche — the list row plus route-specific proof of funding: the + * voucher(s) behind a cash tranche, or the savings account already debited for a savings tranche. */ - submitShareSavingsDebit(data: any): Observable { - return this.http.post('/sharesavingsdebit', data); - } - - /** Savings-debit requests, PENDING by default. */ - getShareSavingsDebitRequests(status?: string, shareAccountId?: number): Observable { - const params: string[] = []; - if (status) { - params.push(`status=${status}`); - } - if (shareAccountId) { - params.push(`shareAccountId=${shareAccountId}`); - } - return this.http.get(`/sharesavingsdebit${params.length ? '?' + params.join('&') : ''}`); - } - - /** Debits the member's savings and issues the shares. Manager only, and not the submitting clerk. */ - approveShareSavingsDebit(id: number): Observable { - return this.http.post(`/sharesavingsdebit/${id}/approve`, {}); - } - - rejectShareSavingsDebit(id: number, rejectionReason?: string): Observable { - return this.http.post(`/sharesavingsdebit/${id}/reject`, { rejectionReason }); + getShareAdditionRequest(transactionId: number): Observable { + return this.http.get(`/shareadditionrequests/${transactionId}`); } } diff --git a/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.html b/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.html deleted file mode 100644 index 857d24cf7..000000000 --- a/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.html +++ /dev/null @@ -1,75 +0,0 @@ - - -

Debit from Savings

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

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

- @if (errorMsg) { -
{{ errorMsg }}
- } - } - } -
- - - - - diff --git a/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.scss b/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.scss deleted file mode 100644 index 9c1c102b2..000000000 --- a/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.scss +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -.dfs-content { - min-width: 420px; -} - -.dfs-loading { - display: flex; - align-items: center; - gap: 12px; - padding: 16px 0; - color: #475569; - font-size: 13.5px; -} - -/* What the member is being charged, laid out as label/amount pairs so the total reads down the - column the way the posting splits it. */ -.dfs-breakdown { - margin: 8px 0 16px; - padding: 12px 14px; - border: 1px solid #e2e8f0; - border-radius: 6px; - background: #f8fafc; -} - -.dfs-row { - display: flex; - justify-content: space-between; - gap: 16px; - padding: 3px 0; - font-size: 13.5px; - color: #334155; - - > span:last-child { - font-variant-numeric: tabular-nums; - white-space: nowrap; - } - - &--total { - margin-top: 6px; - padding-top: 8px; - border-top: 1px solid #cbd5e1; - font-weight: 600; - color: #0f172a; - } - - &--detail { - margin-top: 6px; - font-size: 12px; - color: #64748b; - } -} - -.dfs-field { - display: block; - margin-bottom: 10px; - - > span { - display: block; - margin-bottom: 4px; - font-size: 12.5px; - color: #475569; - } - - input { - width: 100%; - padding: 8px 10px; - border: 1px solid #cbd5e1; - border-radius: 4px; - font-size: 13.5px; - - &:focus { - outline: none; - border-color: #2563eb; - } - } -} - -.dfs-hint { - margin: 0 0 4px; - font-size: 12px; - color: #64748b; -} - -.dfs-error { - margin: 8px 0 0; - padding: 9px 12px; - border-radius: 6px; - background: #fdecea; - color: #b42318; - font-size: 13px; -} diff --git a/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.ts b/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.ts deleted file mode 100644 index d01c54e67..000000000 --- a/src/app/shares/shares-account-view/debit-from-savings-dialog/debit-from-savings-dialog.component.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Component, OnInit, Inject, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { catchError } from 'rxjs/operators'; -import { of } from 'rxjs'; -import { CashierService } from 'app/cashier/cashier.service'; - -/** - * Settles a share application out of the member's own savings account. - * - * The cash route (Apply Collection) starts at the counter, with money the cashier has already - * received sitting in Share Sundry against a receipt. A member who banks with the society has no - * reason to withdraw cash and hand it straight back, so this debits their savings directly for the - * share value plus any entry fee. - * - * Nothing moves when this dialog is submitted: it records a request for a manager to approve. The - * cash route needs no checker because the cashier's receipt is already a second pair of eyes; here - * nobody has counted anything, so the approval is the first moment the member's balance changes. - */ -@Component({ - selector: 'mifosx-debit-from-savings-dialog', - templateUrl: './debit-from-savings-dialog.component.html', - styleUrls: ['./debit-from-savings-dialog.component.scss'], - standalone: true, - imports: [ - CommonModule, - FormsModule, - MatDialogModule, - MatButtonModule, - MatIconModule, - MatProgressSpinnerModule - ] -}) -export class DebitFromSavingsDialogComponent implements OnInit { - private cashierService = inject(CashierService); - - loading = true; - submitting = false; - errorMsg = ''; - - /** - * What the settlement would cost and whether the savings account can cover it. `eligible` false - * carries a `reason` naming the blocker — no linked savings, already settled, short balance. - */ - eligibility: { - eligible: boolean; - reason: string | null; - shareAmount: number; - chargeAmount: number; - totalRequired: number; - savingsAccountNo: string | null; - savingsBalance: number | null; - } | null = null; - - note = ''; - - public dialogRef = inject(MatDialogRef); - public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA); - - ngOnInit(): void { - this.cashierService - .getShareSavingsDebitEligibility(this.data.shareAccountId) - .pipe(catchError(() => of(null))) - .subscribe((res: any) => { - this.loading = false; - if (!res) { - this.errorMsg = 'Could not check whether this account can be settled from savings.'; - return; - } - this.eligibility = { - eligible: !!res.eligible, - reason: res.reason ?? null, - shareAmount: Number(res.shareAmount) || 0, - chargeAmount: Number(res.chargeAmount) || 0, - totalRequired: Number(res.totalRequired) || 0, - savingsAccountNo: res.savingsAccountNo ?? null, - savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance) - }; - }); - } - - submit(): void { - if (!this.eligibility?.eligible || this.submitting) { - return; - } - this.submitting = true; - this.errorMsg = ''; - this.cashierService - .submitShareSavingsDebit({ - shareAccountId: this.data.shareAccountId, - note: this.note?.trim() || undefined - }) - .subscribe({ - next: (res: any) => { - this.submitting = false; - this.dialogRef.close(res); - }, - error: (err: any) => { - this.submitting = false; - this.errorMsg = - err?.error?.defaultUserMessage || err?.error?.message || 'Could not submit the savings-debit request.'; - } - }); - } - - cancel(): void { - this.dialogRef.close(); - } -} diff --git a/src/app/shares/shares-account-view/shares-account-view.component.ts b/src/app/shares/shares-account-view/shares-account-view.component.ts index ccbb346a1..e8318cff0 100644 --- a/src/app/shares/shares-account-view/shares-account-view.component.ts +++ b/src/app/shares/shares-account-view/shares-account-view.component.ts @@ -15,7 +15,6 @@ import { MatDialog } from '@angular/material/dialog'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { ShareCertificateDialogComponent } from './share-certificate-dialog/share-certificate-dialog.component'; import { ApplyCollectionDialogComponent } from './apply-collection-dialog/apply-collection-dialog.component'; -import { DebitFromSavingsDialogComponent } from './debit-from-savings-dialog/debit-from-savings-dialog.component'; /** Custom Services */ import { SharesService } from '../shares.service'; @@ -144,20 +143,6 @@ export class SharesAccountViewComponent implements OnInit { }); } if (status === 'Active') { - const purchasedShares: any[] = this.sharesAccountData.purchasedShares; - let sharesPendingForApproval = false; - purchasedShares.forEach((share: any) => { - if ( - share.status.code === 'purchasedSharesStatusType.applied' && - share.type.code === 'purchasedSharesType.purchased' - ) { - sharesPendingForApproval = true; - } - }); - if (!sharesPendingForApproval) { - this.buttonConfig.removeButton('Approve Additional Shares'); - this.buttonConfig.removeButton('Reject Additional Shares'); - } // An Active account can carry "approved" shares nobody has actually paid for yet -- account // creation, approval and activation post no GL of their own; only applying a Share Advance // Collection does (see isSettled). A certificate for shares still unsettled would certify @@ -194,9 +179,6 @@ export class SharesAccountViewComponent implements OnInit { case 'Apply Collection': this.applyCollection(); break; - case 'Debit from Savings': - this.debitFromSavings(); - break; case 'Share Certificate': this.viewShareCertificate(); break; @@ -247,29 +229,12 @@ export class SharesAccountViewComponent implements OnInit { * Neither the account's status nor the purchase transaction's status can stand in for this: an * account goes Approved then Activated before anything is settled, and the purchase reads * Approved from the moment the account is approved. Only `collectionApplied` marks the point at - * which the money was actually taken — by cash collection or by debiting savings. + * which the money was actually taken by cash collection. */ get isSettled(): boolean { return !!this.sharesAccountData?.collectionApplied; } - /** - * Settles this account out of the member's linked savings instead of cash at the counter. - * - * Only records a request — a manager's approval is what debits the member and issues the shares — - * so unlike Apply Collection there is nothing to refresh here until that approval happens. - */ - private debitFromSavings() { - this.dialog.open(DebitFromSavingsDialogComponent, { - width: '560px', - data: { - shareAccountId: this.sharesAccountData.id, - clientId: this.sharesAccountData.clientId, - accountNo: this.sharesAccountData.accountNo - } - }); - } - /** * Deletes Shares Account. */ diff --git a/src/app/shares/shares-account-view/shares-buttons.config.ts b/src/app/shares/shares-account-view/shares-buttons.config.ts index 539336cbb..18814c8d3 100644 --- a/src/app/shares/shares-account-view/shares-buttons.config.ts +++ b/src/app/shares/shares-account-view/shares-buttons.config.ts @@ -43,32 +43,17 @@ export class SharesButtonsConfiguration { icon: 'hand-holding-usd', taskPermissionName: 'CREATE_JOURNALENTRY' }, - { - // The same settlement for a member who banks here: debits their linked savings for the - // share value plus entry fee instead of taking cash at the counter. Raises a request - // rather than posting, because no cashier has counted anything to check it against. - name: 'Debit from Savings', - icon: 'money-check-alt', - taskPermissionName: 'CREATE_JOURNALENTRY' - }, { name: 'Apply Additional Shares', icon: 'arrow-right', taskPermissionName: 'APPLYADDITIONAL_SHAREACCOUNT' - }, - { - name: 'Approve Additional Shares', - icon: 'arrow-right', - taskPermissionName: 'APPROVEADDITIONAL_SHAREACCOUNT' - }, - { - name: 'Reject Additional Shares', - icon: 'arrow-left', - taskPermissionName: 'REJECTADDITIONAL_SHAREACCOUNT' } - // 'Redeem Shares' is intentionally not offered here — a share withdrawal must go - // through the Clerk Dashboard maker-checker flow (Clerk Dashboard > My Share Requests), - // never a direct one-step action from this view. + // 'Approve Additional Shares' / 'Reject Additional Shares' are intentionally not offered + // here — every pending tranche across every account is reviewed and actioned from the + // Approval Center's Share Addition Requests queue instead, so a checker never has to know + // which account to open. 'Redeem Shares' is likewise not offered here — a share withdrawal + // must go through the Clerk Dashboard maker-checker flow (Clerk Dashboard > My Share + // Requests), never a direct one-step action from this view. ]; break; case 'Submitted and pending approval': @@ -147,7 +132,11 @@ export class SharesButtonsConfiguration { removeButton(name: string) { const buttonNames = this.buttonsArray.map((entry) => entry.name); const index = buttonNames.indexOf(name); - this.buttonsArray.splice(index, 1); + // -1 (not found) fed straight to splice would delete the LAST button instead of doing nothing — + // splice treats a negative index as "from the end", not "no match". + if (index > -1) { + this.buttonsArray.splice(index, 1); + } } removeOption(name: string) { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html new file mode 100644 index 000000000..d7f2c7136 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.html @@ -0,0 +1,126 @@ + + +

Additional Share Request

+ + + @if (loading) { +
+ + Loading request… +
+ } @else if (!detail) { +
{{ errorMsg }}
+ } @else { +
+
+ Client + {{ detail.clientName }} +
+
+ Share account + {{ detail.accountNo }} — {{ detail.productName }} +
+
+ Office + {{ detail.officeName }} +
+
+ Requested shares + {{ detail.totalShares }} @ {{ detail.unitPrice | number: '1.2-2' }} +
+
+ Amount + {{ detail.amount | number: '1.2-2' }} +
+ @if (detail.chargeAmount > 0) { +
+ Entry fee + {{ detail.chargeAmount | number: '1.2-2' }} +
+ } +
+ Board resolution + {{ detail.resolutionNumber || '—' }} +
+
+ Meeting date + {{ detail.meetingDate || '—' }} +
+
+ Requested on + {{ detail.transactionDate }} +
+
+ +
+
How it was funded
+ + @if (detail.fundingSource === 'CASH') { + @if (voucherCount > 0) { + + + + + + + + + + + @for (v of detail.vouchers; track v.id) { + + + + + + + } + +
Voucher / ReceiptDateCollected byAmount
{{ v.voucherNo || v.receiptNo }}{{ v.collectionDate }}{{ v.collectedByName || '—' }}{{ v.amount | number: '1.2-2' }}
+ } @else { +
Cash-funded, but no receipt is linked to this request.
+ } + } @else if (detail.fundingSource === 'SAVINGS') { + @if (detail.savingsAccount) { +
+ Savings account + {{ detail.savingsAccount.accountNo }} +
+
+ Current balance + {{ detail.savingsAccount.balance | number: '1.2-2' }} +
+
+ This amount was already debited from savings when the tranche was submitted. +
+ } @else { +
Savings-funded, but no linked savings account could be found.
+ } + } @else { +
No funding route was recorded for this request.
+ } +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + } +
+ + + + @if (detail) { + + + } + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss new file mode 100644 index 000000000..4e8a7aff8 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.scss @@ -0,0 +1,123 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$primary: #1a3a6b; +$border: #d8e0ec; +$muted: #6b7a90; +$danger: #b3261e; + +.sard-content { + min-width: 480px; +} + +.sard-loading { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 0; + font-size: 13px; + color: $muted; +} + +.sard-grid { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 16px; +} + +.sard-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 5px 0; + font-size: 13px; + border-bottom: 1px solid #f0f3f8; + + > span:first-child { + color: $muted; + } + + > span:last-child { + color: $primary; + font-weight: 500; + text-align: right; + } +} + +.sard-row--total { + > span:last-child { + font-size: 15px; + font-weight: 600; + } +} + +.sard-funding { + border: 1px solid $border; + border-radius: 6px; + padding: 12px; + background: #fbfcfe; +} + +.sard-funding-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: $muted; + margin-bottom: 8px; +} + +.sard-funding-empty, +.sard-funding-hint { + font-size: 12px; + color: $muted; +} + +.sard-voucher-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + color: $muted; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 6px 8px; + text-align: left; + } + + td { + padding: 7px 8px; + border-top: 1px solid #eceff4; + } +} + +.mono { + font-family: 'Courier New', monospace; +} + +.ta-left { + text-align: left; +} + +.ta-right { + text-align: right; +} + +.amount-cell { + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +.sard-error { + color: $danger; + font-size: 13px; + margin-top: 12px; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.component.ts new file mode 100644 index 000000000..05a5267e8 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-request-detail-dialog/share-addition-request-detail-dialog.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 { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { CashierService } from 'app/cashier/cashier.service'; +import { SharesService } from 'app/shares/shares.service'; +import { SettingsService } from 'app/settings/settings.service'; + +/** + * Full detail for one additional-share request, opened from the Approval Center list. + * + * Everything a checker needs to decide without leaving the queue: the board resolution, the tranche + * value, and — the piece a bare list row cannot show — the actual proof of funding. A cash tranche + * shows every receipt behind it (a member paying in instalments has more than one), a savings + * tranche shows the account already debited and its balance, so the approval is never a guess about + * where the money came from. + */ +@Component({ + selector: 'mifosx-share-addition-request-detail-dialog', + templateUrl: './share-addition-request-detail-dialog.component.html', + styleUrls: ['./share-addition-request-detail-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule + ] +}) +export class ShareAdditionRequestDetailDialogComponent implements OnInit { + private cashierService = inject(CashierService); + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + + loading = true; + errorMsg = ''; + actioning = false; + detail: any = null; + + public dialogRef = inject(MatDialogRef); + public data = inject<{ transactionId: number }>(MAT_DIALOG_DATA); + + ngOnInit(): void { + this.cashierService + .getShareAdditionRequest(this.data.transactionId) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, 'Could not load this request.'); + this.loading = false; + return of(null); + }) + ) + .subscribe((res: any) => { + if (res) { + this.detail = res; + } + this.loading = false; + }); + } + + /** How many receipts fund this tranche — the lead one plus any further instalments. */ + get voucherCount(): number { + return this.detail?.vouchers?.length || 0; + } + + approve(): void { + this.act('approveadditionalshares'); + } + + reject(): void { + this.act('rejectadditionalshares'); + } + + private act(command: string): void { + if (this.actioning || !this.detail) { + return; + } + this.actioning = true; + this.errorMsg = ''; + const data = { + requestedShares: [{ id: this.detail.transactionId }], + dateFormat: this.settingsService.dateFormat, + locale: this.settingsService.language.code + }; + this.sharesService + .executeSharesAccountCommand(this.detail.shareAccountId, command, data) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, 'The request could not be actioned.'); + this.actioning = false; + return of(null); + }) + ) + .subscribe((res: any) => { + if (res) { + this.dialogRef.close(true); + } else { + this.actioning = false; + } + }); + } + + cancel(): void { + this.dialogRef.close(false); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-requests.component.html new file mode 100644 index 000000000..ecd6fa5e5 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-requests.component.html @@ -0,0 +1,83 @@ + + +
+

+ Additional-share tranches waiting for approval, across every share account. Click a row for the full request — the + board resolution, how it was funded, and the voucher or savings account behind it. +

+ + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
+ } + + @if (loading) { +
+ + Loading pending requests… +
+ } @else if (requests.length === 0) { +
+ info_outline + No additional-share requests are waiting for approval. +
+ } @else { +
+ + + + + + + + + + + + + + + @for (req of requests; track req.transactionId) { + + + + + + + + + + + } + +
ClientAccountSharesAmountFundingResolutionDateActions
{{ req.clientName }}{{ req.accountNo }}{{ req.totalShares }}{{ req.amount | number: '1.2-2' }}{{ fundingLabel(req) }}{{ req.resolutionNumber || '—' }}{{ req.transactionDate }} + + +
+
+ } +
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-requests.component.scss new file mode 100644 index 000000000..45d8bc39a --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-requests.component.scss @@ -0,0 +1,117 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$primary: #1a3a6b; +$border: #d8e0ec; +$muted: #6b7a90; +$danger: #b3261e; + +.sar-container { + padding: 8px 0; +} + +.sar-intro { + font-size: 13px; + color: $muted; + margin: 0 0 14px; +} + +.sar-loading, +.sar-empty { + display: flex; + align-items: center; + gap: 10px; + padding: 16px; + font-size: 13px; + color: $muted; + background: #f4f7fb; + border: 1px solid $border; + border-radius: 6px; +} + +.sar-error { + display: flex; + align-items: center; + gap: 8px; + background: #fdecea; + color: $danger; + border: 1px solid #f5c6c2; + border-radius: 6px; + padding: 10px 12px; + font-size: 13px; + margin-bottom: 12px; +} + +.sar-table-wrap { + overflow-x: auto; +} + +.sar-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + border: 1px solid $border; + border-radius: 6px; + + th { + background: #eef2f8; + color: $primary; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 9px 12px; + white-space: nowrap; + } + + td { + padding: 10px 12px; + border-bottom: 1px solid #eceff4; + color: #1f2937; + white-space: nowrap; + } + + tbody tr { + cursor: pointer; + + &:hover { + background: #f4f7fb; + } + } + + tbody tr:last-child td { + border-bottom: none; + } +} + +.mono { + font-family: 'Courier New', monospace; +} + +.ta-left { + text-align: left; +} + +.ta-right { + text-align: right; +} + +.ta-center { + text-align: center; +} + +.amount-cell { + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +.sar-actions { + display: flex; + gap: 8px; + justify-content: center; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-requests.component.ts new file mode 100644 index 000000000..9aea1ba13 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-requests.component.ts @@ -0,0 +1,165 @@ +/** + * 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 { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatDialog } from '@angular/material/dialog'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { CashierService } from 'app/cashier/cashier.service'; +import { SharesService } from 'app/shares/shares.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { ShareAdditionRequestDetailDialogComponent } from './share-addition-request-detail-dialog/share-addition-request-detail-dialog.component'; + +/** + * Approval Center — Share Addition Requests tab. + * + * Every additional-share tranche across every member, waiting on the same + * {@code approveadditionalshares}/{@code rejectadditionalshares} commands that used to be reachable only from a + * single account's own page. A checker working through pending items no longer has to know which account to open — + * every request queued across the whole membership is one list here, and clicking a row opens the full detail (the + * board resolution, how it was funded, and — for cash — which voucher, or — for savings — which account was already + * debited) before deciding. + */ +@Component({ + selector: 'mifosx-share-addition-requests', + templateUrl: './share-addition-requests.component.html', + styleUrls: ['./share-addition-requests.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule + ] +}) +export class ShareAdditionRequestsComponent implements OnInit { + private cashierService = inject(CashierService); + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + private dialog = inject(MatDialog); + + loading = true; + errorMsg = ''; + requests: any[] = []; + actioningId: number | null = null; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.cashierService + .getShareAdditionRequests() + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, 'Could not load pending additional-share requests.'); + return of([]); + }) + ) + .subscribe((res: any) => { + this.requests = Array.isArray(res) ? res : []; + this.loading = false; + }); + } + + /** How many receipts fund a cash tranche — the lead one plus any further instalments. */ + voucherCount(req: any): number { + if (!req?.advanceCollectionId) { + return 0; + } + const extra = (req.additionalCollectionIds || '') + .split(',') + .map((part: string) => part.trim()) + .filter((part: string) => part.length > 0); + return 1 + extra.length; + } + + fundingLabel(req: any): string { + switch (req?.fundingSource) { + case 'CASH': { + const count = this.voucherCount(req); + return count > 1 ? `Cash (${count} vouchers)` : 'Cash'; + } + case 'SAVINGS': + return 'Savings'; + default: + return '—'; + } + } + + /** + * Opens the full-detail popup for one request. Approving or rejecting from inside it refreshes + * this list on close, the same way the account-page tables refresh after acting on a row. + */ + viewDetail(req: any): void { + const dialogRef = this.dialog.open(ShareAdditionRequestDetailDialogComponent, { + width: '640px', + data: { transactionId: req.transactionId } + }); + dialogRef.afterClosed().subscribe((actioned: boolean) => { + if (actioned) { + this.load(); + } + }); + } + + /** Approves directly from the list row, for a request the checker does not need the detail popup for. */ + approve(req: any): void { + if (this.actioningId) { + return; + } + this.actioningId = req.transactionId; + this.runCommand(req, 'approveadditionalshares'); + } + + reject(req: any): void { + if (this.actioningId) { + return; + } + this.actioningId = req.transactionId; + this.runCommand(req, 'rejectadditionalshares'); + } + + private runCommand(req: any, command: string): void { + const data = { + requestedShares: [{ id: req.transactionId }], + dateFormat: this.settingsService.dateFormat, + locale: this.settingsService.language.code + }; + this.sharesService + .executeSharesAccountCommand(req.shareAccountId, command, data) + .pipe( + catchError((err) => { + this.errorMsg = this.readError(err, 'The request could not be actioned.'); + this.actioningId = null; + return of(null); + }) + ) + .subscribe((res: any) => { + this.actioningId = null; + if (res) { + this.load(); + } + }); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.html deleted file mode 100644 index 94d00bc26..000000000 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.html +++ /dev/null @@ -1,109 +0,0 @@ - - -
- -
- -
- - - - - - - - - - - - - - - @if (loading) { - - - - } @else if (loaded && rows.length === 0) { - - - - } @else { - @for (row of rows; track row.id) { - - - - - - - - - - - @if (isShort(row)) { - - - - } - @if (actionError[row.id]) { - - - - } - @if (expandedRejectId === row.id) { - - - - } - } - } - -
MemberShare AccountSavings AccountSharesEntry FeeTo DebitRequested By
Loading…
No savings-debit requests are waiting for approval.
{{ row.client_name }}{{ row.share_account_no }} - {{ row.savings_account_no }} - -
- Balance {{ row.savings_balance | number: '1.2-2' }} -
-
{{ row.share_amount | number: '1.2-2' }}{{ row.charge_amount | number: '1.2-2' }} - {{ row.total_amount | number: '1.2-2' }} - - {{ row.clerk_name }} -
{{ row.submitted_at | slice: 0 : 10 }}
-
- - -
- Savings holds {{ row.savings_balance | number: '1.2-2' }} but - {{ row.total_amount | number: '1.2-2' }} is required. Take a deposit first, or reject this and settle by - cash collection. -
{{ actionError[row.id] }}
-
- - -
-
-
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.scss deleted file mode 100644 index d4cd29ac7..000000000 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.scss +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -/* A savings account that no longer covers the request, and any error the server returned. Both - read as blocking conditions on the row, so they share the same treatment. */ -.ssd-warn { - color: #b42318; -} - -.ssd-warn-row { - padding: 8px 12px; - background: #fdecea; - color: #b42318; - font-size: 12.5px; -} - -.ssd-sub { - font-size: 12px; - color: #64748b; -} - -.ssd-actions { - display: flex; - gap: 8px; - justify-content: flex-end; - white-space: nowrap; -} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.ts deleted file mode 100644 index bbe485811..000000000 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { catchError } from 'rxjs/operators'; -import { of } from 'rxjs'; - -import { CashierService } from '../../../cashier/cashier.service'; - -/** - * Approval Center tab — share applications a clerk has asked to settle out of the member's own - * savings, awaiting a manager. - * - * Approving is the moment the member's balance actually changes: it withdraws the share value plus - * entry fee from their savings and issues the shares. Nothing has moved before that, which is why - * this step exists at all — the cash route needs no checker because the cashier's receipt is - * already a second pair of eyes, whereas here nobody has counted anything. - * - * A manager cannot approve a request they raised themselves; the server rejects that, and the - * message is surfaced on the row rather than swallowed. - */ -@Component({ - selector: 'mifosx-share-savings-debit-requests', - templateUrl: './share-savings-debit-requests.component.html', - styleUrls: [ - '../../../cashier/cashier-shared.scss', - '../../../clerk/clerk-shared.scss', - './share-savings-debit-requests.component.scss' - ], - standalone: true, - imports: [ - CommonModule, - FormsModule - ] -}) -export class ShareSavingsDebitRequestsComponent implements OnInit { - private cashierService = inject(CashierService); - - rows: any[] = []; - loading = false; - loaded = false; - - actioningId: number | null = null; - actionError: Record = {}; - - /** Which row has its reject box open, and what reason has been typed into it. */ - expandedRejectId: number | null = null; - rejectReason: Record = {}; - - ngOnInit(): void { - this.load(); - } - - load(): void { - this.loading = true; - this.cashierService - .getShareSavingsDebitRequests('PENDING') - .pipe(catchError(() => of([]))) - .subscribe((res: any) => { - this.rows = res ?? []; - this.loading = false; - this.loaded = true; - }); - } - - approve(row: any): void { - this.actioningId = row.id; - delete this.actionError[row.id]; - this.cashierService.approveShareSavingsDebit(row.id).subscribe({ - next: () => { - this.actioningId = null; - this.load(); - }, - error: (err: any) => { - this.actioningId = null; - this.actionError[row.id] = - err?.error?.defaultUserMessage || err?.error?.message || 'Could not approve this request.'; - } - }); - } - - toggleReject(row: any): void { - this.expandedRejectId = this.expandedRejectId === row.id ? null : row.id; - } - - reject(row: any): void { - this.actioningId = row.id; - delete this.actionError[row.id]; - this.cashierService.rejectShareSavingsDebit(row.id, this.rejectReason[row.id]?.trim()).subscribe({ - next: () => { - this.actioningId = null; - this.expandedRejectId = null; - this.load(); - }, - error: (err: any) => { - this.actioningId = null; - this.actionError[row.id] = - err?.error?.defaultUserMessage || err?.error?.message || 'Could not reject this request.'; - } - }); - } - - /** - * Whether the savings account still covers what the request asks for. - * - * The balance can move between a clerk raising the request and a manager reaching it, and the - * server refuses a short account — showing it on the row means the manager sees why before - * clicking rather than after. - */ - isShort(row: any): boolean { - return Number(row.savings_balance ?? 0) < Number(row.total_amount ?? 0); - } -} diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 8e5a2f0af..fdd248f94 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 @@ -166,12 +166,12 @@ - Share Savings Debit + Share Addition Requests