feat(tasks,shares): fix Share Account Edit Details routing/auto-select, popup compare dialog, redesign Approval Center filters
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

- Register the missing edit-details route so the Share Account "Edit Details"
  maker-checker screen (already built) is actually reachable, and remove the
  stale hardcoded disable on its menu entry.
- Fix Terms step race: Default Savings Account, Total Shares and Application
  Date now display correctly in edit mode instead of racing an async
  product-template fetch or reusing pending-application-only fields.
- Show a client's linked share account number on the Savings Account General
  tab.
- Replace the inline diff panel on Pending Share Account Edits with a popup
  compare dialog (mirrors the savings equivalent), showing only fields that
  actually changed.
- Redesign Pending Share/Savings Account Edits, Share Withdrawal Requests,
  Dividend Requests and Share Addition Requests with a consistent header,
  search box and (where the data supports it) date-range filters, matching
  the existing Withdrawal Requests screen.
This commit is contained in:
2026-09-01 07:43:07 +05:30
parent 7a2cb25538
commit 9739ff0758
27 changed files with 2932 additions and 507 deletions
@@ -28,10 +28,18 @@ export class ClerkShareWithdrawalService {
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
}
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
getPendingRequests(
status = 'PENDING',
offset = 0,
limit = 20,
fromDate?: string,
toDate?: string,
search?: string
): Observable<any> {
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);
if (search) params = params.set('search', search);
return this.http.get('/clerk/share-withdrawal-requests', { params });
}
+9 -1
View File
@@ -32,10 +32,18 @@ export class ClerkWithdrawalService {
return this.http.get('/clerk/withdrawal-requests/my', { params });
}
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
getPendingRequests(
status = 'PENDING',
offset = 0,
limit = 20,
fromDate?: string,
toDate?: string,
search?: string
): Observable<any> {
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);
if (search) params = params.set('search', search);
return this.http.get('/clerk/withdrawal-requests', { params });
}
@@ -35,6 +35,12 @@
{{ savingsAccountData.clientName || savingsAccountData.groupName || '—' }}
</span>
</div>
@if (savingsAccountData.shareLinkingEnabled) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Linked Share Account' | translate }}</span>
<span class="nx-row__value nx-mono">{{ savingsAccountData.shareAccountNo || 'Unassigned' }}</span>
</div>
}
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Activated On' | translate }}</span>
@@ -78,9 +78,15 @@ export class SharesAccountTermsStepComponent implements OnChanges, OnInit {
currencyCode: this.sharesAccountProductTemplate.currency.code,
decimal: this.sharesAccountProductTemplate.currency.decimalPlaces,
currencyMultiple: this.sharesAccountProductTemplate.currency.inMultiplesOf,
unitPrice: this.sharesAccountProductTemplate.currentMarketPrice,
savingsAccountId: ''
unitPrice: this.sharesAccountProductTemplate.currentMarketPrice
});
// A product change clears any previously-selected savings account, since it may no longer be
// valid for the new product's currency - but only once, and never after the account's own
// already-linked savings account has been restored (below, and in ngOnInit for approvalEditMode) -
// otherwise this reset races the async product-template fetch and wipes that restored value.
if (!this.isSavingsPatched) {
this.sharesAccountTermsForm.get('savingsAccountId').patchValue('');
}
this.setOptions();
if (this.sharesAccountTemplate) {
if (!this.isSavingsPatched && this.sharesAccountTemplate.savingsAccountId) {
@@ -94,20 +100,34 @@ export class SharesAccountTermsStepComponent implements OnChanges, OnInit {
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
if (this.sharesAccountTemplate) {
// In approvalEditMode the account is already active: there is nothing "pending approval" and
// purchasedShares[0] is a transaction row, not the application date - use the account's real
// approved share count and its original submitted date instead (both fields are disabled below
// and never sent in the edit payload, so this is display-only).
this.sharesAccountTermsForm.patchValue({
requestedShares: this.sharesAccountTemplate.summary.totalPendingForApprovalShares,
requestedShares: this.approvalEditMode
? this.sharesAccountTemplate.summary.totalApprovedShares
: this.sharesAccountTemplate.summary.totalPendingForApprovalShares,
minimumActivePeriod: this.sharesAccountTemplate.minimumActivePeriod,
minimumActivePeriodFrequencyType:
this.sharesAccountTemplate.minimumActivePeriod && this.sharesAccountTemplate.minimumActivePeriodTypeEnum.id,
lockinPeriodFrequency: this.sharesAccountTemplate.lockinPeriod,
lockinPeriodFrequencyType:
this.sharesAccountTemplate.lockinPeriod && this.sharesAccountTemplate.lockPeriodTypeEnum.id,
applicationDate:
this.sharesAccountTemplate.purchasedShares[0].purchasedDate &&
new Date(this.sharesAccountTemplate.purchasedShares[0].purchasedDate),
applicationDate: this.approvalEditMode
? this.sharesAccountTemplate.timeline.submittedOnDate &&
new Date(this.sharesAccountTemplate.timeline.submittedOnDate)
: this.sharesAccountTemplate.purchasedShares[0].purchasedDate &&
new Date(this.sharesAccountTemplate.purchasedShares[0].purchasedDate),
allowDividendCalculationForInactiveClients:
this.sharesAccountTemplate.allowDividendCalculationForInactiveClients
});
// Seeded directly here rather than left to the async product-template fetch in ngOnChanges:
// that fetch can resolve out of order and its own reset-to-'' would otherwise win the race.
if (!this.isSavingsPatched && this.sharesAccountTemplate.savingsAccountId) {
this.sharesAccountTermsForm.get('savingsAccountId').patchValue(this.sharesAccountTemplate.savingsAccountId);
this.isSavingsPatched = true;
}
}
if (this.approvalEditMode) {
this.sharesAccountTermsForm.get('requestedShares').disable();
@@ -105,15 +105,7 @@
<mat-menu #More="matMenu">
@for (option of buttonConfig.options; track option) {
<span>
<button
mat-menu-item
*mifosxHasPermission="option.taskPermissionName"
[disabled]="option.name === 'Edit Details'"
[matTooltip]="
option.name === 'Edit Details' ? ('tooltips.Editing details is disabled' | translate) : ''
"
(click)="doAction(option.name)"
>
<button mat-menu-item *mifosxHasPermission="option.taskPermissionName" (click)="doAction(option.name)">
{{ 'labels.menus.' + option.name | translate }}
</button>
</span>
+9
View File
@@ -17,6 +17,7 @@ import { ChargesTabComponent } from './shares-account-view/charges-tab/charges-t
import { DividendsTabComponent } from './shares-account-view/dividends-tab/dividends-tab.component';
import { CreateSharesAccountComponent } from './create-shares-account/create-shares-account.component';
import { EditSharesAccountComponent } from './edit-shares-account/edit-shares-account.component';
import { EditShareAccountDetailsComponent } from './edit-share-account-details/edit-share-account-details.component';
import { SharesAccountActionsComponent } from './shares-account-actions/shares-account-actions.component';
import { ShareHoldersComponent } from './share-holders/share-holders.component';
@@ -108,6 +109,14 @@ const routes: Routes = [
sharesAccountAndTemplate: SharesAccountAndTemplateResolver
}
},
{
path: 'edit-details',
data: { title: 'Edit Share Account Details', breadcrumb: 'Edit Details', routeParamBreadcrumb: false },
component: EditShareAccountDetailsComponent,
resolve: {
sharesAccountAndTemplate: SharesAccountAndTemplateResolver
}
},
{
path: 'actions/:name',
data: { title: 'Shares Account Actions', breadcrumb: 'Actions', routeParamBreadcrumb: 'name' },
@@ -6,63 +6,101 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg-card">
<div class="pos-section-label">Dividend Requests — Declared, Awaiting Approval</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
</label>
<div class="dr-tab">
<!-- ── TITLE BAR ── -->
<div class="dr-action-bar">
<div class="dr-action-bar__left">
<mat-icon class="dr-action-bar__icon">payments</mat-icon>
<div>
<div class="dr-action-bar__title">Dividend Requests</div>
<div class="dr-action-bar__sub">Every Share Product dividend declaration still awaiting approval.</div>
</div>
</div>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Share Product</th>
<th>Dividend Period</th>
<th class="num">Total Amount</th>
<th class="num">Members</th>
<th>Declared On</th>
<th></th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="6" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && rows.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No dividend declarations are waiting for approval.</td>
</tr>
} @else {
<!-- The whole row opens the member-by-member breakdown, so a checker can get to the detail
the same way they would on the Dividends list rather than having to find the View
button. The action buttons stop propagation so Approve still approves directly. -->
@for (row of rows; track row.dividendId) {
<tr class="dr-row" (click)="openView(row)">
<td>{{ row.productName }}</td>
<td>{{ row.dividendPeriodStartDate }} → {{ row.dividendPeriodEndDate }}</td>
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
<td class="num">{{ row.memberCount }}</td>
<td>{{ row.declaredOn | slice: 0 : 10 }}</td>
<td class="dr-actions">
<button type="button" class="pg-btn" (click)="openView(row); $event.stopPropagation()">View</button>
<button
type="button"
class="pg-btn dr-approve-btn"
[disabled]="approvingId === row.dividendId"
(click)="approve(row); $event.stopPropagation()"
>
{{ approvingId === row.dividendId ? 'Approving…' : 'Approve' }}
</button>
@if (approveError[row.dividendId]) {
<div class="dr-approve-error">{{ approveError[row.dividendId] }}</div>
}
</td>
</tr>
}
<!-- ── FILTER BAR ── -->
<div class="dr-filter-bar">
<div class="dr-search-box">
<mat-icon>search</mat-icon>
<input
type="text"
placeholder="Search by share product"
[(ngModel)]="searchTerm"
(ngModelChange)="applyFilter()"
/>
@if (searchTerm) {
<button type="button" class="dr-search-box__clear" (click)="searchTerm = ''; applyFilter()">
<mat-icon>close</mat-icon>
</button>
}
</tbody>
</table>
</div>
<button type="button" class="dr-btn dr-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading) {
<div class="empty-state">
<p class="empty-title">Loading…</p>
</div>
} @else if (loaded && rows.length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">check_circle</mat-icon>
<p class="empty-title">No dividend declarations awaiting approval</p>
<p class="empty-text">
@if (allRows.length > 0) {
No declarations match "{{ searchTerm }}".
} @else {
Every declared dividend has already been actioned.
}
</p>
</div>
} @else {
<div class="dr-table-wrap">
<table class="dr-table">
<thead>
<tr>
<th>Share Product</th>
<th>Dividend Period</th>
<th class="num">Total Amount</th>
<th class="num">Members</th>
<th>Declared On</th>
<th></th>
</tr>
</thead>
<tbody>
<!-- The whole row opens the member-by-member breakdown, so a checker can get to the detail
the same way they would on the Dividends list rather than having to find the View
button. The action buttons stop propagation so Approve still approves directly. -->
@for (row of rows; track row.dividendId) {
<tr class="dr-row" (click)="openView(row)">
<td>{{ row.productName }}</td>
<td class="dr-dt">{{ row.dividendPeriodStartDate }} → {{ row.dividendPeriodEndDate }}</td>
<td class="num dr-amount">{{ row.amount | number: '1.2-2' }}</td>
<td class="num">{{ row.memberCount }}</td>
<td class="dr-dt">{{ row.declaredOn | slice: 0 : 10 }}</td>
<td class="dr-actions" (click)="$event.stopPropagation()">
<button type="button" class="dr-btn" (click)="openView(row)">View</button>
<button
type="button"
class="dr-btn dr-approve-btn"
[disabled]="approvingId === row.dividendId"
(click)="approve(row)"
>
{{ approvingId === row.dividendId ? 'Approving…' : 'Approve' }}
</button>
@if (approveError[row.dividendId]) {
<div class="dr-approve-error">{{ approveError[row.dividendId] }}</div>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@if (showViewModal && viewProductId && viewDividendId) {
@@ -6,6 +6,209 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
}
.dr-tab {
padding: 0;
}
// ── Title bar — mirrors WithdrawalRequestsComponent's action bar ──────────
.dr-action-bar {
display: flex;
align-items: center;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #0f4f3c;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
}
// ── Filter bar ──────────────────────────────────────────────────────────
.dr-filter-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.dr-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
min-width: 260px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
transition: border-color 0.15s;
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
flex-shrink: 0;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: transparent;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: none;
cursor: pointer;
color: #94a3b8;
padding: 0;
border-radius: 50%;
flex-shrink: 0;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.dr-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 32px;
padding: 0 12px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--refresh {
padding: 0 8px;
color: #64748b;
&:hover:not(:disabled) {
background: #f1f5f9;
}
}
}
.dr-table-wrap {
overflow-x: auto;
}
.dr-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #f1f5f9;
}
th {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
background: #f8fafc;
white-space: nowrap;
}
.num {
text-align: right;
}
}
.dr-dt {
font-size: 12px;
color: #64748b;
white-space: nowrap;
}
.dr-amount {
font-weight: 700;
color: #0f4f3c;
}
.dr-actions {
display: flex;
align-items: center;
@@ -18,6 +221,10 @@
color: #fff;
border-color: #2563eb;
&:hover:not(:disabled) {
background: #1d4ed8;
}
&:disabled {
opacity: 0.6;
}
@@ -30,6 +237,40 @@
font-weight: 600;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: #94a3b8;
.empty-icon {
font-size: 40px;
color: #cbd5e1;
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: #64748b;
margin: 12px 0 4px;
}
.empty-text {
font-size: 13px;
color: #94a3b8;
margin: 0;
}
}
/* The whole row opens the member-by-member breakdown, so it has to look like it will. */
.dr-row {
cursor: pointer;
transition: background 0.1s;
&:hover td {
background: #f0fdf4;
}
}
@keyframes dr-popup-fade {
from {
opacity: 0;
@@ -83,12 +324,3 @@
animation: none;
}
}
/* The whole row opens the member-by-member breakdown, so it has to look like it will. */
.dr-row {
cursor: pointer;
&:hover {
background: #f8fafc;
}
}
@@ -8,7 +8,9 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
@@ -33,6 +35,8 @@ import { ViewDividendComponent } from '../../../products/share-products/view-div
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
ViewDividendComponent
]
})
@@ -42,9 +46,13 @@ export class DividendRequestsComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
/** Raw, unfiltered list from the API -- search happens client-side over this since the endpoint
* has no server-side filtering (it always returns every pending declaration in one shot). */
allRows: any[] = [];
rows: any[] = [];
loading = false;
loaded = false;
searchTerm = '';
approvingId: number | null = null;
approveError: Record<number, string> = {};
@@ -106,12 +114,20 @@ export class DividendRequestsComponent implements OnInit {
.list()
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.rows = res ?? [];
this.allRows = res ?? [];
this.applyFilter();
this.loading = false;
this.loaded = true;
});
}
applyFilter(): void {
const needle = this.searchTerm.trim().toLowerCase();
this.rows = !needle
? this.allRows
: this.allRows.filter((row) => `${row.productName ?? ''}`.toLowerCase().includes(needle));
}
approve(row: any): void {
this.approvingId = row.dividendId;
delete this.approveError[row.dividendId];
@@ -7,88 +7,175 @@
-->
<div class="psae-tab-content">
<!-- ── TITLE BAR ── -->
<div class="psae-header">
<div class="psae-header__left">
<mat-icon class="psae-header__icon">edit_note</mat-icon>
<div>
<div class="psae-header__title">Pending Account Edits</div>
<div class="psae-header__sub">Review, approve or reject proposed Nominee / Digital-Feature edits.</div>
</div>
</div>
</div>
@if (showData) {
<div class="psae-action-bar">
<mat-form-field appearance="outline" class="psae-search-field">
<mat-icon matPrefix>search</mat-icon>
<input #filterInput matInput placeholder="Search by account number" (keyup)="applyFilter(filterInput.value)" />
</mat-form-field>
<!-- ── FILTER BAR ── -->
<div class="psae-filter-bar">
<div class="psae-search-box">
<mat-icon>search</mat-icon>
<input
#filterInput
type="text"
placeholder="Search by account, client or submitter"
[value]="searchTerm"
(input)="applyFilter(filterInput.value)"
/>
@if (searchTerm) {
<button type="button" class="psae-search-box__clear" (click)="filterInput.value = ''; applyFilter('')">
<mat-icon>close</mat-icon>
</button>
}
</div>
<span class="psae-fb-sep"></span>
<div class="psae-presets">
@for (p of datePresets; track p.key) {
<button
type="button"
class="psae-preset-chip"
[class.psae-preset-chip--active]="activePreset === p.key"
(click)="applyPreset(p.key)"
>
{{ p.label }}
</button>
}
</div>
<span class="psae-fb-sep"></span>
<div class="psae-date-range">
<div class="psae-date-box">
<span class="psae-date-box__lbl">From</span>
<input
class="psae-date-box__input"
[matDatepicker]="fromPicker"
[(ngModel)]="fromDate"
[max]="maxDate"
(dateChange)="onFromDateChange()"
readonly
/>
<button type="button" class="psae-date-box__btn" (click)="fromPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #fromPicker></mat-datepicker>
</div>
<span class="psae-date-range__sep">—</span>
<div class="psae-date-box">
<span class="psae-date-box__lbl">To</span>
<input
class="psae-date-box__input"
[matDatepicker]="toPicker"
[(ngModel)]="toDate"
[min]="fromDate"
[max]="maxDate"
(dateChange)="onToDateChange()"
readonly
/>
<button type="button" class="psae-date-box__btn" (click)="toPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #toPicker></mat-datepicker>
</div>
</div>
<button type="button" class="psae-btn psae-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="modern-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No.</th>
<td mat-cell *matCellDef="let edit">{{ edit.accountNo }}</td>
</ng-container>
@if (dataSource.data.length === 0) {
<div class="empty-state psae-empty-inline">
<fa-icon icon="filter" class="empty-icon"></fa-icon>
<p class="empty-title">No matching requests</p>
<p class="empty-text">No pending edits match the current search or date range.</p>
</div>
} @else {
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="psae-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No.</th>
<td mat-cell *matCellDef="let edit" class="cell-mono">{{ edit.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="accountType">
<th mat-header-cell *matHeaderCellDef>Account Type</th>
<td mat-cell *matCellDef="let edit">
<span
class="status-badge"
[class.status-badge--savings]="edit.accountType === 'Savings'"
[class.status-badge--current]="edit.accountType === 'Current'"
[class.status-badge--fd]="edit.accountType === 'Fixed Deposit'"
[class.status-badge--rd]="edit.accountType === 'Recurring Deposit'"
>
{{ edit.accountType }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="accountType">
<th mat-header-cell *matHeaderCellDef>Account Type</th>
<td mat-cell *matCellDef="let edit">
<span
class="status-badge"
[class.status-badge--savings]="edit.accountType === 'Savings'"
[class.status-badge--current]="edit.accountType === 'Current'"
[class.status-badge--fd]="edit.accountType === 'Fixed Deposit'"
[class.status-badge--rd]="edit.accountType === 'Recurring Deposit'"
>
{{ edit.accountType }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="makerUsername">
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
<td mat-cell *matCellDef="let edit">{{ edit.makerUsername }}</td>
</ng-container>
<ng-container matColumnDef="makerUsername">
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
<td mat-cell *matCellDef="let edit">{{ edit.makerUsername }}</td>
</ng-container>
<ng-container matColumnDef="createdOn">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let edit">{{ formatDate(edit.createdOn) }}</td>
</ng-container>
<ng-container matColumnDef="createdOn">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let edit" class="psae-dt">{{ formatDate(edit.createdOn) }}</td>
</ng-container>
<ng-container matColumnDef="holdAmount">
<th mat-header-cell *matHeaderCellDef>Hold Amount</th>
<td mat-cell *matCellDef="let edit">
@if (edit.holdAmount) {
<span class="status-badge status-badge--hold">{{ edit.holdAmount }}</span>
} @else {
&mdash;
}
</td>
</ng-container>
<ng-container matColumnDef="holdAmount">
<th mat-header-cell *matHeaderCellDef>Hold Amount</th>
<td mat-cell *matCellDef="let edit">
@if (edit.holdAmount) {
<span class="status-badge status-badge--hold">{{ edit.holdAmount }}</span>
} @else {
&mdash;
}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let edit">
<span class="status-badge status-badge--pending">{{ edit.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let edit">
<span class="status-badge status-badge--pending">{{ edit.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let edit">
<button
mat-stroked-button
class="psae-btn psae-btn-view"
[disabled]="loadingCompareFor === edit"
(click)="viewCompare(edit)"
>
<fa-icon icon="eye" class="m-r-10"></fa-icon>{{ loadingCompareFor === edit ? 'Loading…' : 'Compare' }}
</button>
<button mat-stroked-button class="psae-btn psae-btn-approve" (click)="approve(edit)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psae-btn psae-btn-reject" (click)="reject(edit)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let edit">
<button
mat-stroked-button
class="psae-btn psae-btn-view"
[disabled]="loadingCompareFor === edit"
(click)="viewCompare(edit)"
>
<fa-icon icon="eye" class="m-r-10"></fa-icon>{{ loadingCompareFor === edit ? 'Loading…' : 'Compare' }}
</button>
<button mat-stroked-button class="psae-btn psae-btn-approve" (click)="approve(edit)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psae-btn psae-btn-reject" (click)="reject(edit)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
</div>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="psae-row"></tr>
</table>
</div>
}
}
@if (!showData) {
@@ -10,21 +10,298 @@ $border: #e2e8f0;
$text-muted: #64748b;
.psae-tab-content {
padding: 16px 0;
padding: 0;
}
.psae-action-bar {
// ── Title bar — mirrors WithdrawalRequestsComponent's action bar ──────────
.psae-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid $border;
margin-bottom: 16px;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #0f4f3c;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: $text-muted;
margin-top: 2px;
}
}
// ── Filter bar + search box — mirrors WithdrawalRequestsComponent ─────────
.psae-filter-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 0 20px 16px;
flex-wrap: wrap;
}
.psae-search-field {
flex: 1;
min-width: 200px;
max-width: 320px;
.psae-fb-sep {
width: 1px;
height: 22px;
background: $border;
flex-shrink: 0;
margin: 0 4px;
}
.psae-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
min-width: 260px;
padding: 0 10px;
border: 1px solid $border;
border-radius: 8px;
background: #fff;
transition: border-color 0.15s;
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
flex-shrink: 0;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: transparent;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: none;
cursor: pointer;
color: #94a3b8;
padding: 0;
border-radius: 50%;
flex-shrink: 0;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.psae-presets {
display: inline-flex;
gap: 4px;
align-items: center;
}
.psae-preset-chip {
height: 28px;
padding: 0 12px;
border-radius: 14px;
border: 1px solid $border;
background: #fff;
font-size: 12px;
font-weight: 600;
font-family: inherit;
color: #475569;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
&:hover {
border-color: #0f4f3c;
color: #0f4f3c;
background: #f0fdf4;
}
&--active {
background: #0f4f3c;
border-color: #0f4f3c;
color: #fff;
&:hover {
background: #0a3d2e;
}
}
}
.psae-date-range {
display: inline-flex;
align-items: center;
gap: 4px;
&__sep {
color: #94a3b8;
font-size: 16px;
font-weight: 300;
padding: 0 2px;
user-select: none;
}
}
.psae-date-box {
display: inline-flex;
align-items: center;
gap: 4px;
height: 34px;
padding: 0 6px 0 10px;
border: 1px solid $border;
border-radius: 8px;
background: #fff;
transition: border-color 0.15s;
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
}
&__lbl {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
white-space: nowrap;
user-select: none;
letter-spacing: 0.03em;
}
&__input {
border: none;
outline: none;
font-size: 13px;
width: 76px;
color: #0d1b2a;
font-family: inherit;
background: transparent;
cursor: pointer;
&::placeholder {
color: #cbd5e1;
}
}
&__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
background: none;
cursor: pointer;
color: #94a3b8;
padding: 0;
border-radius: 4px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
color: #0f4f3c;
background: #f0fdf4;
}
}
}
.table-container {
padding: 0 20px;
}
// ── Table — mirrors WithdrawalRequestsComponent's table ───────────────────
.psae-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
background: #fff;
th,
td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #f1f5f9;
}
th {
font-size: 11px;
font-weight: 700;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.06em;
background: #f8fafc;
white-space: nowrap;
}
}
.psae-row {
transition: background 0.1s;
&:hover td {
background: #f0fdf4;
}
}
.psae-dt {
font-size: 12px;
color: $text-muted;
white-space: nowrap;
}
.cell-mono {
font-family: 'Courier New', monospace;
font-size: 12px;
}
.psae-btn {
@@ -41,6 +318,36 @@ $text-muted: #64748b;
}
}
.psae-btn--refresh {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
margin-right: 0;
border-radius: 8px !important;
border: 1px solid $border;
background: #fff;
color: $text-muted;
cursor: pointer;
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
}
&:hover:not(:disabled) {
background: #f1f5f9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.psae-btn-view {
border-color: #2563eb;
color: #2563eb;
@@ -117,7 +424,12 @@ $text-muted: #64748b;
border: 1px dashed $border;
border-radius: 14px;
text-align: center;
margin: 16px 0;
margin: 16px 20px;
&.psae-empty-inline {
padding: 40px 24px;
margin: 0 20px 16px;
}
.empty-icon {
font-size: 42px;
@@ -8,6 +8,8 @@
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { forkJoin } from 'rxjs';
import {
MatTableDataSource,
MatTable,
@@ -23,7 +25,6 @@ import {
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { SavingsService } from 'app/savings/savings.service';
@@ -45,6 +46,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./pending-savings-account-edits.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatTable,
MatColumnDef,
@@ -56,8 +58,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon,
MatIconButton
MatIcon
]
})
export class PendingSavingsAccountEditsComponent {
@@ -82,36 +83,145 @@ export class PendingSavingsAccountEditsComponent {
/** Set only while the current account is being fetched, so the button can show progress. */
loadingCompareFor: any = null;
loading = false;
/** Raw, unfiltered list from the resolvers -- filtering happens client-side over this since both
* endpoints always return every PENDING edit in one shot (no server-side paging). */
private allItems: any[] = [];
searchTerm = '';
fromDate: Date | null = null;
toDate: Date | null = null;
maxDate!: Date;
activePreset = 'all';
readonly datePresets = [
{ key: 'today', label: 'Today' },
{ key: 'week', label: 'This Week' },
{ key: 'month', label: 'This Month' },
{ key: 'all', label: 'All' }
];
constructor() {
this.maxDate = new Date();
this.route.data.subscribe((data: { pendingSavingsAccountEditsData: any; pendingCurrentAccountEditsData: any }) => {
const savingsItems = (data.pendingSavingsAccountEditsData || []).map((item: any) => {
let type = 'Savings';
if (item.depositAccountType === 'FIXED_DEPOSIT') {
type = 'Fixed Deposit';
} else if (item.depositAccountType === 'RECURRING_DEPOSIT') {
type = 'Recurring Deposit';
}
return {
...item,
accountType: type
};
});
const currentItems = (data.pendingCurrentAccountEditsData || []).map((item: any) => ({
...item,
accountType: 'Current'
}));
const items = [
...savingsItems,
...currentItems
];
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
this.allItems = this.mergeItems(data.pendingSavingsAccountEditsData, data.pendingCurrentAccountEditsData);
this.applyFilters();
});
}
private mergeItems(savingsData: any, currentData: any): any[] {
const savingsItems = (savingsData || []).map((item: any) => {
let type = 'Savings';
if (item.depositAccountType === 'FIXED_DEPOSIT') {
type = 'Fixed Deposit';
} else if (item.depositAccountType === 'RECURRING_DEPOSIT') {
type = 'Recurring Deposit';
}
return {
...item,
accountType: type
};
});
const currentItems = (currentData || []).map((item: any) => ({
...item,
accountType: 'Current'
}));
return [
...savingsItems,
...currentItems
];
}
applyFilter(filterValue: string = '') {
this.dataSource.filter = filterValue.trim().toLowerCase();
this.searchTerm = filterValue;
this.applyFilters();
}
applyPreset(key: string): void {
this.activePreset = key;
const today = new Date();
today.setHours(0, 0, 0, 0);
if (key === 'today') {
this.fromDate = new Date(today);
this.toDate = new Date(today);
} else if (key === 'week') {
const start = new Date(today);
start.setDate(today.getDate() - today.getDay());
this.fromDate = start;
this.toDate = new Date(today);
} else if (key === 'month') {
this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1);
this.toDate = new Date(today);
} else {
this.fromDate = null;
this.toDate = null;
}
this.applyFilters();
}
onFromDateChange(): void {
this.activePreset = '';
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
this.toDate = new Date(this.fromDate);
}
this.applyFilters();
}
onToDateChange(): void {
this.activePreset = '';
this.applyFilters();
}
private itemDate(item: any): Date | null {
const raw = item?.createdOn;
if (!raw) return null;
const d = Array.isArray(raw) ? new Date(raw[0], raw[1] - 1, raw[2], raw[3] || 0, raw[4] || 0) : new Date(raw);
return isNaN(d.getTime()) ? null : d;
}
private applyFilters(): void {
const from = this.fromDate
? new Date(this.fromDate.getFullYear(), this.fromDate.getMonth(), this.fromDate.getDate())
: null;
const to = this.toDate
? new Date(this.toDate.getFullYear(), this.toDate.getMonth(), this.toDate.getDate(), 23, 59, 59, 999)
: null;
const needle = this.searchTerm.trim().toLowerCase();
const filtered = this.allItems.filter((item) => {
if (from || to) {
const d = this.itemDate(item);
if (!d) return false;
if (from && d < from) return false;
if (to && d > to) return false;
}
if (needle) {
const haystack = `${item.accountNo ?? ''} ${item.clientName ?? ''} ${item.makerUsername ?? ''}`.toLowerCase();
if (!haystack.includes(needle)) return false;
}
return true;
});
this.showData = this.allItems.length > 0;
this.dataSource = new MatTableDataSource(filtered);
}
load(): void {
this.loading = true;
forkJoin({
savings: this.savingsService.getAllPendingSavingsAccountEdits(),
current: this.currentAccountsService.getAllPendingCurrentAccountEdits()
}).subscribe({
next: ({ savings, current }) => {
this.allItems = this.mergeItems(savings, current);
this.applyFilters();
this.loading = false;
},
error: () => {
this.loading = false;
}
});
}
formatDate(date: any): string {
@@ -7,94 +7,153 @@
-->
<div class="psae-tab-content">
<!-- ── TITLE BAR ── -->
<div class="psae-header">
<div class="psae-header__left">
<mat-icon class="psae-header__icon">edit_note</mat-icon>
<div>
<div class="psae-header__title">Pending Share Account Edits</div>
<div class="psae-header__sub">Review, approve or reject proposed Share Account detail edits.</div>
</div>
</div>
</div>
@if (showData) {
<div class="psae-action-bar">
<mat-form-field appearance="outline" class="psae-search-field">
<mat-icon matPrefix>search</mat-icon>
<input #filterInput matInput placeholder="Search by account number" (keyup)="applyFilter(filterInput.value)" />
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="modern-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No.</th>
<td mat-cell *matCellDef="let edit">{{ edit.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Client</th>
<td mat-cell *matCellDef="let edit">{{ edit.clientName }}</td>
</ng-container>
<ng-container matColumnDef="makerUsername">
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
<td mat-cell *matCellDef="let edit">{{ edit.makerUsername }}</td>
</ng-container>
<ng-container matColumnDef="createdOn">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let edit">{{ formatDate(edit.createdOn) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let edit">
<span class="status-badge status-badge--pending">{{ edit.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let edit">
<button mat-stroked-button class="psae-btn psae-btn-view" (click)="viewCompare(edit)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
</button>
<button mat-stroked-button class="psae-btn psae-btn-approve" (click)="approve(edit)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psae-btn psae-btn-reject" (click)="reject(edit)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
</div>
@if (selectedEdit) {
<div class="psae-diff-panel">
<div class="psae-diff-header">
<h3>Proposed Changes &mdash; Account "{{ selectedEdit.accountNo }}"</h3>
<button mat-icon-button (click)="closeCompare()"><fa-icon icon="times"></fa-icon></button>
</div>
<div class="psae-diff-block">
<h4>Details</h4>
<table class="psae-diff-table">
<thead>
<tr>
<th>Field</th>
<th>Proposed Value</th>
</tr>
</thead>
<tbody>
@for (row of entries(selectedEdit); track row.key) {
<tr>
<td class="psae-diff-label">{{ row.key }}</td>
<td class="psae-diff-proposed">{{ row.value }}</td>
</tr>
}
</tbody>
</table>
</div>
@if (selectedEdit.rejectReason) {
<p class="psae-reject-reason"><strong>Reject Reason:</strong> {{ selectedEdit.rejectReason }}</p>
<!-- ── FILTER BAR ── -->
<div class="psae-filter-bar">
<div class="psae-search-box">
<mat-icon>search</mat-icon>
<input
#filterInput
type="text"
placeholder="Search by account, client or submitter"
[value]="searchTerm"
(input)="applyFilter(filterInput.value)"
/>
@if (searchTerm) {
<button type="button" class="psae-search-box__clear" (click)="filterInput.value = ''; applyFilter('')">
<mat-icon>close</mat-icon>
</button>
}
</div>
<span class="psae-fb-sep"></span>
<div class="psae-presets">
@for (p of datePresets; track p.key) {
<button
type="button"
class="psae-preset-chip"
[class.psae-preset-chip--active]="activePreset === p.key"
(click)="applyPreset(p.key)"
>
{{ p.label }}
</button>
}
</div>
<span class="psae-fb-sep"></span>
<div class="psae-date-range">
<div class="psae-date-box">
<span class="psae-date-box__lbl">From</span>
<input
class="psae-date-box__input"
[matDatepicker]="fromPicker"
[(ngModel)]="fromDate"
[max]="maxDate"
(dateChange)="onFromDateChange()"
readonly
/>
<button type="button" class="psae-date-box__btn" (click)="fromPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #fromPicker></mat-datepicker>
</div>
<span class="psae-date-range__sep">—</span>
<div class="psae-date-box">
<span class="psae-date-box__lbl">To</span>
<input
class="psae-date-box__input"
[matDatepicker]="toPicker"
[(ngModel)]="toDate"
[min]="fromDate"
[max]="maxDate"
(dateChange)="onToDateChange()"
readonly
/>
<button type="button" class="psae-date-box__btn" (click)="toPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #toPicker></mat-datepicker>
</div>
</div>
<button type="button" class="psae-btn psae-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
@if (dataSource.data.length === 0) {
<div class="empty-state psae-empty-inline">
<fa-icon icon="filter" class="empty-icon"></fa-icon>
<p class="empty-title">No matching requests</p>
<p class="empty-text">No pending edits match the current search or date range.</p>
</div>
} @else {
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="psae-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No.</th>
<td mat-cell *matCellDef="let edit" class="cell-mono">{{ edit.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Client</th>
<td mat-cell *matCellDef="let edit">{{ edit.clientName }}</td>
</ng-container>
<ng-container matColumnDef="makerUsername">
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
<td mat-cell *matCellDef="let edit">{{ edit.makerUsername }}</td>
</ng-container>
<ng-container matColumnDef="createdOn">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let edit" class="psae-dt">{{ formatDate(edit.createdOn) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let edit">
<span class="status-badge status-badge--pending">{{ edit.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let edit">
<button
mat-stroked-button
class="psae-btn psae-btn-view"
[disabled]="loadingCompareFor === edit"
(click)="viewCompare(edit)"
>
<fa-icon icon="eye" class="m-r-10"></fa-icon>{{ loadingCompareFor === edit ? 'Loading…' : 'Compare' }}
</button>
<button mat-stroked-button class="psae-btn psae-btn-approve" (click)="approve(edit)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psae-btn psae-btn-reject" (click)="reject(edit)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="psae-row"></tr>
</table>
</div>
}
}
@@ -10,21 +10,298 @@ $border: #e2e8f0;
$text-muted: #64748b;
.psae-tab-content {
padding: 16px 0;
padding: 0;
}
.psae-action-bar {
// ── Title bar — mirrors WithdrawalRequestsComponent's action bar ──────────
.psae-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid $border;
margin-bottom: 16px;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #0f4f3c;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: $text-muted;
margin-top: 2px;
}
}
// ── Filter bar + search box — mirrors WithdrawalRequestsComponent ─────────
.psae-filter-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 0 20px 16px;
flex-wrap: wrap;
}
.psae-search-field {
flex: 1;
min-width: 200px;
max-width: 320px;
.psae-fb-sep {
width: 1px;
height: 22px;
background: $border;
flex-shrink: 0;
margin: 0 4px;
}
.psae-presets {
display: inline-flex;
gap: 4px;
align-items: center;
}
.psae-preset-chip {
height: 28px;
padding: 0 12px;
border-radius: 14px;
border: 1px solid $border;
background: #fff;
font-size: 12px;
font-weight: 600;
font-family: inherit;
color: #475569;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
&:hover {
border-color: #0f4f3c;
color: #0f4f3c;
background: #f0fdf4;
}
&--active {
background: #0f4f3c;
border-color: #0f4f3c;
color: #fff;
&:hover {
background: #0a3d2e;
}
}
}
.psae-date-range {
display: inline-flex;
align-items: center;
gap: 4px;
&__sep {
color: #94a3b8;
font-size: 16px;
font-weight: 300;
padding: 0 2px;
user-select: none;
}
}
.psae-date-box {
display: inline-flex;
align-items: center;
gap: 4px;
height: 34px;
padding: 0 6px 0 10px;
border: 1px solid $border;
border-radius: 8px;
background: #fff;
transition: border-color 0.15s;
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
}
&__lbl {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
white-space: nowrap;
user-select: none;
letter-spacing: 0.03em;
}
&__input {
border: none;
outline: none;
font-size: 13px;
width: 76px;
color: #0d1b2a;
font-family: inherit;
background: transparent;
cursor: pointer;
&::placeholder {
color: #cbd5e1;
}
}
&__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
background: none;
cursor: pointer;
color: #94a3b8;
padding: 0;
border-radius: 4px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
color: #0f4f3c;
background: #f0fdf4;
}
}
}
.psae-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
min-width: 260px;
padding: 0 10px;
border: 1px solid $border;
border-radius: 8px;
background: #fff;
transition: border-color 0.15s;
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
flex-shrink: 0;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: transparent;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: none;
cursor: pointer;
color: #94a3b8;
padding: 0;
border-radius: 50%;
flex-shrink: 0;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.table-container {
padding: 0 20px;
}
// ── Table — mirrors WithdrawalRequestsComponent's table ───────────────────
.psae-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
background: #fff;
th,
td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #f1f5f9;
}
th {
font-size: 11px;
font-weight: 700;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.06em;
background: #f8fafc;
white-space: nowrap;
}
}
.psae-row {
transition: background 0.1s;
&:hover td {
background: #f0fdf4;
}
}
.psae-dt {
font-size: 12px;
color: $text-muted;
white-space: nowrap;
}
.cell-mono {
font-family: 'Courier New', monospace;
font-size: 12px;
}
.psae-btn {
@@ -41,6 +318,36 @@ $text-muted: #64748b;
}
}
.psae-btn--refresh {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
margin-right: 0;
border-radius: 8px !important;
border: 1px solid $border;
background: #fff;
color: $text-muted;
cursor: pointer;
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
}
&:hover:not(:disabled) {
background: #f1f5f9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.psae-btn-view {
border-color: #2563eb;
color: #2563eb;
@@ -82,79 +389,6 @@ $text-muted: #64748b;
}
}
.psae-diff-panel {
margin-top: 20px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
padding: 16px;
}
.psae-diff-header {
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
}
.psae-diff-block {
margin-top: 16px;
h4 {
margin: 0 0 6px;
font-size: 12.5px;
font-weight: 700;
color: #0d1b2a;
text-transform: uppercase;
letter-spacing: 0.03em;
}
}
.psae-diff-loading {
color: $text-muted;
font-size: 13px;
}
.psae-diff-table {
width: 100%;
border-collapse: collapse;
margin-top: 4px;
font-size: 12.5px;
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid $border;
}
th {
color: $text-muted;
font-weight: 600;
}
}
.psae-diff-label {
font-weight: 600;
color: #0d1b2a;
}
.psae-diff-proposed {
color: #15803d;
font-weight: 600;
}
.psae-reject-reason {
margin-top: 14px;
font-size: 12.5px;
color: #b91c1c;
}
.empty-state {
display: flex;
flex-direction: column;
@@ -165,7 +399,7 @@ $text-muted: #64748b;
border: 1px dashed $border;
border-radius: 14px;
text-align: center;
margin: 16px 0;
margin: 16px 20px;
.empty-icon {
font-size: 42px;
@@ -180,6 +414,11 @@ $text-muted: #64748b;
color: #0d1b2a;
}
&.psae-empty-inline {
padding: 40px 24px;
margin: 0 20px 16px;
}
.empty-text {
margin: 0;
font-size: 12.5px;
@@ -8,6 +8,7 @@
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import {
MatTableDataSource,
MatTable,
@@ -23,7 +24,6 @@ import {
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { SharesService } from 'app/shares/shares.service';
@@ -32,6 +32,7 @@ import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.componen
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ShareAccountEditCompareDialogComponent } from './share-account-edit-compare-dialog/share-account-edit-compare-dialog.component';
/**
* Checker-facing list of Share Account details edits awaiting approval.
@@ -43,6 +44,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./pending-share-account-edits.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatTable,
MatColumnDef,
@@ -54,8 +56,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon,
MatIconButton
MatIcon
]
})
export class PendingShareAccountEditsComponent {
@@ -76,18 +77,120 @@ export class PendingShareAccountEditsComponent {
'actions'
];
selectedEdit: any = null;
loadingCompareFor: any = null;
loading = false;
/** Raw, unfiltered list from the resolver -- filtering happens client-side over this since the
* endpoint always returns every PENDING edit in one shot (no server-side paging). */
private allItems: any[] = [];
searchTerm = '';
fromDate: Date | null = null;
toDate: Date | null = null;
maxDate!: Date;
activePreset = 'all';
readonly datePresets = [
{ key: 'today', label: 'Today' },
{ key: 'week', label: 'This Week' },
{ key: 'month', label: 'This Month' },
{ key: 'all', label: 'All' }
];
constructor() {
this.maxDate = new Date();
this.route.data.subscribe((data: { pendingShareAccountEditsData: any }) => {
const items = data.pendingShareAccountEditsData || [];
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
this.allItems = data.pendingShareAccountEditsData || [];
this.applyFilters();
});
}
applyFilter(filterValue: string = '') {
this.dataSource.filter = filterValue.trim().toLowerCase();
this.searchTerm = filterValue;
this.applyFilters();
}
applyPreset(key: string): void {
this.activePreset = key;
const today = new Date();
today.setHours(0, 0, 0, 0);
if (key === 'today') {
this.fromDate = new Date(today);
this.toDate = new Date(today);
} else if (key === 'week') {
const start = new Date(today);
start.setDate(today.getDate() - today.getDay());
this.fromDate = start;
this.toDate = new Date(today);
} else if (key === 'month') {
this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1);
this.toDate = new Date(today);
} else {
this.fromDate = null;
this.toDate = null;
}
this.applyFilters();
}
onFromDateChange(): void {
this.activePreset = '';
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
this.toDate = new Date(this.fromDate);
}
this.applyFilters();
}
onToDateChange(): void {
this.activePreset = '';
this.applyFilters();
}
private itemDate(item: any): Date | null {
const raw = item?.createdOn;
if (!raw) return null;
const d = Array.isArray(raw) ? new Date(raw[0], raw[1] - 1, raw[2], raw[3] || 0, raw[4] || 0) : new Date(raw);
return isNaN(d.getTime()) ? null : d;
}
private applyFilters(): void {
const from = this.fromDate
? new Date(this.fromDate.getFullYear(), this.fromDate.getMonth(), this.fromDate.getDate())
: null;
const to = this.toDate
? new Date(this.toDate.getFullYear(), this.toDate.getMonth(), this.toDate.getDate(), 23, 59, 59, 999)
: null;
const needle = this.searchTerm.trim().toLowerCase();
const filtered = this.allItems.filter((item) => {
if (from || to) {
const d = this.itemDate(item);
if (!d) return false;
if (from && d < from) return false;
if (to && d > to) return false;
}
if (needle) {
const haystack = `${item.accountNo ?? ''} ${item.clientName ?? ''} ${item.makerUsername ?? ''}`.toLowerCase();
if (!haystack.includes(needle)) return false;
}
return true;
});
this.showData = this.allItems.length > 0;
this.dataSource = new MatTableDataSource(filtered);
}
load(): void {
this.loading = true;
this.sharesService.getAllPendingShareAccountEdits().subscribe({
next: (items: any) => {
this.allItems = items || [];
this.applyFilters();
this.loading = false;
},
error: () => {
this.loading = false;
}
});
}
formatDate(date: any): string {
@@ -98,48 +201,29 @@ export class PendingShareAccountEditsComponent {
return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN');
}
/** Readable list of "key: value" pairs for the proposed details changes. */
entries(edit: any): { key: string; value: any }[] {
if (!edit) return [];
const fields: [string, any][] = [
[
'Linked Savings Account',
edit.savingsAccountNo
],
[
'Allow Dividends for Inactive Clients',
edit.allowDividendCalculationForInactiveClients
],
[
'Lockin Period Frequency',
edit.lockinPeriodFrequency
],
[
'Minimum Active Period Frequency',
edit.minimumActivePeriodFrequency
]
];
return fields
.filter(
([
,
value
]) => value !== null && value !== undefined
)
.map(
([
key,
value
]) => ({ key, value })
);
}
/**
* Fetches the account's current details so the popup can show them next to what's proposed, then
* opens the compare dialog -- mirrors PendingSavingsAccountEditsComponent#viewCompare.
*/
viewCompare(edit: any): void {
this.selectedEdit = this.selectedEdit === edit ? null : edit;
}
closeCompare(): void {
this.selectedEdit = null;
this.loadingCompareFor = edit;
this.sharesService.getSharesAccountData(String(edit.shareAccountId), false).subscribe({
next: (account: any) => {
this.loadingCompareFor = null;
this.dialog.open(ShareAccountEditCompareDialogComponent, {
width: '640px',
data: {
accountNo: edit.accountNo,
currentAccount: account,
edit,
rejectReason: edit.rejectReason
}
});
},
error: () => {
this.loadingCompareFor = null;
}
});
}
approve(edit: any): void {
@@ -183,7 +267,6 @@ export class PendingShareAccountEditsComponent {
}
reload(): void {
this.closeCompare();
const url = this.router.url;
this.router
.navigateByUrl('/checker-inbox-and-tasks', { skipLocationChange: true })
@@ -0,0 +1,52 @@
<!--
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/.
-->
<h2 mat-dialog-title class="saecd-title">
<mat-icon class="saecd-title__icon">difference</mat-icon>
<span>
Proposed changes &mdash; <strong>{{ accountNo }}</strong>
</span>
</h2>
<div mat-dialog-content class="saecd-content">
@if (rows.length === 0) {
<div class="saecd-empty">
<mat-icon class="saecd-empty__icon">check_circle</mat-icon>
<p class="saecd-empty__title">No changes to review</p>
<p class="saecd-empty__text">This edit was submitted without changing any detail.</p>
</div>
}
@if (rows.length > 0) {
<table class="saecd-table">
<thead>
<tr>
<th>Field</th>
<th>Current</th>
<th>Proposed</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.label) {
<tr>
<td class="saecd-table__label">{{ row.label }}</td>
<td class="saecd-table__old">{{ row.current }}</td>
<td class="saecd-table__new">{{ row.proposed }}</td>
</tr>
}
</tbody>
</table>
}
@if (rejectReason) {
<p class="saecd-reject-reason"><strong>Reject Reason:</strong> {{ rejectReason }}</p>
}
</div>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
</mat-dialog-actions>
@@ -0,0 +1,119 @@
/**
* 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/.
*/
$border: #e2e8f0;
$muted: #64748b;
.saecd-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: #0f172a;
border-bottom: 1px solid $border;
padding-bottom: 12px;
margin-bottom: 0;
&__icon {
color: #0ea5e9;
}
}
.saecd-content {
max-height: 70vh;
overflow-y: auto;
padding-top: 16px;
}
.saecd-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th,
td {
text-align: left;
padding: 9px 12px;
border-bottom: 1px solid $border;
vertical-align: top;
}
th {
color: $muted;
font-weight: 600;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.4px;
background: #f8fafc;
position: sticky;
top: 0;
}
tbody tr:hover {
background: #fffbeb;
}
&__label {
font-weight: 600;
color: #1e293b;
width: 34%;
}
&__old {
color: #b91c1c;
text-decoration: line-through;
width: 33%;
}
&__new {
color: #15803d;
font-weight: 600;
width: 33%;
}
}
.saecd-empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 32px 16px;
&__icon {
font-size: 40px;
width: 40px;
height: 40px;
color: #16a34a;
margin-bottom: 10px;
}
&__title {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
&__text {
margin: 0;
font-size: 12.5px;
color: $muted;
max-width: 420px;
}
}
.saecd-reject-reason {
margin: 20px 0 0;
padding: 10px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
font-size: 12.5px;
color: #991b1b;
}
@@ -0,0 +1,129 @@
/**
* 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 {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
interface DiffRow {
label: string;
current: string;
proposed: string;
}
/** SharePeriodFrequencyType: 0=Days, 1=Weeks, 2=Months, 3=Years. */
const FREQUENCY_TYPE_LABELS: Record<number, string> = {
0: 'Days',
1: 'Weeks',
2: 'Months',
3: 'Years'
};
/**
* Shows what a pending Share Account "details" edit actually changes, as a popup instead of an
* inline panel -- mirrors SavingsAccountEditCompareDialogComponent's current-vs-proposed pattern.
* A field the maker's edit form included in the payload (see EditShareAccountDetailsComponent#submit)
* is still often equal to the account's current value -- e.g. allowDividendCalculationForInactiveClients
* is always sent, and the maker may not have touched the Default Savings Account or Lock-in Period even
* though those controls were part of the same form submit. Only rows whose displayed current and
* proposed values actually differ are shown, so an unchanged field never appears as a false "change".
*/
@Component({
selector: 'mifosx-share-account-edit-compare-dialog',
templateUrl: './share-account-edit-compare-dialog.component.html',
styleUrls: ['./share-account-edit-compare-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButton,
MatIcon
]
})
export class ShareAccountEditCompareDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<ShareAccountEditCompareDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
accountNo = '';
rows: DiffRow[] = [];
rejectReason: string | null = null;
ngOnInit(): void {
this.accountNo = this.data.accountNo ?? '';
this.rejectReason = this.data.rejectReason ?? null;
const current = this.data.currentAccount ?? {};
const edit = this.data.edit ?? {};
const candidates: { label: string; current: string; proposed: string; proposedRaw: any }[] = [];
candidates.push({
label: 'Linked Savings Account',
current: this.displayValue(current.savingsAccountNumber),
proposed: this.displayValue(edit.savingsAccountNo),
proposedRaw: edit.savingsAccountId
});
candidates.push({
label: 'Allow Dividends for Inactive Clients',
current: this.displayValue(current.allowDividendCalculationForInactiveClients),
proposed: this.displayValue(edit.allowDividendCalculationForInactiveClients),
proposedRaw: edit.allowDividendCalculationForInactiveClients
});
candidates.push({
label: 'Minimum Active Period',
current: this.displayPeriod(current.minimumActivePeriod, current.minimumActivePeriodTypeEnum?.id),
proposed: this.displayPeriod(edit.minimumActivePeriodFrequency, edit.minimumActivePeriodFrequencyType),
proposedRaw: edit.minimumActivePeriodFrequency
});
candidates.push({
label: 'Lock-in Period',
current: this.displayPeriod(current.lockinPeriod, current.lockPeriodTypeEnum?.id),
proposed: this.displayPeriod(edit.lockinPeriodFrequency, edit.lockinPeriodFrequencyType),
proposedRaw: edit.lockinPeriodFrequency
});
// A field the maker's edit form didn't touch was never sent (see the class doc), and a field
// that was sent but landed back on the same value never actually changed anything -- both cases
// are dropped so only genuine differences reach the popup.
this.rows = candidates
.filter((c) => c.proposedRaw !== null && c.proposedRaw !== undefined)
.filter((c) => c.current !== c.proposed)
.map(({ label, current: cur, proposed }) => ({ label, current: cur, proposed }));
}
private displayPeriod(frequency: any, frequencyType: any): string {
if (frequency === null || frequency === undefined) {
return '—';
}
const typeLabel = FREQUENCY_TYPE_LABELS[frequencyType] ?? '';
return typeLabel ? `${frequency} ${typeLabel}` : String(frequency);
}
private displayValue(value: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
return String(value);
}
}
@@ -7,10 +7,41 @@
-->
<div class="sar-container">
<p class="sar-intro">
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.
</p>
<!-- ── TITLE BAR ── -->
<div class="sar-action-bar">
<div class="sar-action-bar__left">
<mat-icon class="sar-action-bar__icon">add_card</mat-icon>
<div>
<div class="sar-action-bar__title">Share Addition Requests</div>
<div class="sar-action-bar__sub">
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.
</div>
</div>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="sar-filter-bar">
<div class="sar-search-box">
<mat-icon>search</mat-icon>
<input
type="text"
placeholder="Search by client, account or resolution no"
[(ngModel)]="searchTerm"
(ngModelChange)="applyFilter()"
/>
@if (searchTerm) {
<button type="button" class="sar-search-box__clear" (click)="searchTerm = ''; applyFilter()">
<mat-icon>close</mat-icon>
</button>
}
</div>
<button type="button" class="sar-btn sar-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
@if (errorMsg) {
<div class="sar-error">
@@ -27,7 +58,13 @@
} @else if (requests.length === 0) {
<div class="sar-empty">
<mat-icon>info_outline</mat-icon>
<span>No additional-share requests are waiting for approval.</span>
<span>
@if (allRequests.length > 0) {
No requests match "{{ searchTerm }}".
} @else {
No additional-share requests are waiting for approval.
}
</span>
</div>
} @else {
<div class="sar-table-wrap">
@@ -53,7 +90,7 @@
<td class="ta-right amount-cell">{{ req.amount | number: '1.2-2' }}</td>
<td class="ta-left">{{ fundingLabel(req) }}</td>
<td class="ta-left">{{ req.resolutionNumber || '—' }}</td>
<td class="ta-left">{{ req.transactionDate }}</td>
<td class="ta-left sar-dt">{{ req.transactionDate }}</td>
<td class="ta-center sar-actions" (click)="$event.stopPropagation()">
<button
mat-stroked-button
@@ -6,32 +6,189 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #1a3a6b;
$border: #d8e0ec;
$muted: #6b7a90;
:host {
display: block;
}
$muted: #64748b;
$border: #e2e8f0;
$danger: #b3261e;
.sar-container {
padding: 8px 0;
padding: 0;
}
.sar-intro {
// ── Title bar — mirrors WithdrawalRequestsComponent's action bar ──────────
.sar-action-bar {
display: flex;
align-items: center;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid $border;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #0f4f3c;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: $muted;
margin-top: 2px;
}
}
// ── Filter bar ──────────────────────────────────────────────────────────
.sar-filter-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
background: #f8fafc;
border-bottom: 1px solid $border;
flex-wrap: wrap;
}
.sar-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
min-width: 260px;
padding: 0 10px;
border: 1px solid $border;
border-radius: 8px;
background: #fff;
transition: border-color 0.15s;
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
flex-shrink: 0;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: transparent;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: none;
cursor: pointer;
color: #94a3b8;
padding: 0;
border-radius: 50%;
flex-shrink: 0;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.sar-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid $border;
transition: background 0.15s;
height: 32px;
padding: 0 12px;
font-size: 13px;
color: $muted;
margin: 0 0 14px;
background: #fff;
color: #0d1b2a;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--refresh {
padding: 0 8px;
color: $muted;
&:hover:not(:disabled) {
background: #f1f5f9;
}
}
}
.sar-loading,
.sar-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px;
justify-content: center;
gap: 8px;
padding: 48px 24px;
font-size: 13px;
color: $muted;
background: #f4f7fb;
border: 1px solid $border;
border-radius: 6px;
text-align: center;
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: #cbd5e1;
}
}
.sar-error {
@@ -44,53 +201,50 @@ $danger: #b3261e;
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
margin-bottom: 12px;
margin: 16px 20px 0;
}
.sar-table-wrap {
overflow-x: auto;
padding: 0 20px;
}
.sar-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
border: 1px solid $border;
border-radius: 6px;
th {
background: #eef2f8;
color: $primary;
background: #f8fafc;
color: $muted;
font-size: 11px;
font-weight: 600;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 9px 12px;
letter-spacing: 0.06em;
padding: 10px 14px;
white-space: nowrap;
}
td {
padding: 10px 12px;
border-bottom: 1px solid #eceff4;
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
color: #1f2937;
white-space: nowrap;
}
tbody tr {
cursor: pointer;
transition: background 0.1s;
&:hover {
background: #f4f7fb;
&:hover td {
background: #f0fdf4;
}
}
tbody tr:last-child td {
border-bottom: none;
}
}
.mono {
font-family: 'Courier New', monospace;
font-size: 12px;
}
.ta-left {
@@ -107,7 +261,13 @@ $danger: #b3261e;
.amount-cell {
font-variant-numeric: tabular-nums;
font-weight: 500;
font-weight: 700;
color: #0f4f3c;
}
.sar-dt {
font-size: 12px;
color: $muted;
}
.sar-actions {
@@ -8,6 +8,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@@ -36,6 +37,7 @@ import { ShareAdditionRequestDetailDialogComponent } from './share-addition-requ
standalone: true,
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
@@ -49,8 +51,12 @@ export class ShareAdditionRequestsComponent implements OnInit {
loading = true;
errorMsg = '';
/** Raw, unfiltered list from the API -- search happens client-side over this since the endpoint
* has no server-side filtering (it always returns every pending request in one shot). */
allRequests: any[] = [];
requests: any[] = [];
actioningId: number | null = null;
searchTerm = '';
ngOnInit(): void {
this.load();
@@ -68,11 +74,21 @@ export class ShareAdditionRequestsComponent implements OnInit {
})
)
.subscribe((res: any) => {
this.requests = Array.isArray(res) ? res : [];
this.allRequests = Array.isArray(res) ? res : [];
this.applyFilter();
this.loading = false;
});
}
applyFilter(): void {
const needle = this.searchTerm.trim().toLowerCase();
this.requests = !needle
? this.allRequests
: this.allRequests.filter((req) =>
`${req.clientName ?? ''} ${req.accountNo ?? ''} ${req.resolutionNumber ?? ''}`.toLowerCase().includes(needle)
);
}
/** How many receipts fund a cash tranche — the lead one plus any further instalments. */
voucherCount(req: any): number {
if (!req?.advanceCollectionId) {
@@ -6,69 +6,160 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg-card">
<div class="pos-section-label">Share Withdrawal Requests — Review, Approve or Reject</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
</label>
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="loading" (click)="load()">Refresh</button>
</label>
<div class="swr-tab">
<!-- ── TITLE BAR ── -->
<div class="swr-action-bar">
<div class="swr-action-bar__left">
<mat-icon class="swr-action-bar__icon">redeem</mat-icon>
<div>
<div class="swr-action-bar__title">Share Withdrawal Requests</div>
<div class="swr-action-bar__sub">Review, approve or reject clerk share (redemption) withdrawal requests.</div>
</div>
</div>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Client</th>
<th class="num">Shares</th>
<th>Resolution No.</th>
<th>Meeting Date</th>
<th>Clerk</th>
<th>Manager</th>
<th>Submitted At</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="10" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && requests.length === 0) {
<tr>
<td colspan="10" class="pg-empty">No {{ filterStatus | lowercase }} share withdrawal requests.</td>
</tr>
} @else {
@for (r of requests; track r.id) {
<tr>
<td>
<span class="pg-chip">{{ r.account_no }}</span>
</td>
<td>{{ r.client_name }}</td>
<td class="num">{{ r.requested_shares }}</td>
<td>{{ r.resolution_number }}</td>
<td>{{ formatDateTime(r.meeting_date) }}</td>
<td>{{ r.clerk_name }}</td>
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
<td>{{ formatDateTime(r.submitted_at) }}</td>
<td>
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="review(r)">Review</button>
</td>
</tr>
}
<!-- ── FILTER BAR ── -->
<div class="swr-filter-bar">
<div class="swr-search-box">
<mat-icon>search</mat-icon>
<input
type="text"
placeholder="Search by account, client or resolution no"
[(ngModel)]="searchTerm"
(ngModelChange)="onSearchChange()"
/>
@if (searchTerm) {
<button type="button" class="swr-search-box__clear" (click)="searchTerm = ''; onSearchChange()">
<mat-icon>close</mat-icon>
</button>
}
</tbody>
</table>
</div>
<span class="swr-fb-sep"></span>
<select class="swr-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<span class="swr-fb-sep"></span>
<div class="swr-presets">
@for (p of datePresets; track p.key) {
<button
type="button"
class="swr-preset-chip"
[class.swr-preset-chip--active]="activePreset === p.key"
(click)="applyPreset(p.key)"
>
{{ p.label }}
</button>
}
</div>
<span class="swr-fb-sep"></span>
<div class="swr-date-range">
<div class="swr-date-box">
<span class="swr-date-box__lbl">From</span>
<input
class="swr-date-box__input"
[matDatepicker]="fromPicker"
[(ngModel)]="fromDate"
[max]="maxDate"
(dateChange)="onFromDateChange()"
readonly
/>
<button type="button" class="swr-date-box__btn" (click)="fromPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #fromPicker></mat-datepicker>
</div>
<span class="swr-date-range__sep">—</span>
<div class="swr-date-box">
<span class="swr-date-box__lbl">To</span>
<input
class="swr-date-box__input"
[matDatepicker]="toPicker"
[(ngModel)]="toDate"
[min]="fromDate"
[max]="maxDate"
(dateChange)="onToDateChange()"
readonly
/>
<button type="button" class="swr-date-box__btn" (click)="toPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #toPicker></mat-datepicker>
</div>
</div>
<button type="button" class="swr-btn swr-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading) {
<div class="empty-state">
<p class="empty-title">Loading…</p>
</div>
} @else if (loaded && requests.length === 0) {
<div class="empty-state">
<i class="fas fa-inbox empty-icon"></i>
<p class="empty-title">No share withdrawal requests</p>
<p class="empty-text">
@if (filterStatus === 'PENDING') {
There are no pending share withdrawal requests waiting for review.
} @else {
No {{ filterStatus | lowercase }} share withdrawal requests found.
}
</p>
</div>
} @else {
<div class="swr-table-wrap">
<table class="swr-table">
<thead>
<tr>
<th>Account No</th>
<th>Client</th>
<th class="num">Shares</th>
<th>Resolution No.</th>
<th>Meeting Date</th>
<th>Clerk</th>
<th>Manager</th>
<th>Submitted At</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of requests; track r.id) {
<tr class="swr-row" (click)="review(r)">
<td class="cell-mono">{{ r.account_no }}</td>
<td class="swr-client">{{ r.client_name }}</td>
<td class="num">{{ r.requested_shares }}</td>
<td>{{ r.resolution_number }}</td>
<td class="swr-dt">{{ formatDateTime(r.meeting_date) }}</td>
<td>{{ r.clerk_name }}</td>
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
<td class="swr-dt">{{ formatDateTime(r.submitted_at) }}</td>
<td>
<span class="swr-status" [ngClass]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
<td (click)="$event.stopPropagation()">
<button type="button" class="swr-btn swr-btn--review" (click)="review(r)">
<mat-icon>rate_review</mat-icon>
Review
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@if (showReviewModal && reviewRequestId) {
@@ -6,6 +6,430 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
}
.swr-tab {
padding: 0;
}
// ── Title bar — mirrors WithdrawalRequestsComponent's action bar ──────────
.swr-action-bar {
display: flex;
align-items: center;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #0f4f3c;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
}
// ── Filter bar ──────────────────────────────────────────────────────────
.swr-filter-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.swr-fb-sep {
width: 1px;
height: 22px;
background: #e2e8f0;
flex-shrink: 0;
margin: 0 4px;
}
.swr-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
min-width: 260px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
transition: border-color 0.15s;
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
flex-shrink: 0;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: transparent;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: none;
cursor: pointer;
color: #94a3b8;
padding: 0;
border-radius: 50%;
flex-shrink: 0;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.swr-filter-select {
height: 34px;
min-width: 130px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
.swr-presets {
display: inline-flex;
gap: 4px;
align-items: center;
}
.swr-preset-chip {
height: 28px;
padding: 0 12px;
border-radius: 14px;
border: 1px solid #e2e8f0;
background: #fff;
font-size: 12px;
font-weight: 600;
font-family: inherit;
color: #475569;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
&:hover {
border-color: #0f4f3c;
color: #0f4f3c;
background: #f0fdf4;
}
&--active {
background: #0f4f3c;
border-color: #0f4f3c;
color: #fff;
&:hover {
background: #0a3d2e;
}
}
}
.swr-date-range {
display: inline-flex;
align-items: center;
gap: 4px;
&__sep {
color: #94a3b8;
font-size: 16px;
font-weight: 300;
padding: 0 2px;
user-select: none;
}
}
.swr-date-box {
display: inline-flex;
align-items: center;
gap: 4px;
height: 34px;
padding: 0 6px 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
transition: border-color 0.15s;
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
}
&__lbl {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
white-space: nowrap;
user-select: none;
letter-spacing: 0.03em;
}
&__input {
border: none;
outline: none;
font-size: 13px;
width: 76px;
color: #0d1b2a;
font-family: inherit;
background: transparent;
cursor: pointer;
&::placeholder {
color: #cbd5e1;
}
}
&__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
background: none;
cursor: pointer;
color: #94a3b8;
padding: 0;
border-radius: 4px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
color: #0f4f3c;
background: #f0fdf4;
}
}
}
.swr-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 32px;
padding: 0 12px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--refresh {
padding: 0 8px;
color: #64748b;
&:hover:not(:disabled) {
background: #f1f5f9;
}
}
&--review {
background: #0f4f3c;
color: #fff;
border-color: #0f4f3c;
&:hover {
background: #0a3d2e;
}
}
}
.swr-table-wrap {
overflow-x: auto;
}
.swr-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #f1f5f9;
}
th {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
background: #f8fafc;
white-space: nowrap;
}
.num {
text-align: right;
}
}
.swr-row {
cursor: pointer;
transition: background 0.1s;
&:hover td {
background: #f0fdf4;
}
}
.swr-client {
font-weight: 500;
}
.swr-dt {
font-size: 12px;
color: #64748b;
white-space: nowrap;
}
.swr-status {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
&--pending {
background: #fef3c7;
color: #92400e;
}
&--approved {
background: #dcfce7;
color: #166534;
}
&--rejected {
background: #fee2e2;
color: #991b1b;
}
}
.cell-mono {
font-family: 'Courier New', monospace;
font-size: 12px;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: #94a3b8;
.empty-icon {
font-size: 40px;
color: #cbd5e1;
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: #64748b;
margin: 12px 0 4px;
}
.empty-text {
font-size: 13px;
color: #94a3b8;
margin: 0;
}
}
// ── Review popup (unchanged) ────────────────────────────────────────────
@keyframes swr-list-popup-fade {
from {
opacity: 0;
@@ -9,11 +9,15 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatNativeDateModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
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';
import { SettingsService } from '../../../settings/settings.service';
/** Approval Center — Share Withdrawal Requests tab. A Bank Manager (checker) reviews, approves
* or rejects share withdrawal requests submitted from the Clerk Dashboard. Mirrors
@@ -32,11 +36,15 @@ import { ShareWithdrawalRequestComponent } from '../../../clerk/share-withdrawal
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatNativeDateModule,
MatDatepickerModule,
ShareWithdrawalRequestComponent
]
})
export class ShareWithdrawalRequestsComponent implements OnInit {
private service = inject(ClerkShareWithdrawalService);
private settingsService = inject(SettingsService);
requests: any[] = [];
totalRecords = 0;
@@ -44,10 +52,22 @@ export class ShareWithdrawalRequestsComponent implements OnInit {
loaded = false;
filterStatus = 'PENDING';
searchTerm = '';
fromDate: Date | null = null;
toDate: Date | null = null;
maxDate!: Date;
activePreset = 'all';
showReviewModal = false;
reviewRequestId: number | null = null;
readonly datePresets = [
{ key: 'today', label: 'Today' },
{ key: 'week', label: 'This Week' },
{ key: 'month', label: 'This Month' },
{ key: 'all', label: 'All' }
];
get statusOptions(): { value: string; label: string }[] {
return [
{ value: 'PENDING', label: 'Pending' },
@@ -57,13 +77,23 @@ export class ShareWithdrawalRequestsComponent implements OnInit {
}
ngOnInit(): void {
const today = new Date(this.settingsService.businessDate);
today.setHours(0, 0, 0, 0);
this.maxDate = today;
this.load();
}
load(): void {
this.loading = true;
this.service
.getPendingRequests(this.filterStatus, 0, 50)
.getPendingRequests(
this.filterStatus,
0,
50,
this.isoDate(this.fromDate),
this.isoDate(this.toDate),
this.searchTerm.trim() || undefined
)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.requests = res?.pageItems ?? [];
@@ -77,6 +107,53 @@ export class ShareWithdrawalRequestsComponent implements OnInit {
this.load();
}
onSearchChange(): void {
this.load();
}
applyPreset(key: string): void {
this.activePreset = key;
const today = new Date(this.settingsService.businessDate);
today.setHours(0, 0, 0, 0);
if (key === 'today') {
this.fromDate = new Date(today);
this.toDate = new Date(today);
} else if (key === 'week') {
const start = new Date(today);
start.setDate(today.getDate() - today.getDay());
this.fromDate = start;
this.toDate = new Date(today);
} else if (key === 'month') {
this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1);
this.toDate = new Date(today);
} else {
this.fromDate = null;
this.toDate = null;
}
this.load();
}
onFromDateChange(): void {
this.activePreset = '';
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
this.toDate = new Date(this.fromDate);
}
this.load();
}
onToDateChange(): void {
this.activePreset = '';
this.load();
}
private isoDate(d: Date | null): string | undefined {
if (!d) return undefined;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
review(req: any): void {
this.reviewRequestId = req.id;
this.showReviewModal = true;
@@ -94,11 +171,11 @@ export class ShareWithdrawalRequestsComponent implements OnInit {
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'inv-badge--active',
APPROVED: 'inv-badge--matured',
REJECTED: 'inv-badge--written_off'
PENDING: 'swr-status--pending',
APPROVED: 'swr-status--approved',
REJECTED: 'swr-status--rejected'
};
return `inv-badge ${map[status] ?? ''}`;
return map[status] ?? '';
}
statusLabel(status: string): string {
@@ -20,6 +20,23 @@
<!-- ── FILTER BAR ── -->
<div class="wri-filter-bar">
<div class="wri-search-box">
<mat-icon>search</mat-icon>
<input
type="text"
placeholder="Search by account no, client, voucher or token"
[(ngModel)]="searchTerm"
(ngModelChange)="onSearchChange()"
/>
@if (searchTerm) {
<button type="button" class="wri-search-box__clear" (click)="searchTerm = ''; onSearchChange()">
<mat-icon>close</mat-icon>
</button>
}
</div>
<span class="wri-fb-sep"></span>
<select class="wri-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
@@ -65,6 +65,77 @@
margin: 0 4px;
}
.wri-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
min-width: 260px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
transition: border-color 0.15s;
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
flex-shrink: 0;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: transparent;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: none;
cursor: pointer;
color: #94a3b8;
padding: 0;
border-radius: 50%;
flex-shrink: 0;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.wri-filter-label {
display: flex;
flex-direction: column;
@@ -64,6 +64,8 @@ export class WithdrawalRequestsComponent implements OnInit {
activePreset: string = 'today';
loading = false;
loaded = false;
searchTerm = '';
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
readonly datePresets = [
{ key: 'today', label: 'Today' },
@@ -96,7 +98,8 @@ export class WithdrawalRequestsComponent implements OnInit {
this.pageIndex * this.pageSize,
this.pageSize,
this.isoDate(this.fromDate),
this.isoDate(this.toDate)
this.isoDate(this.toDate),
this.searchTerm.trim() || undefined
)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
@@ -107,6 +110,16 @@ export class WithdrawalRequestsComponent implements OnInit {
});
}
onSearchChange(): void {
if (this.searchDebounce) {
clearTimeout(this.searchDebounce);
}
this.searchDebounce = setTimeout(() => {
this.pageIndex = 0;
this.load();
}, 300);
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;