Compare commits

...
2 Commits
Author SHA1 Message Date
nitesh 9739ff0758 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.
2026-09-01 07:43:07 +05:30
niteshandClaude Sonnet 5 7a2cb25538 feat(savings,shares): bidirectional savings/share account linking, new Migrate Cash Denomination screen
- Savings account creation shows a required "Default Share Account" picker once Share Linking is
  enabled, populated from the client's active share accounts; submits shareAccountId alongside it.
- Fixes a real data-loss bug on Share Account creation: the Charges step re-seeded its table from
  the product's default charges on every ngOnChanges firing (e.g. stepping back to Details and
  forward again), silently discarding a charge the clerk had already added, edited, or removed - a
  pristine guard existed for exactly this but wasn't checked in ngOnChanges. Now it is.
- Removes the hard "required" on Share Account creation's Default Savings Account field, matching
  the backend now accepting a share account with no linked savings account.
- New /accounting/migrate-cash-denomination screen: counts a branch's opening cash into notes and
  coins, checks it against (or defines, if unset) the Cash GL account's balance, and opens the
  branch's first business day with that same count - the vault-side counterpart to Migrate Opening
  Balances, kept under Accounting rather than Branch Ops/Cashier so it isn't reachable by mistake.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:18:33 +05:30
39 changed files with 3762 additions and 527 deletions
@@ -25,6 +25,7 @@ import { CreateFinancialActivityMappingComponent } from './financial-activity-ma
import { ViewFinancialActivityMappingComponent } from './financial-activity-mappings/view-financial-activity-mapping/view-financial-activity-mapping.component';
import { EditFinancialActivityMappingComponent } from './financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component';
import { MigrateOpeningBalancesComponent } from './migrate-opening-balances/migrate-opening-balances.component';
import { MigrateCashDenominationComponent } from './migrate-cash-denomination/migrate-cash-denomination.component';
import { ChartOfAccountsComponent } from './chart-of-accounts/chart-of-accounts.component';
import { CreateGlAccountComponent } from './chart-of-accounts/create-gl-account/create-gl-account.component';
import { ViewGlAccountComponent } from './chart-of-accounts/view-gl-account/view-gl-account.component';
@@ -185,6 +186,14 @@ const routes: Routes = [
currencies: CurrenciesResolver
}
},
{
path: 'migrate-cash-denomination',
component: MigrateCashDenominationComponent,
data: { title: 'Migrate Cash Denomination', breadcrumb: 'Migrate Cash Denomination' },
resolve: {
offices: OfficesResolver
}
},
{
path: 'chart-of-accounts',
data: { title: 'Chart of Accounts', breadcrumb: 'Chart of Accounts' },
@@ -88,6 +88,22 @@
</div>
</a>
<!-- Migrate Cash Denomination -->
<a
class="acc-card"
[routerLink]="['migrate-cash-denomination']"
*mifosxHasPermission="'READ_JOURNALENTRY'"
#migrateCashDenomination
>
<div class="acc-card__icon amber"><i class="fas fa-coins"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">Migrate Cash Denomination</span>
<span class="acc-card__desc">
Count the vault opening cash into notes and coins, and open the first business day
</span>
</div>
</a>
<!-- Chart of Accounts -->
<a class="acc-card" [routerLink]="['chart-of-accounts']" *mifosxHasPermission="'READ_GLACCOUNT'" #chartofAccounts>
<div class="acc-card__icon blue"><i class="fas fa-sitemap"></i></div>
@@ -0,0 +1,167 @@
<!--
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/.
-->
<div class="container mcd">
<!-- Step 1: which branch's vault is being counted. -->
<mat-card class="mcd-card">
<mat-card-content>
<div class="mcd-intro">
<mat-icon>inventory_2</mat-icon>
<span>
Count the branch's opening cash into notes and coins. If the Cash GL account has no balance yet, this count
defines it; if it already carries one, the count must match it exactly. Either way, this also opens the
branch's first business day with the same count.
</span>
</div>
<div class="mcd-row">
<mat-form-field class="mcd-office">
<mat-label>Branch</mat-label>
<mat-select required [(ngModel)]="officeId" (selectionChange)="onOfficeChange()">
@for (office of officeData; track office) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="mcd-field" (click)="datePicker.open()">
<mat-label>Business Date</mat-label>
<input matInput [max]="maxDate" [matDatepicker]="datePicker" [(ngModel)]="businessDate" readonly />
<mat-datepicker-toggle matSuffix [for]="datePicker"></mat-datepicker-toggle>
<mat-datepicker #datePicker></mat-datepicker>
</mat-form-field>
</div>
</mat-card-content>
</mat-card>
@if (glLoading) {
<div class="mcd-loading">Checking the cash GL account…</div>
}
@if (glError) {
<div class="mcd-gl mcd-gl--error">
<mat-icon>error</mat-icon>
<div>{{ glError }}</div>
</div>
}
@if (glStatus && !glError) {
<!-- Step 2: what the ledger already says, so the count is counted against something concrete. -->
<div class="mcd-gl" [class.mcd-gl--fresh]="!glHasBalance">
<mat-icon>account_balance</mat-icon>
<div>
<div class="mcd-gl__label">Cash GL Account</div>
<div class="mcd-gl__value">({{ glStatus.glCode }}) {{ glStatus.glAccountName }}</div>
@if (glHasBalance) {
<div class="mcd-gl__hint">
Already holds
<strong>₹{{ glTarget.toLocaleString('en-IN') }}</strong>
— count the drawer to exactly this amount.
</div>
} @else {
<div class="mcd-gl__hint">
No opening balance posted yet — whatever you count below becomes this account's opening balance.
</div>
}
</div>
</div>
<!-- Step 3: the actual count. -->
<mat-card class="mcd-card mcd-card--flush">
<mat-card-content>
<div class="mcd-grid mcd-grid--head">
<div class="mcd-grid__denom">Denomination</div>
<div class="mcd-grid__count">Count</div>
<div class="mcd-grid__amt">Amount</div>
</div>
@for (d of allDenoms; track d.id) {
<div class="mcd-grid mcd-grid--row">
<div class="mcd-grid__denom">
{{ d.label }} <span class="mcd-grid__kind">{{ d.kind }}</span>
</div>
<div class="mcd-grid__count">
<button type="button" mat-icon-button (click)="bumpCount(d, -1)"><mat-icon>remove</mat-icon></button>
<input
type="number"
min="0"
class="mcd-grid__input"
[ngModel]="countFor(d)"
(ngModelChange)="onCountChange(d, $event)"
/>
<button type="button" mat-icon-button (click)="bumpCount(d, 1)"><mat-icon>add</mat-icon></button>
</div>
<div class="mcd-grid__amt">₹{{ subtotalFor(d).toLocaleString('en-IN') }}</div>
</div>
}
</mat-card-content>
</mat-card>
<!-- The one number that matters. -->
<div class="mcd-balance" [class.mcd-balance--ok]="isReconciled">
<div class="mcd-balance__figure">
<span class="mcd-balance__label">Counted Total</span>
<span class="mcd-balance__amt">₹{{ countedTotal.toLocaleString('en-IN') }}</span>
</div>
@if (glHasBalance) {
<div class="mcd-balance__figure">
<span class="mcd-balance__label">GL Target</span>
<span class="mcd-balance__amt">₹{{ glTarget.toLocaleString('en-IN') }}</span>
</div>
}
<div class="mcd-balance__status">
@if (countedTotal <= 0) {
<mat-icon>edit_note</mat-icon>
<span>Count each denomination above</span>
} @else if (isReconciled) {
<mat-icon>check_circle</mat-icon>
<span>{{ glHasBalance ? 'Matches the GL balance' : 'Will define the GL opening balance' }}</span>
} @else {
<mat-icon>error</mat-icon>
<span
>Off by ₹{{
(countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString('en-IN')
}}</span
>
}
</div>
</div>
@if (needsDifferenceReason) {
<mat-card class="mcd-card">
<mat-card-content>
<mat-form-field class="mcd-reason">
<mat-label>Why does the count differ from the GL balance?</mat-label>
<input matInput [(ngModel)]="differenceReason" />
</mat-form-field>
</mat-card-content>
</mat-card>
}
@if (errorMsg) {
<div class="mcd-error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="mcd-success">
<mat-icon>check_circle</mat-icon>
<div>
{{ successMsg }}
<div class="mcd-success__actions">
<button type="button" mat-stroked-button (click)="goToBranchDay()">Go to Day Register</button>
</div>
</div>
</div>
}
@if (!successMsg) {
<div class="mcd-actions">
<button mat-raised-button color="primary" [disabled]="!canSubmit" (click)="submit()">
{{ submitting ? 'Submitting…' : 'Migrate & Open Business Day' }}
</button>
</div>
}
}
</div>
@@ -0,0 +1,308 @@
/**
* 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/.
*/
.mcd {
max-width: 760px;
}
.mcd-card {
margin-bottom: 14px;
border-radius: 8px;
&--flush mat-card-content {
padding: 0;
}
}
.mcd-intro {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
line-height: 1.5;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: none;
}
}
.mcd-row {
display: flex;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.mcd-office {
flex: 1 1 280px;
}
.mcd-field {
flex: 1 1 200px;
}
.mcd-loading {
padding: 10px 14px;
margin-bottom: 14px;
font-size: 12px;
color: #64748b;
}
.mcd-gl {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 14px;
margin-bottom: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-left: 4px solid #0ea5e9;
border-radius: 8px;
mat-icon {
color: #0ea5e9;
margin-top: 2px;
}
&--fresh {
border-left-color: #f59e0b;
mat-icon {
color: #f59e0b;
}
}
&--error {
border-left-color: #dc2626;
background: #fef2f2;
mat-icon {
color: #dc2626;
}
}
&__label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #64748b;
}
&__value {
font-size: 13px;
font-weight: 600;
color: #0d1b2a;
margin: 2px 0 4px;
}
&__hint {
font-size: 11.5px;
color: #64748b;
line-height: 1.4;
}
}
.mcd-grid {
display: grid;
grid-template-columns: 1fr 160px 120px;
align-items: center;
gap: 8px;
padding: 10px 14px;
&--head {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #64748b;
}
&--row {
border-bottom: 1px solid #f1f5f9;
}
&__denom {
font-size: 13px;
font-weight: 600;
color: #0d1b2a;
}
&__kind {
font-size: 10px;
font-weight: 500;
color: #94a3b8;
margin-left: 6px;
text-transform: uppercase;
}
&__count {
display: flex;
align-items: center;
gap: 2px;
button {
width: 28px;
height: 28px;
line-height: 28px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
&__input {
width: 48px;
text-align: center;
font-variant-numeric: tabular-nums;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 4px 2px;
font-size: 13px;
}
&__amt {
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 13px;
text-align: right;
color: #0d1b2a;
}
}
.mcd-balance {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
padding: 12px 16px;
margin-bottom: 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 8px;
&--ok {
background: #f0fdf4;
border-color: #bbf7d0;
border-left-color: #16a34a;
}
&__figure {
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #64748b;
}
&__amt {
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 16px;
white-space: nowrap;
color: #0d1b2a;
}
&__status {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 13px;
font-weight: 600;
color: #b91c1c;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
&--ok &__status {
color: #15803d;
}
}
.mcd-reason {
width: 100%;
}
.mcd-error {
padding: 10px 14px;
margin-bottom: 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12.5px;
}
.mcd-success {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px 16px;
margin-bottom: 14px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
color: #14532d;
font-size: 13px;
mat-icon {
color: #16a34a;
margin-top: 1px;
}
&__actions {
margin-top: 10px;
}
}
.mcd-actions {
display: flex;
justify-content: flex-end;
padding-bottom: 24px;
}
@media (width <= 560px) {
.mcd-grid {
grid-template-columns: 1fr auto;
row-gap: 4px;
&--head {
display: none;
}
&__amt {
grid-column: 1 / -1;
text-align: left;
}
}
}
@@ -0,0 +1,237 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../../cashier/cashier.service';
import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
interface Denom {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
/**
* Migrate Cash Denomination — the vault-side counterpart to Migrate Opening Balances.
*
* A freshly set-up institution's chart of accounts carries a "Cash at Vault" GL balance (defined via
* Migrate Opening Balances, or not yet defined at all), but that figure is meaningless to a cashier
* standing at the drawer until it is broken into actual notes and coins, and the branch has nowhere
* to track that breakdown until its first business day is open. This screen is the one place both of
* those happen together: count the drawer, and it either defines the cash GL's opening balance from
* that count (nothing posted to it yet) or checks the count against what is already posted, then
* opens the business day with that same breakdown - so a new bank goes from "GL seeded" straight to
* "vault countable" without a separate trip through Manage Tellers or Open Business Day first.
*/
@Component({
selector: 'mifosx-migrate-cash-denomination',
templateUrl: './migrate-cash-denomination.component.html',
styleUrls: ['./migrate-cash-denomination.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
...STANDALONE_SHARED_IMPORTS
]
})
export class MigrateCashDenominationComponent implements OnInit {
private service = inject(CashierService);
private settingsService = inject(SettingsService);
private route = inject(ActivatedRoute);
private router = inject(Router);
officeData: any[] = [];
officeId: number | null = null;
/** Bound to the datepicker as a Date, converted to a strict yyyy-MM-dd string only when
* submitting — the branchday endpoints parse this with LocalDate.parse() directly, with no
* locale/dateFormat hint alongside it, so a locale-formatted string (what dateUtils.formatDate
* produces) is rejected outright. */
businessDate: Date = new Date();
maxDate = new Date();
glLoading = false;
glStatus: any = null;
glError = '';
notes: Denom[] = [];
coins: Denom[] = [];
counts: Record<number, number> = {};
submitting = false;
errorMsg = '';
successMsg = '';
result: any = null;
/** Only asked for when the counted total disagrees with an already-posted GL balance — mirrors
* the same "explain the gap" gate Open Business Day applies. */
differenceReason = '';
get allDenoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.businessDate = this.maxDate;
this.route.data.subscribe((data: { offices: any }) => {
this.officeData = data.offices || [];
});
this.service.getActiveDenominations().subscribe({
next: (list) => {
const rows: Denom[] = (list || []).map((x: any) => ({
id: x.id,
value: x.denomination,
kind: x.kind,
label: '₹' + x.denomination
}));
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
},
error: () => {
this.notes = [];
this.coins = [];
}
});
}
onOfficeChange(): void {
this.glStatus = null;
this.glError = '';
this.result = null;
this.successMsg = '';
this.errorMsg = '';
if (!this.officeId) return;
this.glLoading = true;
this.service
.cashGlStatus(this.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.glLoading = false;
this.glStatus = res;
if (res && res.configured === false) {
this.glError = res.message || 'No cash GL account is configured for this tenant yet.';
}
});
}
countFor(d: Denom): number {
return this.counts[d.id] || 0;
}
subtotalFor(d: Denom): number {
return this.countFor(d) * d.value;
}
onCountChange(d: Denom, raw: any): void {
this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
}
bumpCount(d: Denom, delta: number): void {
this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
}
get countedTotal(): number {
return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0);
}
/** Whether this office's GL already carries a balance the count has to match, vs. an empty
* account the count itself will define. */
get glHasBalance(): boolean {
return !!this.glStatus?.hasBalance;
}
get glTarget(): number {
return Number(this.glStatus?.currentBalance ?? 0);
}
get isReconciled(): boolean {
if (!this.glStatus || this.glStatus.configured === false) return false;
if (this.countedTotal <= 0) return false;
return !this.glHasBalance || this.countedTotal === this.glTarget;
}
get needsDifferenceReason(): boolean {
return (
this.glHasBalance && this.countedTotal > 0 && this.countedTotal !== this.glTarget && !this.differenceReason.trim()
);
}
get canSubmit(): boolean {
return (
!!this.officeId &&
!!this.businessDate &&
!this.glError &&
this.countedTotal > 0 &&
!this.needsDifferenceReason &&
!this.submitting
);
}
private denominationPayload() {
return this.allDenoms
.filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }));
}
/** Strict yyyy-MM-dd, independent of the viewer's locale/date-format setting — see the comment on
* `businessDate` for why this can't go through dateUtils.formatDate(). */
private toIsoDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
submit(): void {
if (!this.canSubmit || !this.officeId) return;
this.submitting = true;
this.errorMsg = '';
this.successMsg = '';
this.service
.migrateCashDenomination(
this.officeId,
this.toIsoDate(this.businessDate),
this.denominationPayload(),
this.differenceReason.trim() || undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.message ??
'Could not migrate cash denomination.';
return of(null);
})
)
.subscribe((res: any) => {
this.submitting = false;
if (res) {
this.result = res;
this.successMsg = res.glOpeningBalancePosted
? `Cash GL opening balance defined at ₹${this.countedTotal.toLocaleString('en-IN')} and business day ${res.businessDate} opened.`
: `Business day ${res.businessDate} opened — count matched the existing GL balance.`;
}
});
}
goToBranchDay(): void {
this.router.navigate(['/branch-ops/day-register']);
}
}
+25
View File
@@ -304,6 +304,31 @@ export class CashierService {
return this.http.get('/branchday/denominations', { params });
}
/** What the office's cash-at-vault GL account currently holds — zero means the count itself will
* define its opening balance; non-zero means the count must match it exactly. */
cashGlStatus(officeId?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/branchday/cash-gl-status', { params });
}
/** One-step new-institution path: defines (or reconciles against) the cash GL's opening balance
* from a counted denomination breakdown, then opens the business day with that same count. */
migrateCashDenomination(
officeId: number,
businessDate: string,
denominations: { denominationId: number; denominationValue: number; count: number }[],
openingDifferenceReason?: string
): Observable<any> {
return this.http.post('/branchday/migrate-cash-denomination', {
officeId,
businessDate,
denominations,
openingDifferenceReason: openingDifferenceReason ?? null,
locale: 'en'
});
}
/** Denomination-wise stock a vault (teller) currently holds — "how much of what does the bank have". */
vaultPosition(tellerId?: number, asOfDate?: string): Observable<any> {
let params = new HttpParams();
@@ -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 });
}
@@ -146,6 +146,13 @@ export class CreateSavingsAccountComponent {
});
return;
}
if (detailsStep.shareLinkingEnabled && !detailsStep.shareAccountId) {
this.alertService.alert({
type: 'Error',
message: 'Share Linking is enabled — please select the share account to link before submitting.'
});
return;
}
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const monthDayFormat = 'dd MMMM';
@@ -208,6 +215,14 @@ export class CreateSavingsAccountComponent {
type: 'Error',
message: 'An account with this External ID already exists. Please use a unique External ID.'
});
} else if (
errorCode === 'error.msg.savingsaccount.share.account.required' ||
errorCode === 'error.msg.savingsaccount.share.account.not.active.for.client'
) {
this.alertService.alert({
type: 'Error',
message: 'Please select an active share account belonging to this client before submitting.'
});
}
}
});
@@ -126,6 +126,28 @@
<mat-option [value]="false">No</mat-option>
</mat-select>
</mat-form-field>
@if (shareLinkingEnabled) {
<mat-form-field appearance="outline" class="sad-field">
<mat-label>Default Share Account</mat-label>
<mat-select
required
[(ngModel)]="shareAccountId"
[ngModelOptions]="{ standalone: true }"
[disabled]="readOnly"
>
@for (account of activeShareAccounts; track account.id) {
<mat-option [value]="account.id"> {{ account.accountNo }} - {{ account.productName }} </mat-option>
}
</mat-select>
@if (!shareAccountId) {
<mat-error>Default Share Account is <strong>required</strong></mat-error>
}
@if (activeShareAccounts.length === 0) {
<mat-hint>This client has no active share account. Open one first, or turn off Share Linking.</mat-hint>
}
</mat-form-field>
}
</div>
</div>
@@ -78,6 +78,11 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
productTemplate: any = null;
shareLinkingEnabled = false;
/** The client's active share accounts, offered as the "Default Share Account" once Share Linking
* is enabled — the reverse of the "Default Savings Account" picker Share Account creation
* already shows. Fetched once for the client this form is for, since it never changes mid-form. */
activeShareAccounts: any[] = [];
shareAccountId: number | null = null;
accountTypeOptions: string[] = [];
customerSegmentOptions: string[] = [];
selectedAccountType: string | null = null;
@@ -209,6 +214,10 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
const clientId = this.savingsAccountTemplate?.clientId;
if (clientId) {
this.primaryClientId = clientId;
this.savingsService.getClientActiveShareAccounts(clientId).subscribe({
next: (accounts) => (this.activeShareAccounts = accounts || []),
error: () => (this.activeShareAccounts = [])
});
this.clientsService.getClientData(String(clientId)).subscribe((client: any) => {
this.primaryClientName = client?.displayName ?? client?.fullname ?? '';
this.primaryClientCif = client?.cifNumber ?? client?.cifNo ?? '';
@@ -450,6 +459,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
if (ea.shareLinkingEnabled !== undefined) {
this.shareLinkingEnabled = !!ea.shareLinkingEnabled;
}
if (ea.shareAccountId) {
this.shareAccountId = ea.shareAccountId;
}
this.cdr.detectChanges();
}
@@ -669,6 +681,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
}
value.shareLinkingEnabled = this.shareLinkingEnabled;
if (this.shareLinkingEnabled && this.shareAccountId) {
value.shareAccountId = this.shareAccountId;
}
return value;
}
@@ -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>
+6
View File
@@ -181,6 +181,12 @@ export class SavingsService {
return this.http.get('/savingsaccounts/template', { params: httpParams });
}
/** A client's active share accounts — for the "Default Share Account" picker shown once Share
* Linking is enabled on a savings account being created. */
getClientActiveShareAccounts(clientId: number): Observable<any[]> {
return this.http.get<any[]>(`/clients/${clientId}/activeshareaccounts`);
}
/**
* @param {any} savingsAccount Savings Account
* @returns {Observable<any>}
@@ -111,7 +111,14 @@ export class SharesAccountChargesStepComponent implements OnInit, OnChanges {
ngOnChanges() {
if (this.sharesAccountProductTemplate) {
this.chargeData = this.sharesAccountTemplate.chargeOptions;
this.chargesDataSource = this.sharesAccountProductTemplate.charges;
// Re-seeding from the product's own charges is only correct the first time this template
// arrives (picking the product). The productId control's valueChanges fires again on any
// later patchValue with the same id - stepping back to Details and forward again, for
// instance - and re-emits this same template; without this guard, that silently wipes
// whatever the user had already added, edited, or removed on this step.
if (this.pristine) {
this.chargesDataSource = this.sharesAccountProductTemplate.charges;
}
}
}
@@ -30,20 +30,13 @@
<span class="flex-48">{{ calculateCurrenValue() | currency: currency.code : 'symbol-narrow' : '1.2-2' }}</span>
}
<!-- No "None" option: the server rejects a share account without a linked savings account, and
offering the choice only produced a failure after the whole stepper was filled in. The link
is also where an over-payment at the counter is credited back to the member. -->
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Default Savings Account' | translate }}</mat-label>
<mat-select formControlName="savingsAccountId" required>
<mat-select formControlName="savingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id"> {{ account.accountNo }} - {{ account.savingsProductName }} </mat-option>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Default Savings Account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
@if (savingsAccountsData?.length === 0) {
<p class="sats-field-note flex-98">
@@ -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();
@@ -128,17 +148,7 @@ export class SharesAccountTermsStepComponent implements OnChanges, OnInit {
],
unitPrice: [{ value: '', disabled: true }],
currencyMultiple: [{ value: '', disabled: true }],
// Required to match the server, which has always rejected a share account without a linked
// savings account. The form allowed one through, so the failure surfaced as a validation
// error on submit after the whole stepper had been filled in.
//
// It also gives an over-payment somewhere to go: a member who hands over more cash than the
// application costs has the difference credited to this account, rather than being left in a
// suspense GL that nobody can attribute back to them.
savingsAccountId: [
'',
Validators.required
],
savingsAccountId: [''],
minimumActivePeriod: [''],
minimumActivePeriodFrequencyType: [''],
lockinPeriodFrequency: [''],
@@ -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;