Merge remote-tracking branch 'nexora/uate' into merge-uate-into-dev
# Conflicts: # src/app/core/shell/shell.component.html # src/app/tasks/tasks-routing.module.ts # src/environments/.env.ts
This commit is contained in:
@@ -69,6 +69,8 @@ import { DigitalModule } from './digital/digital.module';
|
||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||
import { AccountsModule } from './accounts/accounts.module';
|
||||
import { ElectionModule } from './election/election.module';
|
||||
import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module';
|
||||
import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module';
|
||||
import { PortalModule } from '@angular/cdk/portal';
|
||||
|
||||
/** Main Routing Module */
|
||||
@@ -174,6 +176,8 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
LetterMgmtModule,
|
||||
AccountsModule,
|
||||
ElectionModule,
|
||||
SponsorDDModule,
|
||||
SponsorDDReconciliationModule,
|
||||
AppRoutingModule,
|
||||
NotFoundComponent,
|
||||
CallbackComponent,
|
||||
|
||||
@@ -218,6 +218,26 @@
|
||||
<span class="mdr-detail__val cell-mono">{{ viewRequest.txn_id }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (viewRequest.dd_face_value != null) {
|
||||
<div class="mdr-detail">
|
||||
<span class="mdr-detail__lbl">DD Face Value</span>
|
||||
<span class="mdr-detail__val">₹{{ viewRequest.dd_face_value | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="mdr-detail">
|
||||
<span class="mdr-detail__lbl">Charges</span>
|
||||
<span class="mdr-detail__val">₹{{ viewRequest.dd_charges | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="mdr-detail">
|
||||
<span class="mdr-detail__lbl">GST ({{ viewRequest.dd_gst_percentage }}%)</span>
|
||||
<span class="mdr-detail__val">₹{{ viewRequest.dd_gst_amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="mdr-detail">
|
||||
<span class="mdr-detail__lbl">Total Collected</span>
|
||||
<span class="mdr-detail__val"
|
||||
><strong>₹{{ viewRequest.amount | number: '1.2-2' }}</strong></span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@if (viewRequest.description) {
|
||||
<div class="mdr-detail mdr-detail--wide">
|
||||
<span class="mdr-detail__lbl">Description</span>
|
||||
|
||||
@@ -70,6 +70,13 @@
|
||||
<span class="ws-header__title">Transaction Entry</span>
|
||||
</div>
|
||||
<div class="ws-header__right">
|
||||
<button type="button" class="ws-btn ws-btn--ghost" (click)="openCustomerSearch()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search Customer
|
||||
</button>
|
||||
<span class="ws-header__effdt">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
@@ -300,6 +307,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||
@if (isDdCashGlRow(row)) {
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||
<div class="ws-amt-wrap">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.ddCharges ?? ''"
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||
GST ({{ ddGstPercentage }}%)
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||
TOTAL TO COLLECT
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddTotalPayableFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -957,11 +1017,15 @@
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||
</svg>
|
||||
Denomination Entry
|
||||
@if (denominationTotal > 0) {
|
||||
<span class="te-mandate-badge">₹{{ denominationTotal | number: '1.2-2' }}</span>
|
||||
}
|
||||
@if (isCashPayment && denominationTotal <= 0) {
|
||||
<span class="te-denom-required-badge">⚠ Required for Cash</span>
|
||||
@if (isSelectedRowDdCashGl) {
|
||||
<span class="te-mandate-badge">Target ₹{{ ddTargetTotal | number: '1.2-2' }}</span>
|
||||
} @else {
|
||||
@if (denominationTotal > 0) {
|
||||
<span class="te-mandate-badge">₹{{ denominationTotal | number: '1.2-2' }}</span>
|
||||
}
|
||||
@if (isCashPayment && denominationTotal <= 0) {
|
||||
<span class="te-denom-required-badge">⚠ Required for Cash</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="te-panel__body">
|
||||
@@ -1011,7 +1075,26 @@
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@if (denominationTotal > 0) {
|
||||
@if (isSelectedRowDdCashGl) {
|
||||
<div class="te-denom-actions">
|
||||
@if (denominationTotal <= 0) {
|
||||
<span class="te-denom-required-badge"
|
||||
>⚠ Count notes/coins totalling ₹{{ ddTargetTotal | number: '1.2-2' }}</span
|
||||
>
|
||||
} @else if (ddDenomMatchesTarget()) {
|
||||
<span class="te-denom-match-badge"
|
||||
>✓ Matches Total To Collect ₹{{ ddTargetTotal | number: '1.2-2' }}</span
|
||||
>
|
||||
} @else {
|
||||
<span class="te-denom-required-badge">
|
||||
⚠ Must total ₹{{ ddTargetTotal | number: '1.2-2' }} (counted ₹{{
|
||||
denominationTotal | number: '1.2-2'
|
||||
}})
|
||||
</span>
|
||||
}
|
||||
<button class="te-btn" type="button" (click)="clearDenominations()">Clear</button>
|
||||
</div>
|
||||
} @else if (denominationTotal > 0) {
|
||||
<div class="te-denom-actions">
|
||||
<button class="te-btn te-btn--primary" type="button" (click)="applyDenominationTotal()">
|
||||
Apply ₹{{ denominationTotal | number: '1.2-2' }} to Amount
|
||||
@@ -1140,6 +1223,13 @@
|
||||
<span class="ws-header__title">Transaction Entry</span>
|
||||
</div>
|
||||
<div class="ws-header__right">
|
||||
<button type="button" class="ws-btn ws-btn--ghost" (click)="openCustomerSearch()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search Customer
|
||||
</button>
|
||||
<span class="ws-header__effdt">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
@@ -1325,6 +1415,58 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||
@if (isDdCashGlRow(row)) {
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||
<div class="ws-amt-wrap">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.ddCharges ?? ''"
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||
GST ({{ ddGstPercentage }}%)
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||
TOTAL TO COLLECT
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddTotalPayableFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1539,8 +1681,26 @@
|
||||
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
@if (confirmData.isDdCashGl) {
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">DD Face Value</span
|
||||
><span class="te-confirm-val"
|
||||
>{{ confirmData.symbol }}{{ confirmData.ddFaceValue | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">Charges</span
|
||||
><span class="te-confirm-val">{{ confirmData.symbol }}{{ confirmData.ddCharges | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="te-confirm-row">
|
||||
<span class="te-confirm-label">GST ({{ confirmData.ddGstPercentage }}%)</span
|
||||
><span class="te-confirm-val"
|
||||
>{{ confirmData.symbol }}{{ confirmData.ddGstAmount | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
<div class="te-confirm-row te-confirm-row--amount">
|
||||
<span class="te-confirm-label">Deposit Amount</span>
|
||||
<span class="te-confirm-label">{{ confirmData.isDdCashGl ? 'Total To Collect' : 'Deposit Amount' }}</span>
|
||||
<span class="te-confirm-val te-cr"
|
||||
>+{{ confirmData.symbol }}{{ confirmData.amount | number: '1.2-2' }}</span
|
||||
>
|
||||
|
||||
@@ -1928,6 +1928,19 @@ select.te-cell {
|
||||
animation: te-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// Denomination matches DD target badge
|
||||
.te-denom-match-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
border: 1px solid #86efac;
|
||||
}
|
||||
|
||||
@keyframes te-pulse {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
@@ -23,6 +23,11 @@ import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
||||
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
||||
import { CashierDepositService } from '../cashier-deposit.service';
|
||||
import { SettingsService } from '../../settings/settings.service';
|
||||
import {
|
||||
CustomerSearchDialogComponent,
|
||||
CustomerSearchResult
|
||||
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
|
||||
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
|
||||
|
||||
interface TxnRow {
|
||||
typ: string;
|
||||
@@ -40,6 +45,8 @@ interface TxnRow {
|
||||
paymentTypeId: number | null;
|
||||
bankName: string;
|
||||
routingCode: string;
|
||||
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||||
ddCharges: number | null;
|
||||
}
|
||||
|
||||
interface Denomination {
|
||||
@@ -744,6 +751,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
private cashierDepositService = inject(CashierDepositService);
|
||||
readonly router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
|
||||
/** GST % on DD issuance charges — loaded from the admin GST/GL Settings (sponsor-dd module). */
|
||||
ddGstPercentage = 18;
|
||||
/** Admin-configured DD limit (sponsor-dd module) — caps a single DD's face value. Null = no limit. */
|
||||
ddLimitAmount: number | null = null;
|
||||
|
||||
today = '';
|
||||
rows: TxnRow[] = [];
|
||||
@@ -889,6 +902,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
this.loadCashVaultGl();
|
||||
this.loadDdGstPercentage();
|
||||
|
||||
if (this.reviewMode) {
|
||||
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
||||
@@ -973,6 +987,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin GST rate (set via GST / GL Settings in the sponsor-dd module) applied to DD issuance charges. */
|
||||
private loadDdGstPercentage(): void {
|
||||
this.sponsorDDService
|
||||
.getGlMappings()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((mappings: any[]) => {
|
||||
const mapping = mappings && mappings.length ? mappings[0] : null;
|
||||
this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18);
|
||||
this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null;
|
||||
});
|
||||
}
|
||||
|
||||
private loadDayStatus(): void {
|
||||
this.cashierService
|
||||
.getCashierDayStatus()
|
||||
@@ -1139,7 +1165,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
applyDenominationTotal(): void {
|
||||
if (this.selectedRow) {
|
||||
// DD + Cash + GL: amount is derived from Face Value + Charges + GST, not from counted cash —
|
||||
// overwriting it here would feed back into the Charges/GST sum and compound on every click.
|
||||
if (this.selectedRow && !this.isDdCashGlRow(this.selectedRow)) {
|
||||
this.selectedRow.amount = this.denominationTotal;
|
||||
}
|
||||
}
|
||||
@@ -1167,7 +1195,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
tokNo: '',
|
||||
paymentTypeId: this.findTransferPaymentTypeId(),
|
||||
bankName: '',
|
||||
routingCode: ''
|
||||
routingCode: '',
|
||||
ddCharges: null
|
||||
};
|
||||
this.rows.push(mirror);
|
||||
this.selectedRow = mirror;
|
||||
@@ -1188,7 +1217,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
tokNo: '',
|
||||
paymentTypeId: null,
|
||||
bankName: '',
|
||||
routingCode: ''
|
||||
routingCode: '',
|
||||
ddCharges: null
|
||||
};
|
||||
this.rows.push(row);
|
||||
}
|
||||
@@ -1227,6 +1257,40 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return pt?.id ?? null;
|
||||
}
|
||||
|
||||
/** TYPE=DD + TXN TP=Cash + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */
|
||||
isDdCashGlRow(row: TxnRow): boolean {
|
||||
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL';
|
||||
}
|
||||
|
||||
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
|
||||
ddGstAmountFor(row: TxnRow): number {
|
||||
const charges = Number(row.ddCharges) || 0;
|
||||
if (charges <= 0) return 0;
|
||||
return Math.round(charges * this.ddGstPercentage) / 100;
|
||||
}
|
||||
|
||||
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
|
||||
ddTotalPayableFor(row: TxnRow): number {
|
||||
const amount = Number(row.amount) || 0;
|
||||
const charges = Number(row.ddCharges) || 0;
|
||||
const gst = this.ddGstAmountFor(row);
|
||||
return Math.ceil(amount + charges + gst - 1e-9);
|
||||
}
|
||||
|
||||
get isSelectedRowDdCashGl(): boolean {
|
||||
return !!this.selectedRow && this.isDdCashGlRow(this.selectedRow);
|
||||
}
|
||||
|
||||
/** Cash the cashier must physically count for the selected DD row — Face Value + Charges + GST. */
|
||||
get ddTargetTotal(): number {
|
||||
return this.selectedRow ? this.ddTotalPayableFor(this.selectedRow) : 0;
|
||||
}
|
||||
|
||||
ddDenomMatchesTarget(): boolean {
|
||||
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
|
||||
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
|
||||
@@ -1503,6 +1567,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly SEARCH_ACCOUNT_TYPE_TO_AC_TYP: Record<string, string> = {
|
||||
SAVINGS: 'SD',
|
||||
CURRENT: 'CA',
|
||||
FIXED_DEPOSIT: 'FD',
|
||||
RECURRING_DEPOSIT: 'RD'
|
||||
};
|
||||
|
||||
openCustomerSearch(): void {
|
||||
const dialogRef = this.dialog.open(CustomerSearchDialogComponent, {
|
||||
width: '960px',
|
||||
maxWidth: '95vw'
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: CustomerSearchResult | undefined) => {
|
||||
if (!result || !result.accountNo) return;
|
||||
// selectedRow is only set once the clerk types into A/C NO. themselves - on a fresh screen
|
||||
// it's still null, so fall back to the first (only) row that addRow() created at startup.
|
||||
const targetRow = this.selectedRow ?? this.rows[0];
|
||||
if (!targetRow) return;
|
||||
|
||||
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[result.accountType || ''] || 'SD';
|
||||
targetRow.acTyp = acTyp;
|
||||
targetRow.acNo = result.accountNo;
|
||||
this.selectedRow = targetRow;
|
||||
this.loadAccountDetails(result.accountNo, acTyp);
|
||||
});
|
||||
}
|
||||
|
||||
private loadAccountDetails(acNo: string, acTyp: string): void {
|
||||
this.lastLoadedAcNo = acNo;
|
||||
this.loadingAccount = true;
|
||||
@@ -2532,20 +2624,55 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
const isCash = !!selectedPtEarly?.isCashPayment;
|
||||
const isDdCashGl = this.isDdCashGlRow(row);
|
||||
// Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination
|
||||
const amount = isCash && !this.clerkMode ? this.denominationTotal : (row.amount ?? this.denominationTotal);
|
||||
// DD + Cash + GL: amount is DD face value + charges + GST (rounded up), not the counted denomination
|
||||
const amount = isDdCashGl
|
||||
? this.ddTotalPayableFor(row)
|
||||
: isCash && !this.clerkMode
|
||||
? this.denominationTotal
|
||||
: (row.amount ?? this.denominationTotal);
|
||||
|
||||
if (isCash && this.denominationTotal <= 0 && !this.clerkMode) {
|
||||
if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl) {
|
||||
this.showAlert('error', 'Denomination Required', [
|
||||
'Cash transaction: Please enter denomination (notes/coins count) first.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// DD + Cash + GL: physical cash counted must reconcile with Face Value + Charges + GST
|
||||
if (isDdCashGl && !this.clerkMode) {
|
||||
if (this.denominationTotal <= 0) {
|
||||
this.showAlert('error', 'Denomination Required', [
|
||||
`Cash transaction: Please count notes/coins totalling ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (DD Face Value + Charges + GST).`
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (Math.abs(this.denominationTotal - amount) > 0.005) {
|
||||
this.showAlert('error', 'Denomination Mismatch', [
|
||||
`Cash counted: ₹${this.denominationTotal.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||
`Total To Collect: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (Face Value + Charges + GST)`,
|
||||
'Adjust the notes/coins breakdown to match the Total To Collect exactly.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
this.showAlert('error', 'Amount Required', ['Please enter a valid transaction amount.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// DD + Cash + GL: reject at cash intake, before the cashier even accepts the money, if the DD
|
||||
// Face Value exceeds the admin-configured DD limit (same limit enforced again at DD issuance).
|
||||
if (isDdCashGl && this.ddLimitAmount != null && this.ddLimitAmount > 0 && Number(row.amount) > this.ddLimitAmount) {
|
||||
this.showAlert('error', 'DD Limit Exceeded', [
|
||||
`DD Face Value (₹${Number(row.amount).toLocaleString('en-IN', { minimumFractionDigits: 2 })}) exceeds the configured DD limit of ₹${this.ddLimitAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
|
||||
'Reduce the amount, or issue this as separate DDs within the limit each.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const isDeposit = row.dbCr === 'Cr';
|
||||
|
||||
// Clerk cash deposit is not permitted — cash handling (notes/coins) is cashier's responsibility
|
||||
@@ -2848,7 +2975,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
paymentType: selectedPt?.name ?? 'Cash',
|
||||
chequeNo: row.chequeNo || null,
|
||||
symbol: this.currencySymbol,
|
||||
tokNo: this.activeWithdrawalRequest?.tok_no ?? null
|
||||
tokNo: this.activeWithdrawalRequest?.tok_no ?? null,
|
||||
isDdCashGl: this.isDdCashGlRow(row),
|
||||
ddFaceValue: row.amount ?? 0,
|
||||
ddCharges: row.ddCharges ?? 0,
|
||||
ddGstPercentage: this.ddGstPercentage,
|
||||
ddGstAmount: this.ddGstAmountFor(row)
|
||||
};
|
||||
this.showConfirmDialog = true;
|
||||
}
|
||||
@@ -2856,7 +2988,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
confirmSubmit(): void {
|
||||
this.showConfirmDialog = false;
|
||||
const row = this.selectedRow!;
|
||||
const amount = row.amount ?? this.denominationTotal;
|
||||
const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal);
|
||||
const isDeposit = row.dbCr === 'Cr';
|
||||
const command = isDeposit ? 'deposit' : 'withdrawal';
|
||||
const accountId = this.accountDetails.id;
|
||||
@@ -3261,7 +3393,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
description: row.description || null,
|
||||
chequeNo: row.chequeNo || null,
|
||||
bankName: row.bankName || null,
|
||||
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null
|
||||
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null,
|
||||
// DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face
|
||||
// value/charges/GST back once this request is approved, instead of retyping them.
|
||||
...(this.isDdCashGlRow(row)
|
||||
? {
|
||||
ddFaceValue: row.amount ?? 0,
|
||||
ddCharges: row.ddCharges ?? 0,
|
||||
ddGstPercentage: this.ddGstPercentage,
|
||||
ddGstAmount: this.ddGstAmountFor(row)
|
||||
}
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1009,6 +1009,17 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
|
||||
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward</button>
|
||||
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
|
||||
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="['/sponsor-dd/issue-approvals']" tabindex="0">
|
||||
<fa-icon icon="check-double" class="menu-item-icon"></fa-icon> DD Issuance Approvals
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="['/sponsor-dd/reprint-approvals']" tabindex="0">
|
||||
<fa-icon icon="print" class="menu-item-icon"></fa-icon> DD Reprint Approvals
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
|
||||
@@ -1333,7 +1344,7 @@
|
||||
<!-- Instruments & Members -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Instruments & Members</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0">
|
||||
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
DD / Pay Order Reconciliation
|
||||
</button>
|
||||
|
||||
+20
-18
@@ -26,29 +26,31 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="sac-alert sac-alert--editable">
|
||||
<div class="sac-alert__content">
|
||||
<h4>Balance Swap Option</h4>
|
||||
<p>Allows balance to be swapped or transferred between linked accounts as per product configuration.</p>
|
||||
@if (!nominationOnly) {
|
||||
<div class="sac-alert sac-alert--editable">
|
||||
<div class="sac-alert__content">
|
||||
<h4>Balance Swap Option</h4>
|
||||
<p>Allows balance to be swapped or transferred between linked accounts as per product configuration.</p>
|
||||
</div>
|
||||
<mat-slide-toggle [(ngModel)]="balanceSwapOption" color="primary"></mat-slide-toggle>
|
||||
</div>
|
||||
<mat-slide-toggle [(ngModel)]="balanceSwapOption" color="primary"></mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
<div class="sac-alert sac-alert--editable">
|
||||
<div class="sac-alert__content">
|
||||
<h4>MSME</h4>
|
||||
<p>Account is categorized under the Micro, Small and Medium Enterprises segment.</p>
|
||||
<div class="sac-alert sac-alert--editable">
|
||||
<div class="sac-alert__content">
|
||||
<h4>MSME</h4>
|
||||
<p>Account is categorized under the Micro, Small and Medium Enterprises segment.</p>
|
||||
</div>
|
||||
<mat-slide-toggle [(ngModel)]="msme" color="primary"></mat-slide-toggle>
|
||||
</div>
|
||||
<mat-slide-toggle [(ngModel)]="msme" color="primary"></mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
<div class="sac-alert sac-alert--editable">
|
||||
<div class="sac-alert__content">
|
||||
<h4>Auto Debit Facility</h4>
|
||||
<p>Enable automatic debit instructions for recurring payments linked to this account.</p>
|
||||
<div class="sac-alert sac-alert--editable">
|
||||
<div class="sac-alert__content">
|
||||
<h4>Auto Debit Facility</h4>
|
||||
<p>Enable automatic debit instructions for recurring payments linked to this account.</p>
|
||||
</div>
|
||||
<mat-slide-toggle [(ngModel)]="autoDebitFacility" color="primary"></mat-slide-toggle>
|
||||
</div>
|
||||
<mat-slide-toggle [(ngModel)]="autoDebitFacility" color="primary"></mat-slide-toggle>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="sac-alert">
|
||||
<div class="sac-alert__content">
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ export class CurrentAccountCurrencyStepComponent implements OnChanges {
|
||||
@Input() primaryClientId: number | null = null;
|
||||
@Input() isNonIndividualClient = false;
|
||||
@Input() existingAccount: any = null;
|
||||
@Input() nominationOnly = false;
|
||||
@ViewChild(NomineeFormComponent) nomineeFormComp: NomineeFormComponent;
|
||||
|
||||
nominees: any[] = [];
|
||||
|
||||
+39
-14
@@ -104,7 +104,7 @@
|
||||
<div class="sad-grid sad-grid--classification">
|
||||
<mat-form-field appearance="outline" class="sad-field">
|
||||
<mat-label>Account Type</mat-label>
|
||||
<mat-select [(ngModel)]="selectedAccountType" [ngModelOptions]="{ standalone: true }">
|
||||
<mat-select [(ngModel)]="selectedAccountType" [ngModelOptions]="{ standalone: true }" [disabled]="readOnly">
|
||||
@for (opt of accountTypeOptions; track opt) {
|
||||
<mat-option [value]="opt">{{ opt }}</mat-option>
|
||||
}
|
||||
@@ -113,7 +113,11 @@
|
||||
|
||||
<mat-form-field appearance="outline" class="sad-field">
|
||||
<mat-label>Customer Segment</mat-label>
|
||||
<mat-select [(ngModel)]="selectedCustomerSegment" [ngModelOptions]="{ standalone: true }">
|
||||
<mat-select
|
||||
[(ngModel)]="selectedCustomerSegment"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="readOnly"
|
||||
>
|
||||
@for (opt of customerSegmentOptions; track opt) {
|
||||
<mat-option [value]="opt">{{ opt }}</mat-option>
|
||||
}
|
||||
@@ -157,7 +161,7 @@
|
||||
|
||||
<mat-form-field appearance="outline" class="sad-field">
|
||||
<mat-label>Share Linking Enabled</mat-label>
|
||||
<mat-select [(ngModel)]="shareLinkingEnabled" [ngModelOptions]="{ standalone: true }">
|
||||
<mat-select [(ngModel)]="shareLinkingEnabled" [ngModelOptions]="{ standalone: true }" [disabled]="readOnly">
|
||||
<mat-option [value]="true">Yes</mat-option>
|
||||
<mat-option [value]="false">No</mat-option>
|
||||
</mat-select>
|
||||
@@ -172,6 +176,7 @@
|
||||
introducerId ? { clientId: introducerId, displayName: introducerName, cifNumber: introducerCif } : null
|
||||
"
|
||||
(introducerChange)="onIntroducerChange($event)"
|
||||
[disabled]="readOnly"
|
||||
></mifosx-introducer-search>
|
||||
}
|
||||
}
|
||||
@@ -263,7 +268,7 @@
|
||||
color="primary"
|
||||
type="button"
|
||||
class="soa-add-btn"
|
||||
[disabled]="totalJointSharePercentage >= 100"
|
||||
[disabled]="totalJointSharePercentage >= 100 || readOnly"
|
||||
(click)="openAddJointHolderDialog()"
|
||||
>
|
||||
<mat-icon>person_add</mat-icon> Add Holder
|
||||
@@ -309,6 +314,7 @@
|
||||
max="100"
|
||||
[(ngModel)]="primaryHolderShare"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="readOnly"
|
||||
placeholder="0"
|
||||
(input)="onPrimaryShareInput($event)"
|
||||
(blur)="onPrimaryShareBlur($event)"
|
||||
@@ -358,7 +364,8 @@
|
||||
class="soa-sign-toggle"
|
||||
[class.soa-sign-toggle--mandatory]="h.isMandatory"
|
||||
[class.soa-sign-toggle--optional]="!h.isMandatory"
|
||||
(click)="toggleJointHolderMandatory(h)"
|
||||
[disabled]="readOnly"
|
||||
(click)="!readOnly && toggleJointHolderMandatory(h)"
|
||||
>
|
||||
<mat-icon>{{ h.isMandatory ? 'lock' : 'lock_open' }}</mat-icon>
|
||||
{{ h.isMandatory ? 'Mandatory' : 'Optional' }}
|
||||
@@ -366,7 +373,13 @@
|
||||
</td>
|
||||
<td class="soa-td soa-td--center">Co-Holder</td>
|
||||
<td class="soa-td soa-td--center">
|
||||
<button mat-icon-button color="warn" type="button" (click)="removeJointHolder(i)">
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
type="button"
|
||||
[disabled]="readOnly"
|
||||
(click)="removeJointHolder(i)"
|
||||
>
|
||||
<mat-icon>delete_outline</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
@@ -448,7 +461,8 @@
|
||||
type="button"
|
||||
class="soa-mode-card"
|
||||
[class.soa-mode-card--selected]="modeOfOperation === opt.value"
|
||||
(click)="modeOfOperation = opt.value"
|
||||
[disabled]="readOnly"
|
||||
(click)="!readOnly && (modeOfOperation = opt.value)"
|
||||
>
|
||||
<mat-icon class="soa-mode-card__icon">{{ opt.icon }}</mat-icon>
|
||||
<div class="soa-mode-card__code">{{ opt.label }}</div>
|
||||
@@ -503,8 +517,9 @@
|
||||
[max]="maxDate"
|
||||
[(ngModel)]="minorDob"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="readOnly"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="minorDobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker-toggle matSuffix [for]="minorDobPicker" [disabled]="readOnly"></mat-datepicker-toggle>
|
||||
<mat-datepicker #minorDobPicker></mat-datepicker>
|
||||
@if (clientDob) {
|
||||
<mat-hint>Auto-filled from client profile</mat-hint>
|
||||
@@ -525,7 +540,11 @@
|
||||
|
||||
<mat-form-field appearance="outline" class="soa-field">
|
||||
<mat-label>Guardian Relationship</mat-label>
|
||||
<mat-select [(ngModel)]="guardianRelationship" [ngModelOptions]="{ standalone: true }">
|
||||
<mat-select
|
||||
[(ngModel)]="guardianRelationship"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="readOnly"
|
||||
>
|
||||
@for (rel of guardianRelationshipOptions; track rel) {
|
||||
<mat-option [value]="rel">{{ rel }}</mat-option>
|
||||
}
|
||||
@@ -541,6 +560,7 @@
|
||||
<mifosx-introducer-search
|
||||
[excludedClientIds]="primaryClientId ? [primaryClientId] : []"
|
||||
(introducerChange)="onGuardianChange($event)"
|
||||
[disabled]="readOnly"
|
||||
></mifosx-introducer-search>
|
||||
|
||||
@if (guardianName) {
|
||||
@@ -667,7 +687,8 @@
|
||||
class="soa-cat-tile"
|
||||
[class.soa-cat-tile--selected]="selectedSpecialCategory === opt.value"
|
||||
[class.soa-cat-tile--male]="true"
|
||||
(click)="selectedSpecialCategory = opt.value"
|
||||
[disabled]="readOnly"
|
||||
(click)="!readOnly && (selectedSpecialCategory = opt.value)"
|
||||
>
|
||||
<mat-icon class="soa-cat-tile__icon">{{ opt.icon }}</mat-icon>
|
||||
<span class="soa-cat-tile__label">{{ opt.label }}</span>
|
||||
@@ -717,7 +738,8 @@
|
||||
class="soa-cat-tile"
|
||||
[class.soa-cat-tile--selected]="selectedWeakerSection === opt.value"
|
||||
[class.soa-cat-tile--female]="true"
|
||||
(click)="selectedWeakerSection = opt.value"
|
||||
[disabled]="readOnly"
|
||||
(click)="!readOnly && (selectedWeakerSection = opt.value)"
|
||||
>
|
||||
<mat-icon class="soa-cat-tile__icon">{{ opt.icon }}</mat-icon>
|
||||
<span class="soa-cat-tile__label">{{ opt.label }}</span>
|
||||
@@ -775,7 +797,7 @@
|
||||
color="primary"
|
||||
type="button"
|
||||
class="ni-add-btn"
|
||||
[disabled]="totalSharePercentage >= 100"
|
||||
[disabled]="totalSharePercentage >= 100 || readOnly"
|
||||
(click)="openAddRelatedClientDialog()"
|
||||
>
|
||||
<mat-icon>person_add</mat-icon> Add Partner
|
||||
@@ -826,7 +848,8 @@
|
||||
class="ni-sign-toggle"
|
||||
[class.ni-sign-toggle--mandatory]="c.isMandatory"
|
||||
[class.ni-sign-toggle--optional]="!c.isMandatory"
|
||||
(click)="toggleMandatory(c)"
|
||||
[disabled]="readOnly"
|
||||
(click)="!readOnly && toggleMandatory(c)"
|
||||
[title]="c.isMandatory ? 'Click to set Optional' : 'Click to set Mandatory'"
|
||||
>
|
||||
<mat-icon>{{ c.isMandatory ? 'lock' : 'lock_open' }}</mat-icon>
|
||||
@@ -838,6 +861,7 @@
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
type="button"
|
||||
[disabled]="readOnly"
|
||||
(click)="removeRelatedClient(i)"
|
||||
class="ni-del-btn"
|
||||
>
|
||||
@@ -915,7 +939,8 @@
|
||||
type="button"
|
||||
class="ni-sig-card"
|
||||
[class.ni-sig-card--selected]="signatoryRights === opt.value"
|
||||
(click)="signatoryRights = opt.value"
|
||||
[disabled]="readOnly"
|
||||
(click)="!readOnly && (signatoryRights = opt.value)"
|
||||
>
|
||||
<mat-icon class="ni-sig-card__icon">{{ opt.icon }}</mat-icon>
|
||||
<div class="ni-sig-card__code">{{ opt.label }}</div>
|
||||
|
||||
+5
@@ -53,6 +53,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
|
||||
@Input() currentAccountTemplate: any;
|
||||
@Input() hideButtons = false;
|
||||
@Input() readOnly = false;
|
||||
|
||||
minDate = new Date(2000, 0, 1);
|
||||
maxDate = new Date();
|
||||
@@ -236,6 +237,9 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.readOnly) {
|
||||
this.currentAccountDetailsForm.disable();
|
||||
}
|
||||
}
|
||||
|
||||
createCurrentAccountDetailsForm() {
|
||||
@@ -453,6 +457,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
}
|
||||
|
||||
isTileDisabled(value: string): boolean {
|
||||
if (this.readOnly) return true;
|
||||
if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18;
|
||||
if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60;
|
||||
return false;
|
||||
|
||||
+24
@@ -210,5 +210,29 @@
|
||||
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dig-card" [class.dig-card--on]="facilityConfig.internetBanking">
|
||||
<div class="dig-card__icon"><i class="fas fa-globe"></i></div>
|
||||
<div class="dig-card__body">
|
||||
<span class="dig-card__label">Internet Banking</span>
|
||||
<span class="dig-card__status">{{ facilityConfig.internetBanking ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
<label class="dig-toggle">
|
||||
<input type="checkbox" [(ngModel)]="facilityConfig.internetBanking" />
|
||||
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dig-card" [class.dig-card--on]="facilityConfig.mobileBanking">
|
||||
<div class="dig-card__icon"><i class="fas fa-mobile-screen-button"></i></div>
|
||||
<div class="dig-card__body">
|
||||
<span class="dig-card__label">Mobile Banking</span>
|
||||
<span class="dig-card__status">{{ facilityConfig.mobileBanking ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
<label class="dig-toggle">
|
||||
<input type="checkbox" [(ngModel)]="facilityConfig.mobileBanking" />
|
||||
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-1
@@ -26,7 +26,9 @@ const DEFAULT_FACILITY_CONFIG = {
|
||||
interBankTotalCapFacility: false,
|
||||
withdrawalFacility: false,
|
||||
depositFacilityKyc: false,
|
||||
virtualDebitCard: false
|
||||
virtualDebitCard: false,
|
||||
internetBanking: false,
|
||||
mobileBanking: false
|
||||
};
|
||||
|
||||
@Component({
|
||||
|
||||
+3
-1
@@ -78,7 +78,9 @@ export class CurrentAccountPreviewStepComponent implements OnChanges {
|
||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' },
|
||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
|
||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
|
||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
|
||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' },
|
||||
{ key: 'internetBanking', label: 'Internet Banking' },
|
||||
{ key: 'mobileBanking', label: 'Mobile Banking' }
|
||||
];
|
||||
|
||||
private translateService = inject(TranslateService);
|
||||
|
||||
@@ -124,6 +124,7 @@ export class CurrentAccountViewComponent implements OnInit {
|
||||
'Add Charge': 'admin',
|
||||
'Apply Annual Fees': 'admin',
|
||||
'Modify Application': 'admin',
|
||||
'Edit Nominee & Features': 'admin',
|
||||
'Assign Staff': 'admin',
|
||||
'Unassign Staff': 'admin',
|
||||
Approve: 'admin',
|
||||
@@ -156,6 +157,7 @@ export class CurrentAccountViewComponent implements OnInit {
|
||||
'Add Charge': 'plus-circle',
|
||||
'Apply Annual Fees': 'receipt',
|
||||
'Modify Application': 'edit',
|
||||
'Edit Nominee & Features': 'edit',
|
||||
'Assign Staff': 'user-plus',
|
||||
'Unassign Staff': 'user-slash',
|
||||
Approve: 'check-circle',
|
||||
@@ -314,6 +316,9 @@ export class CurrentAccountViewComponent implements OnInit {
|
||||
case 'Modify Application':
|
||||
this.router.navigate(['edit'], { relativeTo: this.route });
|
||||
break;
|
||||
case 'Edit Nominee & Features':
|
||||
this.router.navigate(['edit-nominee-features'], { relativeTo: this.route });
|
||||
break;
|
||||
case 'Delete':
|
||||
this.deleteCurrentAccount();
|
||||
break;
|
||||
|
||||
@@ -162,6 +162,10 @@ export class CurrentAccountButtonsConfiguration {
|
||||
switch (status) {
|
||||
case 'Active':
|
||||
this.optionArray = [
|
||||
{
|
||||
name: 'Edit Nominee & Features',
|
||||
taskPermissionName: 'SUBMITEDIT_CURRENTACCOUNT'
|
||||
},
|
||||
{
|
||||
name: 'Post Interest',
|
||||
taskPermissionName: 'POSTINTEREST_CURRENTACCOUNT'
|
||||
|
||||
@@ -20,6 +20,7 @@ import { StandingInstructionsTabComponent } from './current-account-view/standin
|
||||
import { DatatableTabsComponent } from './current-account-view/datatable-tabs/datatable-tabs.component';
|
||||
import { CreateCurrentAccountComponent } from './create-current-account/create-current-account.component';
|
||||
import { EditCurrentAccountComponent } from './edit-current-account/edit-current-account.component';
|
||||
import { EditCurrentAccountFeaturesComponent } from './edit-current-account-features/edit-current-account-features.component';
|
||||
import { ViewTransactionComponent } from './current-account-view/transactions/view-transaction/view-transaction.component';
|
||||
import { ViewChargeComponent } from './current-account-view/view-charge/view-charge.component';
|
||||
import { ViewRecieptComponent } from './current-account-view/transactions/view-reciept/view-reciept.component';
|
||||
@@ -170,6 +171,18 @@ const routes: Routes = [
|
||||
currentAccountAndTemplate: CurrentAccountAndTemplateResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':currentAccountId/edit-nominee-features',
|
||||
data: {
|
||||
title: 'Edit Nominee & Features',
|
||||
breadcrumb: 'Edit Nominee & Features',
|
||||
routeParamBreadcrumb: false
|
||||
},
|
||||
component: EditCurrentAccountFeaturesComponent,
|
||||
resolve: {
|
||||
currentAccountAndTemplate: CurrentAccountAndTemplateResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':currentAccountId/transactions/:id',
|
||||
data: { title: 'Current Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false },
|
||||
|
||||
@@ -61,6 +61,7 @@ import { CurrentAccountTransactionDatatableTabComponent } from './current-accoun
|
||||
import { GeneralTabComponent } from './current-account-view/general-tab/general-tab.component';
|
||||
import { CurrentAccountActiveClientMembersComponent } from './current-account-stepper/current-accounts-active-client-members/current-accounts-active-client-members.component';
|
||||
import { PassbookTabComponent } from './current-account-view/passbook-tab/passbook-tab.component';
|
||||
import { EditCurrentAccountFeaturesComponent } from './edit-current-account-features/edit-current-account-features.component';
|
||||
|
||||
/**
|
||||
* Current Accounts Module
|
||||
@@ -117,7 +118,8 @@ import { PassbookTabComponent } from './current-account-view/passbook-tab/passbo
|
||||
CurrentAccountTransactionDatatableTabComponent,
|
||||
GeneralTabComponent,
|
||||
CurrentAccountActiveClientMembersComponent,
|
||||
PassbookTabComponent
|
||||
PassbookTabComponent,
|
||||
EditCurrentAccountFeaturesComponent
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
|
||||
@@ -370,4 +370,49 @@ export class CurrentAccountsService {
|
||||
loadCurrentAccountDocument(currentAccountId: any, data: any): Observable<any> {
|
||||
return this.http.post(`/currentaccounts/${currentAccountId}/documents`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a maker-checker edit request (Nominee and/or Digital & Account Features change) for a
|
||||
* current account. The edit is held for checker approval before it is actually applied.
|
||||
* @param {string} accountId Current Account Id
|
||||
* @param {any} data Edit payload — `accountFacilityConfig` and/or `nominee`, plus locale/dateFormat
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
submitCurrentAccountEdit(accountId: string, data: any): Observable<any> {
|
||||
return this.http.post(`/currentaccounts/${accountId}/edit`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} accountId Current Account Id
|
||||
* @returns {Observable<any>} the pending edit for this account, if any.
|
||||
*/
|
||||
getPendingCurrentAccountEdit(accountId: string): Observable<any> {
|
||||
return this.http.get(`/currentaccounts/${accountId}/edit/pending`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<any>} all current account edits currently awaiting checker approval.
|
||||
*/
|
||||
getAllPendingCurrentAccountEdits(): Observable<any> {
|
||||
return this.http.get(`/currentaccounts/edit/pending`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approves a pending current account edit, releasing any hold and applying the proposed changes.
|
||||
* @param {string} accountId Current Account Id
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
approveCurrentAccountEdit(accountId: string): Observable<any> {
|
||||
return this.http.post(`/currentaccounts/${accountId}/edit/approve`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a pending current account edit, releasing any hold without applying the changes.
|
||||
* @param {string} accountId Current Account Id
|
||||
* @param {string} rejectReason Optional reason for rejection
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
rejectCurrentAccountEdit(accountId: string, rejectReason?: string): Observable<any> {
|
||||
return this.http.post(`/currentaccounts/${accountId}/edit/reject`, { rejectReason: rejectReason || '' });
|
||||
}
|
||||
}
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
<!--
|
||||
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="csa-container mat-typography">
|
||||
<!-- Hero -->
|
||||
<div class="csa-header">
|
||||
<fa-icon icon="university" class="csa-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h2 class="csa-header__title">Modify Current Account Features</h2>
|
||||
<div class="csa-header__sub">Review account, manage nominees and digital features before submit.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (pendingEditLoading) {
|
||||
<div class="esf-loading">
|
||||
<mat-progress-spinner diameter="20" mode="indeterminate"></mat-progress-spinner>
|
||||
<span>Checking for pending edits...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!pendingEditLoading && hasPendingEdit) {
|
||||
<div class="esf-pending-banner">
|
||||
<mat-icon>hourglass_top</mat-icon>
|
||||
<div class="esf-pending-banner__text">
|
||||
<strong>An edit is already pending approval for this account.</strong>
|
||||
<span>
|
||||
Submitted by {{ pendingEdit.makerUsername || 'a maker' }} on {{ pendingEdit.createdOn | dateFormat }}. Status:
|
||||
<span class="esf-pending-banner__status">{{ pendingEdit.status }}</span>
|
||||
@if (pendingEdit.holdAmount) {
|
||||
· Hold placed: {{ pendingEdit.holdAmount }}
|
||||
}
|
||||
</span>
|
||||
<span>You cannot submit another edit until this one is approved or rejected.</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!pendingEditLoading) {
|
||||
<!-- Wizard tabs -->
|
||||
<mat-tab-group
|
||||
class="csa-tabs"
|
||||
animationDuration="200ms"
|
||||
[preserveContent]="true"
|
||||
[(selectedIndex)]="selectedTabIndex"
|
||||
>
|
||||
<!-- Tab 1: Product Classification + Details -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="check-square" class="csa-tab__icon"></fa-icon>
|
||||
Product
|
||||
</ng-template>
|
||||
<div class="csa-tab-body">
|
||||
<mifosx-current-account-details-step
|
||||
[currentAccountTemplate]="currentAccountAndTemplate"
|
||||
(currentAccountProductTemplate)="setTemplate($event)"
|
||||
[readOnly]="true"
|
||||
>
|
||||
</mifosx-current-account-details-step>
|
||||
|
||||
<div class="csa-actions">
|
||||
<span class="csa-actions__spacer"></span>
|
||||
<button mat-raised-button color="primary" [disabled]="!currentAccountProductTemplate" (click)="next()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 2: Currency -->
|
||||
<mat-tab [disabled]="!currentAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="dollar-sign" class="csa-tab__icon"></fa-icon>
|
||||
Currency
|
||||
</ng-template>
|
||||
<div class="csa-tab-body">
|
||||
@if (currentAccountProductTemplate) {
|
||||
<mifosx-current-account-currency-step
|
||||
[currentAccountProductTemplate]="currentAccountProductTemplate"
|
||||
[primaryClientId]="currentAccountAndTemplate?.clientId ?? null"
|
||||
[isNonIndividualClient]="currentAccountDetailsStep?.isNonIndividualClient ?? false"
|
||||
[existingAccount]="currentAccountAndTemplate"
|
||||
[nominationOnly]="true"
|
||||
></mifosx-current-account-currency-step>
|
||||
}
|
||||
|
||||
<div class="csa-actions">
|
||||
<button mat-stroked-button (click)="prev()">
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<span class="csa-actions__spacer"></span>
|
||||
<button mat-raised-button color="primary" (click)="next()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 3: Digital -->
|
||||
<mat-tab [disabled]="!currentAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="mobile-alt" class="csa-tab__icon"></fa-icon>
|
||||
Digital
|
||||
</ng-template>
|
||||
<div class="csa-tab-body">
|
||||
@if (currentAccountProductTemplate) {
|
||||
<mifosx-current-account-digital-step
|
||||
[currentAccountProductTemplate]="currentAccountProductTemplate"
|
||||
[existingAccount]="currentAccountAndTemplate"
|
||||
></mifosx-current-account-digital-step>
|
||||
}
|
||||
|
||||
<div class="csa-actions">
|
||||
<button mat-stroked-button (click)="prev()">
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<span class="csa-actions__spacer"></span>
|
||||
<button mat-raised-button color="primary" (click)="next()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 4: Preview / Submit -->
|
||||
<mat-tab [disabled]="!currentAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="eye" class="csa-tab__icon"></fa-icon>
|
||||
{{ 'labels.inputs.PREVIEW' | translate }}
|
||||
</ng-template>
|
||||
<div class="csa-tab-body">
|
||||
@if (currentAccountProductTemplate) {
|
||||
<mifosx-current-account-preview-step
|
||||
[currentAccountProductTemplate]="currentAccountProductTemplate"
|
||||
[currentAccountTemplate]="currentAccountAndTemplate"
|
||||
[currentAccountTermForm]="currentAccountTermForm"
|
||||
[currentAccountSettingsForm]="currentAccountSettingsForm"
|
||||
[currentAccountTermsForm]="currentAccountTermsForm"
|
||||
[currentAccount]="currentAccount"
|
||||
[currentAccountDigital]="currentAccountDigital"
|
||||
[currentAccountNominees]="currentAccountCurrencyStep?.nominees ?? []"
|
||||
[currentAccountCurrencyFlags]="currentAccountCurrencyFlags"
|
||||
[hideButtons]="true"
|
||||
(submitEvent)="submit()"
|
||||
>
|
||||
</mifosx-current-account-preview-step>
|
||||
}
|
||||
|
||||
<div class="csa-actions">
|
||||
<button mat-stroked-button (click)="prev()">
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<span class="csa-actions__spacer"></span>
|
||||
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
|
||||
<button mat-raised-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
|
||||
@if (submitting) {
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<mat-icon>send</mat-icon> Submit for Approval
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
}
|
||||
</div>
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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;
|
||||
$amber: #92400e;
|
||||
$amber-bg: #fffbeb;
|
||||
$amber-border: #fde68a;
|
||||
|
||||
.esf-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 0;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
|
||||
&--sm {
|
||||
padding: 10px 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.esf-pending-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: $amber-bg;
|
||||
border: 1px solid $amber-border;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
color: $amber;
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12.5px;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── CSA Stepper Styles ─── */
|
||||
$navy: #0d1b2a;
|
||||
$blue: #1e88e5;
|
||||
$blue-d: #1e40af;
|
||||
$indigo: #4f46e5;
|
||||
|
||||
.csa-container {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.csa-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, $blue 0%, $indigo 100%);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.csa-tabs {
|
||||
background: #fff;
|
||||
border: 1px solid $border;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
overflow: hidden;
|
||||
|
||||
::ng-deep {
|
||||
.mat-mdc-tab-header {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
.mat-mdc-tab {
|
||||
min-width: 140px;
|
||||
height: 50px;
|
||||
padding: 0 22px;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.01em;
|
||||
color: #475569 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab:hover:not(.mdc-tab--active, .mat-mdc-tab-disabled) {
|
||||
background: rgb(30 64 175 / 5%);
|
||||
color: $navy !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab.mat-mdc-tab-active {
|
||||
.mdc-tab__text-label {
|
||||
color: $blue-d !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mdc-tab-indicator__content--underline {
|
||||
border-color: $blue-d !important;
|
||||
border-top-width: 3px !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab.mat-mdc-tab-disabled {
|
||||
opacity: 0.45 !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-wrapper {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body,
|
||||
.mat-mdc-tab-body-content {
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.csa-tab__icon {
|
||||
margin-right: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.csa-tab-body {
|
||||
padding: 24px;
|
||||
|
||||
@media (width <= 768px) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.csa-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
|
||||
&__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 40px !important;
|
||||
border-radius: 10px !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 0 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.m-l-10 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.m-r-10 {
|
||||
margin-right: 8px;
|
||||
}
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject, ViewChild } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule, FormGroup } from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { CurrentAccountDetailsStepComponent } from '../current-account-stepper/current-account-details-step/current-account-details-step.component';
|
||||
import { CurrentAccountCurrencyStepComponent } from '../current-account-stepper/current-account-currency-step/current-account-currency-step.component';
|
||||
import { CurrentAccountDigitalStepComponent } from '../current-account-stepper/current-account-digital-step/current-account-digital-step.component';
|
||||
import { CurrentAccountPreviewStepComponent } from '../current-account-stepper/current-account-preview-step/current-account-preview-step.component';
|
||||
|
||||
/** Custom Services */
|
||||
import { CurrentAccountsService } from '../current-accounts.service';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Default state for every Digital & Account Feature toggle, mirrors
|
||||
* current-account-digital-step's DEFAULT_FACILITY_CONFIG. */
|
||||
const DEFAULT_FACILITY_CONFIG: Record<string, boolean> = {
|
||||
atmFacility: false,
|
||||
atmWithdrawalFacility: false,
|
||||
upiFacility: false,
|
||||
posTransactionsFacility: false,
|
||||
chequeBook: false,
|
||||
impsFacility: false,
|
||||
smsAlerts: false,
|
||||
bbpsFacility: false,
|
||||
insuranceCover: false,
|
||||
bepgFacility: false,
|
||||
neftFacility: false,
|
||||
rtgsFacility: false,
|
||||
interBankTotalCapFacility: false,
|
||||
withdrawalFacility: false,
|
||||
depositFacilityKyc: false,
|
||||
virtualDebitCard: false,
|
||||
internetBanking: false,
|
||||
mobileBanking: false
|
||||
};
|
||||
|
||||
/** Toggle metadata used to render the feature grid — key must match a
|
||||
* property of DEFAULT_FACILITY_CONFIG / the backend's accountFacilityConfig map. */
|
||||
const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [
|
||||
{ key: 'atmFacility', label: 'ATM Facility', icon: 'fa-credit-card' },
|
||||
{ key: 'atmWithdrawalFacility', label: 'ATM Withdrawal', icon: 'fa-wallet' },
|
||||
{ key: 'upiFacility', label: 'UPI Facility', icon: 'fa-mobile-alt' },
|
||||
{ key: 'posTransactionsFacility', label: 'POS Transactions', icon: 'fa-exchange-alt' },
|
||||
{ key: 'chequeBook', label: 'Cheque Book', icon: 'fa-book' },
|
||||
{ key: 'impsFacility', label: 'IMPS Facility', icon: 'fa-bolt' },
|
||||
{ key: 'smsAlerts', label: 'SMS Alerts', icon: 'fa-sms' },
|
||||
{ key: 'bbpsFacility', label: 'BBPS Facility', icon: 'fa-bell' },
|
||||
{ key: 'insuranceCover', label: 'Insurance Cover', icon: 'fa-shield-alt' },
|
||||
{ key: 'bepgFacility', label: 'BEPG Facility', icon: 'fa-plug' },
|
||||
{ key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' },
|
||||
{ key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' },
|
||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' },
|
||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' },
|
||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' },
|
||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' },
|
||||
{ key: 'internetBanking', label: 'Internet Banking', icon: 'fa-globe' },
|
||||
{ key: 'mobileBanking', label: 'Mobile Banking', icon: 'fa-mobile-screen-button' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Edit Nominee & Features — maker-checker submission screen.
|
||||
*
|
||||
* A maker proposes changes to a current account's Digital & Account Features toggles and/or its
|
||||
* nominee here; nothing is applied immediately. The request is staged and a different user (checker)
|
||||
* must approve it from the "Pending Current Account Edits" screen before it takes effect.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-edit-current-account-features',
|
||||
templateUrl: './edit-current-account-features.component.html',
|
||||
styleUrls: ['./edit-current-account-features.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
MatTabsModule,
|
||||
CurrentAccountDetailsStepComponent,
|
||||
CurrentAccountCurrencyStepComponent,
|
||||
CurrentAccountDigitalStepComponent,
|
||||
CurrentAccountPreviewStepComponent
|
||||
]
|
||||
})
|
||||
export class EditCurrentAccountFeaturesComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private currentAccountsService = inject(CurrentAccountsService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@ViewChild(CurrentAccountDetailsStepComponent) currentAccountDetailsStep: CurrentAccountDetailsStepComponent;
|
||||
@ViewChild(CurrentAccountCurrencyStepComponent) currentAccountCurrencyStep: CurrentAccountCurrencyStepComponent;
|
||||
@ViewChild(CurrentAccountDigitalStepComponent) currentAccountDigitalStep: CurrentAccountDigitalStepComponent;
|
||||
|
||||
originalNominees: any[] = [];
|
||||
|
||||
get currentAccountTermForm(): FormGroup {
|
||||
return new FormGroup({});
|
||||
}
|
||||
|
||||
get currentAccountSettingsForm(): FormGroup {
|
||||
return new FormGroup({});
|
||||
}
|
||||
|
||||
get currentAccountTermsForm(): FormGroup {
|
||||
return new FormGroup({});
|
||||
}
|
||||
|
||||
get currentAccount() {
|
||||
return this.currentAccountDetailsStep ? { ...this.currentAccountDetailsStep.currentAccountDetails } : null;
|
||||
}
|
||||
|
||||
get currentAccountDigital() {
|
||||
return this.currentAccountDigitalStep?.currentAccountDigital ?? null;
|
||||
}
|
||||
|
||||
get currentAccountCurrencyFlags() {
|
||||
return {
|
||||
balanceSwapOption: this.currentAccountCurrencyStep?.balanceSwapOption ?? false,
|
||||
msme: this.currentAccountCurrencyStep?.msme ?? false,
|
||||
autoDebitFacility: this.currentAccountCurrencyStep?.autoDebitFacility ?? false
|
||||
};
|
||||
}
|
||||
|
||||
readonly toggles = FACILITY_TOGGLES;
|
||||
|
||||
accountId: string;
|
||||
currentAccountAndTemplate: any;
|
||||
clientId = '';
|
||||
accountNo = '';
|
||||
|
||||
currentAccountProductTemplate: any = null;
|
||||
selectedTabIndex = 0;
|
||||
readonly totalTabs = 4;
|
||||
|
||||
originalFacilityConfig: Record<string, boolean> = { ...DEFAULT_FACILITY_CONFIG };
|
||||
|
||||
/** Current nominees on the account, fetched read-only for reference. */
|
||||
nominees: any[] = [];
|
||||
nomineesLoading = false;
|
||||
relationshipOptions: any[] = [];
|
||||
|
||||
/** Pending edit already submitted for this account, if any. */
|
||||
pendingEdit: any = null;
|
||||
pendingEditLoading = true;
|
||||
|
||||
submitting = false;
|
||||
|
||||
constructor() {
|
||||
this.accountId = this.route.snapshot.params['currentAccountId'];
|
||||
this.loadPendingEdit();
|
||||
this.route.data.subscribe((data: { currentAccountAndTemplate: any }) => {
|
||||
const account = data.currentAccountAndTemplate;
|
||||
this.currentAccountAndTemplate = account;
|
||||
this.clientId = account?.clientId != null ? String(account.clientId) : '';
|
||||
this.accountNo = account?.accountNo || '';
|
||||
this.originalFacilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...(account?.accountFacilityConfig || {}) };
|
||||
if (this.clientId) {
|
||||
this.loadNominees();
|
||||
this.loadRelationshipTemplate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private todayIso(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
private loadPendingEdit(): void {
|
||||
this.pendingEditLoading = true;
|
||||
this.currentAccountsService.getPendingCurrentAccountEdit(this.accountId).subscribe({
|
||||
next: (edit: any) => {
|
||||
this.pendingEdit = edit && edit.id ? edit : null;
|
||||
this.pendingEditLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.pendingEdit = null;
|
||||
this.pendingEditLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadNominees(): void {
|
||||
this.nomineesLoading = true;
|
||||
this.clientsService.getClientNominees(this.clientId, this.accountNo, 'CB').subscribe({
|
||||
next: (data: any) => {
|
||||
this.nominees = Array.isArray(data) ? data : [];
|
||||
this.originalNominees = JSON.parse(JSON.stringify(this.nominees));
|
||||
this.nomineesLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.nominees = [];
|
||||
this.originalNominees = [];
|
||||
this.nomineesLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadRelationshipTemplate(): void {
|
||||
this.clientsService.getClientNomineeTemplate(this.clientId).subscribe({
|
||||
next: (template: any) => {
|
||||
this.relationshipOptions = template?.relationshipOptions || [];
|
||||
},
|
||||
error: () => {
|
||||
this.relationshipOptions = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get hasPendingEdit(): boolean {
|
||||
return !!this.pendingEdit && this.pendingEdit.status === 'PENDING';
|
||||
}
|
||||
|
||||
get facilityConfig(): Record<string, boolean> {
|
||||
return this.currentAccountDigitalStep?.facilityConfig ?? this.originalFacilityConfig;
|
||||
}
|
||||
|
||||
get facilityChanged(): boolean {
|
||||
const config = this.facilityConfig;
|
||||
return this.toggles.some((t) => config[t.key] !== this.originalFacilityConfig[t.key]);
|
||||
}
|
||||
|
||||
get changedToggles(): any[] {
|
||||
const list: any[] = [];
|
||||
const config = this.facilityConfig;
|
||||
this.toggles.forEach((t) => {
|
||||
if (config[t.key] !== this.originalFacilityConfig[t.key]) {
|
||||
list.push({
|
||||
label: t.label,
|
||||
from: this.originalFacilityConfig[t.key],
|
||||
to: config[t.key]
|
||||
});
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
get nomineeChanged(): boolean {
|
||||
return this.buildNomineePayload() !== null;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return (
|
||||
!this.hasPendingEdit &&
|
||||
!this.pendingEditLoading &&
|
||||
!this.submitting &&
|
||||
(this.facilityChanged || this.nomineeChanged)
|
||||
);
|
||||
}
|
||||
|
||||
setTemplate($event: any): void {
|
||||
this.currentAccountProductTemplate = $event;
|
||||
}
|
||||
|
||||
next(): void {
|
||||
if (this.selectedTabIndex < this.totalTabs - 1) {
|
||||
this.selectedTabIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
prev(): void {
|
||||
if (this.selectedTabIndex > 0) {
|
||||
this.selectedTabIndex -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
getRelationshipName(relationshipId: any): string {
|
||||
const opt = this.relationshipOptions.find((r: any) => r.id === relationshipId);
|
||||
return opt?.name || '';
|
||||
}
|
||||
|
||||
private buildFacilityDiff(): Record<string, boolean> | null {
|
||||
const diff: Record<string, boolean> = {};
|
||||
let changed = false;
|
||||
const config = this.facilityConfig;
|
||||
this.toggles.forEach((t) => {
|
||||
if (config[t.key] !== this.originalFacilityConfig[t.key]) {
|
||||
diff[t.key] = config[t.key];
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? diff : null;
|
||||
}
|
||||
|
||||
private buildNomineePayload(): any | null {
|
||||
const original = this.originalNominees || [];
|
||||
const current = this.currentAccountCurrencyStep?.nominees ?? [];
|
||||
|
||||
// Find if a nominee has been added
|
||||
const added = current.find(
|
||||
(curr: any) => !original.some((orig: any) => orig.id === curr.id || orig.nomineeClientId === curr.nomineeClientId)
|
||||
);
|
||||
if (added) {
|
||||
return {
|
||||
operation: 'ADD',
|
||||
nomineeId: null,
|
||||
nomineeClientId: added.nomineeClientId,
|
||||
relationshipId: added.relationshipId,
|
||||
percentageShare: added.percentageShare,
|
||||
isActive: added.isActive !== undefined ? !!added.isActive : true,
|
||||
nomineeIsMinor: !!added.nomineeIsMinor,
|
||||
guardianClientId: added.nomineeIsMinor ? added.guardianClientId : null,
|
||||
guardianRelationshipId: added.nomineeIsMinor ? added.guardianRelationshipId : null,
|
||||
effectiveDate: this.todayIso(),
|
||||
remarks: added.remarks || null
|
||||
};
|
||||
}
|
||||
|
||||
// Find if a nominee has been deleted
|
||||
const deleted = original.find(
|
||||
(orig: any) => !current.some((curr: any) => curr.id === orig.id || curr.nomineeClientId === orig.nomineeClientId)
|
||||
);
|
||||
if (deleted) {
|
||||
return {
|
||||
operation: 'DELETE',
|
||||
nomineeId: deleted.id
|
||||
};
|
||||
}
|
||||
|
||||
// Find if any nominee has been updated
|
||||
for (const curr of current) {
|
||||
const orig = original.find((o: any) => o.id === curr.id || o.nomineeClientId === curr.nomineeClientId);
|
||||
if (orig) {
|
||||
const relChanged = orig.relationshipId !== curr.relationshipId;
|
||||
const shareChanged = +orig.percentageShare !== +curr.percentageShare;
|
||||
const minorChanged = !!orig.nomineeIsMinor !== !!curr.nomineeIsMinor;
|
||||
const guardianChanged =
|
||||
orig.guardianClientId !== curr.guardianClientId ||
|
||||
orig.guardianRelationshipId !== curr.guardianRelationshipId;
|
||||
const activeChanged = !!orig.isActive !== (curr.isActive !== undefined ? !!curr.isActive : true);
|
||||
const remarksChanged = (orig.remarks || '') !== (curr.remarks || '');
|
||||
|
||||
if (relChanged || shareChanged || minorChanged || guardianChanged || activeChanged || remarksChanged) {
|
||||
return {
|
||||
operation: 'UPDATE',
|
||||
nomineeId: orig.id,
|
||||
relationshipId: curr.relationshipId,
|
||||
percentageShare: curr.percentageShare,
|
||||
isActive: curr.isActive !== undefined ? !!curr.isActive : true,
|
||||
nomineeIsMinor: !!curr.nomineeIsMinor,
|
||||
guardianClientId: curr.nomineeIsMinor ? curr.guardianClientId : null,
|
||||
guardianRelationshipId: curr.nomineeIsMinor ? curr.guardianRelationshipId : null,
|
||||
effectiveDate: this.todayIso(),
|
||||
remarks: curr.remarks || null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) return;
|
||||
|
||||
const locale = this.settingsService.language.code;
|
||||
const payload: any = { locale, dateFormat: 'yyyy-MM-dd' };
|
||||
|
||||
const facilityDiff = this.buildFacilityDiff();
|
||||
if (facilityDiff) {
|
||||
payload.accountFacilityConfig = facilityDiff;
|
||||
}
|
||||
|
||||
const nomineePayload = this.buildNomineePayload();
|
||||
if (nomineePayload) {
|
||||
payload.nominee = nomineePayload;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.currentAccountsService.submitCurrentAccountEdit(this.accountId, payload).subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.showSuccess();
|
||||
},
|
||||
error: () => {
|
||||
this.submitting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showSuccess(): void {
|
||||
const ref = this.dialog.open(SuccessDialogComponent, {
|
||||
width: '420px',
|
||||
maxWidth: '92vw',
|
||||
autoFocus: false,
|
||||
data: {
|
||||
title: 'Submitted for Approval',
|
||||
message: 'Your changes have been submitted and are awaiting checker approval.',
|
||||
buttonLabel: 'Done'
|
||||
}
|
||||
});
|
||||
ref.afterClosed().subscribe(() => this.cancel());
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['../'], { relativeTo: this.route });
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,7 @@ import { ViewLoanOriginatorComponent } from './loan-originators/view-loan-origin
|
||||
import { EditLoanOriginatorComponent } from './loan-originators/edit-loan-originator/edit-loan-originator.component';
|
||||
import { CreateLoanOriginatorComponent } from './loan-originators/create-loan-originator/create-loan-originator.component';
|
||||
import { LoanOriginatorsTemplateResolver } from './loan-originators/loan-originators-template.resolver';
|
||||
import { SponsorBankListComponent } from '../sponsor-dd/sponsor-bank-list/sponsor-bank-list.component';
|
||||
|
||||
/** Organization Routes */
|
||||
const routes: Routes = [
|
||||
@@ -553,6 +554,11 @@ const routes: Routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'sponsor-banks',
|
||||
component: SponsorBankListComponent,
|
||||
data: { title: 'Sponsor Banks', breadcrumb: 'Sponsor Banks' }
|
||||
},
|
||||
{
|
||||
path: 'password-preferences',
|
||||
component: PasswordPreferencesComponent,
|
||||
|
||||
@@ -228,6 +228,34 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Sponsor Banks -->
|
||||
<div>
|
||||
<a class="org-card" [routerLink]="['sponsor-banks']">
|
||||
<div class="org-card__icon org-card__icon--emerald">
|
||||
<i class="fas fa-university"></i>
|
||||
</div>
|
||||
<div class="org-card__body">
|
||||
<h3 class="org-card__title">Sponsor Banks</h3>
|
||||
<p class="org-card__desc">Manage banks on whose behalf demand drafts are issued</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right org-card__arrow"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Sponsor DD GST / GL Settings -->
|
||||
<div>
|
||||
<a class="org-card" (click)="openGstGlSettings()">
|
||||
<div class="org-card__icon org-card__icon--emerald">
|
||||
<i class="fas fa-percentage"></i>
|
||||
</div>
|
||||
<div class="org-card__body">
|
||||
<h3 class="org-card__title">Sponsor DD GST / GL Settings</h3>
|
||||
<p class="org-card__desc">Set the GST rate charged on DD issuance commission, and its GL account</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right org-card__arrow"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Loan Provisioning Criteria -->
|
||||
<div #provisioning>
|
||||
<a class="org-card" [routerLink]="['provisioning-criteria']" *mifosxHasPermission="'VIEW_PROVISIONS'">
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
/** Angular Imports */
|
||||
import { AfterViewInit, Component, ElementRef, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
|
||||
/** Custom Services */
|
||||
import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service';
|
||||
import { PopoverService } from '../configuration-wizard/popover/popover.service';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { GlSettingsDialogComponent } from '../sponsor-dd/gl-settings-dialog/gl-settings-dialog.component';
|
||||
|
||||
/**
|
||||
* Organization component.
|
||||
@@ -31,6 +33,7 @@ export class OrganizationComponent implements AfterViewInit {
|
||||
private router = inject(Router);
|
||||
private configurationWizardService = inject(ConfigurationWizardService);
|
||||
private popoverService = inject(PopoverService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
shouldShowFundMapping = false;
|
||||
/* Reference of manage offices */
|
||||
@@ -214,4 +217,13 @@ export class OrganizationComponent implements AfterViewInit {
|
||||
this.configurationWizardService.showRecurringDepositProductsList = true;
|
||||
this.router.navigate(['/products/recurring-deposit-products']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Sponsor DD GST / GL Settings dialog (GST rate + GL account assignments).
|
||||
*/
|
||||
openGstGlSettings(): void {
|
||||
this.dialog.open(GlSettingsDialogComponent, {
|
||||
width: '450px'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
<!--
|
||||
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="csa-container mat-typography">
|
||||
<!-- Hero -->
|
||||
<div class="csa-header">
|
||||
<fa-icon icon="piggy-bank" class="csa-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h2 class="csa-header__title">Modify Savings Account Features</h2>
|
||||
<div class="csa-header__sub">Review account, manage nominees and digital features before submit.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (pendingEditLoading) {
|
||||
<div class="esf-loading">
|
||||
<mat-progress-spinner diameter="20" mode="indeterminate"></mat-progress-spinner>
|
||||
<span>Checking for pending edits...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!pendingEditLoading && hasPendingEdit) {
|
||||
<div class="esf-pending-banner">
|
||||
<mat-icon>hourglass_top</mat-icon>
|
||||
<div class="esf-pending-banner__text">
|
||||
<strong>An edit is already pending approval for this account.</strong>
|
||||
<span>
|
||||
Submitted by {{ pendingEdit.makerUsername || 'a maker' }} on {{ pendingEdit.createdOn | dateFormat }}. Status:
|
||||
<span class="esf-pending-banner__status">{{ pendingEdit.status }}</span>
|
||||
@if (pendingEdit.holdAmount) {
|
||||
· Hold placed: {{ pendingEdit.holdAmount }}
|
||||
}
|
||||
</span>
|
||||
<span>You cannot submit another edit until this one is approved or rejected.</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!pendingEditLoading) {
|
||||
<!-- Wizard tabs -->
|
||||
<mat-tab-group
|
||||
class="csa-tabs"
|
||||
animationDuration="200ms"
|
||||
[preserveContent]="true"
|
||||
[(selectedIndex)]="selectedTabIndex"
|
||||
>
|
||||
<!-- Tab 1: Product Classification + Details -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="check-square" class="csa-tab__icon"></fa-icon>
|
||||
Product
|
||||
</ng-template>
|
||||
<div class="csa-tab-body">
|
||||
<mifosx-savings-account-details-step
|
||||
[savingsAccountTemplate]="savingsAccountAndTemplate"
|
||||
(savingsAccountProductTemplate)="setTemplate($event)"
|
||||
[readOnly]="true"
|
||||
>
|
||||
</mifosx-savings-account-details-step>
|
||||
|
||||
<div class="csa-actions">
|
||||
<span class="csa-actions__spacer"></span>
|
||||
<button mat-raised-button color="primary" [disabled]="!savingsAccountProductTemplate" (click)="next()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 2: Currency -->
|
||||
<mat-tab [disabled]="!savingsAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="dollar-sign" class="csa-tab__icon"></fa-icon>
|
||||
Currency
|
||||
</ng-template>
|
||||
<div class="csa-tab-body">
|
||||
@if (savingsAccountProductTemplate) {
|
||||
<mifosx-savings-account-currency-step
|
||||
[savingsAccountProductTemplate]="savingsAccountProductTemplate"
|
||||
[primaryClientId]="savingsAccountAndTemplate?.clientId ?? null"
|
||||
[existingAccount]="savingsAccountAndTemplate"
|
||||
></mifosx-savings-account-currency-step>
|
||||
}
|
||||
|
||||
<div class="csa-actions">
|
||||
<button mat-stroked-button (click)="prev()">
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<span class="csa-actions__spacer"></span>
|
||||
<button mat-raised-button color="primary" (click)="next()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 3: Digital -->
|
||||
<mat-tab [disabled]="!savingsAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="mobile-alt" class="csa-tab__icon"></fa-icon>
|
||||
Digital
|
||||
</ng-template>
|
||||
<div class="csa-tab-body">
|
||||
@if (savingsAccountProductTemplate) {
|
||||
<mifosx-savings-account-digital-step
|
||||
[savingsAccountProductTemplate]="savingsAccountProductTemplate"
|
||||
[existingAccount]="savingsAccountAndTemplate"
|
||||
></mifosx-savings-account-digital-step>
|
||||
}
|
||||
|
||||
<div class="csa-actions">
|
||||
<button mat-stroked-button (click)="prev()">
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<span class="csa-actions__spacer"></span>
|
||||
<button mat-raised-button color="primary" (click)="next()">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Tab 4: Preview / Submit -->
|
||||
<mat-tab [disabled]="!savingsAccountProductTemplate">
|
||||
<ng-template mat-tab-label>
|
||||
<fa-icon icon="eye" class="csa-tab__icon"></fa-icon>
|
||||
{{ 'labels.inputs.PREVIEW' | translate }}
|
||||
</ng-template>
|
||||
<div class="csa-tab-body">
|
||||
@if (savingsAccountProductTemplate) {
|
||||
<mifosx-savings-account-preview-step
|
||||
[savingsAccountProductTemplate]="savingsAccountProductTemplate"
|
||||
[savingsAccountTemplate]="savingsAccountAndTemplate"
|
||||
[savingsAccountTermsForm]="savingsAccountTermsForm"
|
||||
[savingsAccount]="savingsAccount"
|
||||
[savingsAccountDigital]="savingsAccountDigital"
|
||||
[savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []"
|
||||
[hideButtons]="true"
|
||||
(submitEvent)="submit()"
|
||||
>
|
||||
</mifosx-savings-account-preview-step>
|
||||
}
|
||||
|
||||
<div class="csa-actions">
|
||||
<button mat-stroked-button (click)="prev()">
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<span class="csa-actions__spacer"></span>
|
||||
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
|
||||
<button mat-raised-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
|
||||
@if (submitting) {
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<mat-icon>send</mat-icon> Submit for Approval
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
}
|
||||
</div>
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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;
|
||||
$amber: #92400e;
|
||||
$amber-bg: #fffbeb;
|
||||
$amber-border: #fde68a;
|
||||
|
||||
.esf-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 0;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
|
||||
&--sm {
|
||||
padding: 10px 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.esf-pending-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: $amber-bg;
|
||||
border: 1px solid $amber-border;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
color: $amber;
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12.5px;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── CSA Stepper Styles ─── */
|
||||
$navy: #0d1b2a;
|
||||
$blue: #1e88e5;
|
||||
$blue-d: #1e40af;
|
||||
$indigo: #4f46e5;
|
||||
|
||||
.csa-container {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.csa-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, $blue 0%, $indigo 100%);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.csa-tabs {
|
||||
background: #fff;
|
||||
border: 1px solid $border;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
overflow: hidden;
|
||||
|
||||
::ng-deep {
|
||||
.mat-mdc-tab-header {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
.mat-mdc-tab {
|
||||
min-width: 140px;
|
||||
height: 50px;
|
||||
padding: 0 22px;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.01em;
|
||||
color: #475569 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab:hover:not(.mdc-tab--active, .mat-mdc-tab-disabled) {
|
||||
background: rgb(30 64 175 / 5%);
|
||||
color: $navy !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab.mat-mdc-tab-active {
|
||||
.mdc-tab__text-label {
|
||||
color: $blue-d !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mdc-tab-indicator__content--underline {
|
||||
border-color: $blue-d !important;
|
||||
border-top-width: 3px !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab.mat-mdc-tab-disabled {
|
||||
opacity: 0.45 !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-wrapper {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body,
|
||||
.mat-mdc-tab-body-content {
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.csa-tab__icon {
|
||||
margin-right: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.csa-tab-body {
|
||||
padding: 24px;
|
||||
|
||||
@media (width <= 768px) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.csa-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
|
||||
&__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 40px !important;
|
||||
border-radius: 10px !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 0 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.m-l-10 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.m-r-10 {
|
||||
margin-right: 8px;
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, inject, ViewChild } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule, FormGroup } from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { SavingsAccountDetailsStepComponent } from '../savings-account-stepper/savings-account-details-step/savings-account-details-step.component';
|
||||
import { SavingsAccountCurrencyStepComponent } from '../savings-account-stepper/savings-account-currency-step/savings-account-currency-step.component';
|
||||
import { SavingsAccountDigitalStepComponent } from '../savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component';
|
||||
import { SavingsAccountPreviewStepComponent } from '../savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component';
|
||||
|
||||
/** Custom Services */
|
||||
import { SavingsService } from '../savings.service';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Default state for every Digital & Account Feature toggle, mirrors
|
||||
* savings-account-digital-step's DEFAULT_FACILITY_CONFIG. */
|
||||
const DEFAULT_FACILITY_CONFIG: Record<string, boolean> = {
|
||||
atmFacility: false,
|
||||
atmWithdrawalFacility: false,
|
||||
upiFacility: false,
|
||||
posTransactionsFacility: false,
|
||||
chequeBook: false,
|
||||
impsFacility: false,
|
||||
smsAlerts: false,
|
||||
bbpsFacility: false,
|
||||
insuranceCover: false,
|
||||
bepgFacility: false,
|
||||
neftFacility: false,
|
||||
rtgsFacility: false,
|
||||
interBankTotalCapFacility: false,
|
||||
withdrawalFacility: false,
|
||||
depositFacilityKyc: false,
|
||||
virtualDebitCard: false
|
||||
};
|
||||
|
||||
/** Toggle metadata used to render the feature grid — key must match a
|
||||
* property of DEFAULT_FACILITY_CONFIG / the backend's accountFacilityConfig map. */
|
||||
const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [
|
||||
{ key: 'atmFacility', label: 'ATM Facility', icon: 'fa-credit-card' },
|
||||
{ key: 'atmWithdrawalFacility', label: 'ATM Withdrawal', icon: 'fa-wallet' },
|
||||
{ key: 'upiFacility', label: 'UPI Facility', icon: 'fa-mobile-alt' },
|
||||
{ key: 'posTransactionsFacility', label: 'POS Transactions', icon: 'fa-exchange-alt' },
|
||||
{ key: 'chequeBook', label: 'Cheque Book', icon: 'fa-book' },
|
||||
{ key: 'impsFacility', label: 'IMPS Facility', icon: 'fa-bolt' },
|
||||
{ key: 'smsAlerts', label: 'SMS Alerts', icon: 'fa-sms' },
|
||||
{ key: 'bbpsFacility', label: 'BBPS Facility', icon: 'fa-bell' },
|
||||
{ key: 'insuranceCover', label: 'Insurance Cover', icon: 'fa-shield-alt' },
|
||||
{ key: 'bepgFacility', label: 'BEPG Facility', icon: 'fa-plug' },
|
||||
{ key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' },
|
||||
{ key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' },
|
||||
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' },
|
||||
{ key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' },
|
||||
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' },
|
||||
{ key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Edit Nominee & Features — maker-checker submission screen.
|
||||
*
|
||||
* A maker proposes changes to a savings account's Digital & Account Features toggles and/or its
|
||||
* nominee here; nothing is applied immediately. The request is staged and a different user (checker)
|
||||
* must approve it from the "Pending Savings Account Edits" screen before it takes effect.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-edit-savings-account-features',
|
||||
templateUrl: './edit-savings-account-features.component.html',
|
||||
styleUrls: ['./edit-savings-account-features.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
MatTabsModule,
|
||||
SavingsAccountDetailsStepComponent,
|
||||
SavingsAccountCurrencyStepComponent,
|
||||
SavingsAccountDigitalStepComponent,
|
||||
SavingsAccountPreviewStepComponent
|
||||
]
|
||||
})
|
||||
export class EditSavingsAccountFeaturesComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private savingsService = inject(SavingsService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@ViewChild(SavingsAccountDetailsStepComponent) savingsAccountDetailsStep: SavingsAccountDetailsStepComponent;
|
||||
@ViewChild(SavingsAccountCurrencyStepComponent) savingsAccountCurrencyStep: SavingsAccountCurrencyStepComponent;
|
||||
@ViewChild(SavingsAccountDigitalStepComponent) savingsAccountDigitalStep: SavingsAccountDigitalStepComponent;
|
||||
|
||||
originalNominees: any[] = [];
|
||||
|
||||
get savingsAccountTermsForm(): FormGroup {
|
||||
return new FormGroup({});
|
||||
}
|
||||
|
||||
get savingsAccount() {
|
||||
return this.savingsAccountDetailsStep ? { ...this.savingsAccountDetailsStep.savingsAccountDetails } : null;
|
||||
}
|
||||
|
||||
get savingsAccountDigital() {
|
||||
return this.savingsAccountDigitalStep?.savingsAccountDigital ?? null;
|
||||
}
|
||||
|
||||
readonly toggles = FACILITY_TOGGLES;
|
||||
|
||||
accountId: string;
|
||||
savingsAccountAndTemplate: any;
|
||||
clientId = '';
|
||||
accountNo = '';
|
||||
|
||||
savingsAccountProductTemplate: any = null;
|
||||
selectedTabIndex = 0;
|
||||
readonly totalTabs = 4;
|
||||
|
||||
originalFacilityConfig: Record<string, boolean> = { ...DEFAULT_FACILITY_CONFIG };
|
||||
|
||||
/** Current nominees on the account, fetched read-only for reference. */
|
||||
nominees: any[] = [];
|
||||
nomineesLoading = false;
|
||||
relationshipOptions: any[] = [];
|
||||
|
||||
/** Pending edit already submitted for this account, if any. */
|
||||
pendingEdit: any = null;
|
||||
pendingEditLoading = true;
|
||||
|
||||
submitting = false;
|
||||
|
||||
constructor() {
|
||||
this.accountId = this.route.snapshot.params['savingAccountId'];
|
||||
this.loadPendingEdit();
|
||||
this.route.data.subscribe((data: { savingsAccountAndTemplate: any }) => {
|
||||
const account = data.savingsAccountAndTemplate;
|
||||
this.savingsAccountAndTemplate = account;
|
||||
this.clientId = account?.clientId != null ? String(account.clientId) : '';
|
||||
this.accountNo = account?.accountNo || '';
|
||||
this.originalFacilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...(account?.accountFacilityConfig || {}) };
|
||||
if (this.clientId) {
|
||||
this.loadNominees();
|
||||
this.loadRelationshipTemplate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private todayIso(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
private loadPendingEdit(): void {
|
||||
this.pendingEditLoading = true;
|
||||
this.savingsService.getPendingSavingsAccountEdit(this.accountId).subscribe({
|
||||
next: (edit: any) => {
|
||||
this.pendingEdit = edit && edit.id ? edit : null;
|
||||
this.pendingEditLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.pendingEdit = null;
|
||||
this.pendingEditLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadNominees(): void {
|
||||
this.nomineesLoading = true;
|
||||
this.clientsService.getClientNominees(this.clientId, this.accountNo, 'SB').subscribe({
|
||||
next: (data: any) => {
|
||||
this.nominees = Array.isArray(data) ? data : [];
|
||||
this.originalNominees = JSON.parse(JSON.stringify(this.nominees));
|
||||
this.nomineesLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.nominees = [];
|
||||
this.originalNominees = [];
|
||||
this.nomineesLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadRelationshipTemplate(): void {
|
||||
this.clientsService.getClientNomineeTemplate(this.clientId).subscribe({
|
||||
next: (template: any) => {
|
||||
this.relationshipOptions = template?.relationshipOptions || [];
|
||||
},
|
||||
error: () => {
|
||||
this.relationshipOptions = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Max share allowed for the mode currently being edited — excludes the nominee's own
|
||||
* existing share from the running total when updating that same nominee. */
|
||||
get hasPendingEdit(): boolean {
|
||||
return !!this.pendingEdit && this.pendingEdit.status === 'PENDING';
|
||||
}
|
||||
|
||||
get facilityConfig(): Record<string, boolean> {
|
||||
return this.savingsAccountDigitalStep?.facilityConfig ?? this.originalFacilityConfig;
|
||||
}
|
||||
|
||||
get facilityChanged(): boolean {
|
||||
const config = this.facilityConfig;
|
||||
return this.toggles.some((t) => config[t.key] !== this.originalFacilityConfig[t.key]);
|
||||
}
|
||||
|
||||
get changedToggles(): any[] {
|
||||
const list: any[] = [];
|
||||
const config = this.facilityConfig;
|
||||
this.toggles.forEach((t) => {
|
||||
if (config[t.key] !== this.originalFacilityConfig[t.key]) {
|
||||
list.push({
|
||||
label: t.label,
|
||||
from: this.originalFacilityConfig[t.key],
|
||||
to: config[t.key]
|
||||
});
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
get nomineeChanged(): boolean {
|
||||
return this.buildNomineePayload() !== null;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return (
|
||||
!this.hasPendingEdit &&
|
||||
!this.pendingEditLoading &&
|
||||
!this.submitting &&
|
||||
(this.facilityChanged || this.nomineeChanged)
|
||||
);
|
||||
}
|
||||
|
||||
setTemplate($event: any): void {
|
||||
this.savingsAccountProductTemplate = $event;
|
||||
}
|
||||
|
||||
next(): void {
|
||||
if (this.selectedTabIndex < this.totalTabs - 1) {
|
||||
this.selectedTabIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
prev(): void {
|
||||
if (this.selectedTabIndex > 0) {
|
||||
this.selectedTabIndex -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
getRelationshipName(relationshipId: any): string {
|
||||
const opt = this.relationshipOptions.find((r: any) => r.id === relationshipId);
|
||||
return opt?.name || '';
|
||||
}
|
||||
|
||||
private buildFacilityDiff(): Record<string, boolean> | null {
|
||||
const diff: Record<string, boolean> = {};
|
||||
let changed = false;
|
||||
const config = this.facilityConfig;
|
||||
this.toggles.forEach((t) => {
|
||||
if (config[t.key] !== this.originalFacilityConfig[t.key]) {
|
||||
diff[t.key] = config[t.key];
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? diff : null;
|
||||
}
|
||||
|
||||
private buildNomineePayload(): any | null {
|
||||
const original = this.originalNominees || [];
|
||||
const current = this.savingsAccountCurrencyStep?.nominees ?? [];
|
||||
|
||||
// Find if a nominee has been added
|
||||
const added = current.find(
|
||||
(curr: any) => !original.some((orig: any) => orig.id === curr.id || orig.nomineeClientId === curr.nomineeClientId)
|
||||
);
|
||||
if (added) {
|
||||
return {
|
||||
operation: 'ADD',
|
||||
nomineeId: null,
|
||||
nomineeClientId: added.nomineeClientId,
|
||||
relationshipId: added.relationshipId,
|
||||
percentageShare: added.percentageShare,
|
||||
isActive: added.isActive !== undefined ? !!added.isActive : true,
|
||||
nomineeIsMinor: !!added.nomineeIsMinor,
|
||||
guardianClientId: added.nomineeIsMinor ? added.guardianClientId : null,
|
||||
guardianRelationshipId: added.nomineeIsMinor ? added.guardianRelationshipId : null,
|
||||
effectiveDate: this.todayIso(),
|
||||
remarks: added.remarks || null
|
||||
};
|
||||
}
|
||||
|
||||
// Find if a nominee has been deleted
|
||||
const deleted = original.find(
|
||||
(orig: any) => !current.some((curr: any) => curr.id === orig.id || curr.nomineeClientId === orig.nomineeClientId)
|
||||
);
|
||||
if (deleted) {
|
||||
return {
|
||||
operation: 'DELETE',
|
||||
nomineeId: deleted.id
|
||||
};
|
||||
}
|
||||
|
||||
// Find if any nominee has been updated
|
||||
for (const curr of current) {
|
||||
const orig = original.find((o: any) => o.id === curr.id || o.nomineeClientId === curr.nomineeClientId);
|
||||
if (orig) {
|
||||
const relChanged = orig.relationshipId !== curr.relationshipId;
|
||||
const shareChanged = +orig.percentageShare !== +curr.percentageShare;
|
||||
const minorChanged = !!orig.nomineeIsMinor !== !!curr.nomineeIsMinor;
|
||||
const guardianChanged =
|
||||
orig.guardianClientId !== curr.guardianClientId ||
|
||||
orig.guardianRelationshipId !== curr.guardianRelationshipId;
|
||||
const activeChanged = !!orig.isActive !== (curr.isActive !== undefined ? !!curr.isActive : true);
|
||||
const remarksChanged = (orig.remarks || '') !== (curr.remarks || '');
|
||||
|
||||
if (relChanged || shareChanged || minorChanged || guardianChanged || activeChanged || remarksChanged) {
|
||||
return {
|
||||
operation: 'UPDATE',
|
||||
nomineeId: orig.id,
|
||||
relationshipId: curr.relationshipId,
|
||||
percentageShare: curr.percentageShare,
|
||||
isActive: curr.isActive !== undefined ? !!curr.isActive : true,
|
||||
nomineeIsMinor: !!curr.nomineeIsMinor,
|
||||
guardianClientId: curr.nomineeIsMinor ? curr.guardianClientId : null,
|
||||
guardianRelationshipId: curr.nomineeIsMinor ? curr.guardianRelationshipId : null,
|
||||
effectiveDate: this.todayIso(),
|
||||
remarks: curr.remarks || null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) return;
|
||||
|
||||
const locale = this.settingsService.language.code;
|
||||
const payload: any = { locale, dateFormat: 'yyyy-MM-dd' };
|
||||
|
||||
const facilityDiff = this.buildFacilityDiff();
|
||||
if (facilityDiff) {
|
||||
payload.accountFacilityConfig = facilityDiff;
|
||||
}
|
||||
|
||||
const nomineePayload = this.buildNomineePayload();
|
||||
if (nomineePayload) {
|
||||
payload.nominee = nomineePayload;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.savingsService.submitSavingsAccountEdit(this.accountId, payload).subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.showSuccess();
|
||||
},
|
||||
error: () => {
|
||||
this.submitting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showSuccess(): void {
|
||||
const ref = this.dialog.open(SuccessDialogComponent, {
|
||||
width: '420px',
|
||||
maxWidth: '92vw',
|
||||
autoFocus: false,
|
||||
data: {
|
||||
title: 'Submitted for Approval',
|
||||
message: 'Your changes have been submitted and are awaiting checker approval.',
|
||||
buttonLabel: 'Done'
|
||||
}
|
||||
});
|
||||
ref.afterClosed().subscribe(() => this.cancel());
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['../'], { relativeTo: this.route });
|
||||
}
|
||||
}
|
||||
+33
-11
@@ -71,7 +71,7 @@
|
||||
<div class="sad-grid sad-grid--classification">
|
||||
<mat-form-field appearance="outline" class="sad-field">
|
||||
<mat-label>Account Type</mat-label>
|
||||
<mat-select [(ngModel)]="selectedAccountType" [ngModelOptions]="{ standalone: true }">
|
||||
<mat-select [(ngModel)]="selectedAccountType" [ngModelOptions]="{ standalone: true }" [disabled]="readOnly">
|
||||
@for (opt of accountTypeOptions; track opt) {
|
||||
<mat-option [value]="opt">{{ opt }}</mat-option>
|
||||
}
|
||||
@@ -80,7 +80,11 @@
|
||||
|
||||
<mat-form-field appearance="outline" class="sad-field">
|
||||
<mat-label>Customer Segment</mat-label>
|
||||
<mat-select [(ngModel)]="selectedCustomerSegment" [ngModelOptions]="{ standalone: true }">
|
||||
<mat-select
|
||||
[(ngModel)]="selectedCustomerSegment"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="readOnly"
|
||||
>
|
||||
@for (opt of customerSegmentOptions; track opt) {
|
||||
<mat-option [value]="opt">{{ opt }}</mat-option>
|
||||
}
|
||||
@@ -117,7 +121,7 @@
|
||||
|
||||
<mat-form-field appearance="outline" class="sad-field">
|
||||
<mat-label>Share Linking Enabled</mat-label>
|
||||
<mat-select [(ngModel)]="shareLinkingEnabled" [ngModelOptions]="{ standalone: true }">
|
||||
<mat-select [(ngModel)]="shareLinkingEnabled" [ngModelOptions]="{ standalone: true }" [disabled]="readOnly">
|
||||
<mat-option [value]="true">Yes</mat-option>
|
||||
<mat-option [value]="false">No</mat-option>
|
||||
</mat-select>
|
||||
@@ -132,6 +136,7 @@
|
||||
introducerId ? { clientId: introducerId, displayName: introducerName, cifNumber: introducerCif } : null
|
||||
"
|
||||
(introducerChange)="onIntroducerChange($event)"
|
||||
[disabled]="readOnly"
|
||||
></mifosx-introducer-search>
|
||||
}
|
||||
}
|
||||
@@ -223,7 +228,7 @@
|
||||
color="primary"
|
||||
type="button"
|
||||
class="soa-add-btn"
|
||||
[disabled]="totalJointSharePercentage >= 100"
|
||||
[disabled]="totalJointSharePercentage >= 100 || readOnly"
|
||||
(click)="openAddJointHolderDialog()"
|
||||
>
|
||||
<mat-icon>person_add</mat-icon> Add Holder
|
||||
@@ -269,6 +274,7 @@
|
||||
max="100"
|
||||
[(ngModel)]="primaryHolderShare"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="readOnly"
|
||||
placeholder="0"
|
||||
(input)="onPrimaryShareInput($event)"
|
||||
(blur)="onPrimaryShareBlur($event)"
|
||||
@@ -320,7 +326,8 @@
|
||||
class="soa-sign-toggle"
|
||||
[class.soa-sign-toggle--mandatory]="h.isMandatory"
|
||||
[class.soa-sign-toggle--optional]="!h.isMandatory"
|
||||
(click)="toggleJointHolderMandatory(h)"
|
||||
[disabled]="readOnly"
|
||||
(click)="!readOnly && toggleJointHolderMandatory(h)"
|
||||
>
|
||||
<mat-icon>{{ h.isMandatory ? 'lock' : 'lock_open' }}</mat-icon>
|
||||
{{ h.isMandatory ? 'Mandatory' : 'Optional' }}
|
||||
@@ -328,7 +335,13 @@
|
||||
</td>
|
||||
<td class="soa-td soa-td--center">Co-Holder</td>
|
||||
<td class="soa-td soa-td--center">
|
||||
<button mat-icon-button color="warn" type="button" (click)="removeJointHolder(i)">
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
type="button"
|
||||
[disabled]="readOnly"
|
||||
(click)="removeJointHolder(i)"
|
||||
>
|
||||
<mat-icon>delete_outline</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
@@ -410,7 +423,8 @@
|
||||
type="button"
|
||||
class="soa-mode-card"
|
||||
[class.soa-mode-card--selected]="modeOfOperation === opt.value"
|
||||
(click)="modeOfOperation = opt.value"
|
||||
[disabled]="readOnly"
|
||||
(click)="!readOnly && (modeOfOperation = opt.value)"
|
||||
>
|
||||
<mat-icon class="soa-mode-card__icon">{{ opt.icon }}</mat-icon>
|
||||
<div class="soa-mode-card__code">{{ opt.label }}</div>
|
||||
@@ -465,8 +479,9 @@
|
||||
[max]="maxDate"
|
||||
[(ngModel)]="minorDob"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="readOnly"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="minorDobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker-toggle matSuffix [for]="minorDobPicker" [disabled]="readOnly"></mat-datepicker-toggle>
|
||||
<mat-datepicker #minorDobPicker></mat-datepicker>
|
||||
@if (clientDob) {
|
||||
<mat-hint>Auto-filled from client profile</mat-hint>
|
||||
@@ -487,7 +502,11 @@
|
||||
|
||||
<mat-form-field appearance="outline" class="soa-field">
|
||||
<mat-label>Guardian Relationship</mat-label>
|
||||
<mat-select [(ngModel)]="guardianRelationship" [ngModelOptions]="{ standalone: true }">
|
||||
<mat-select
|
||||
[(ngModel)]="guardianRelationship"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="readOnly"
|
||||
>
|
||||
@for (rel of guardianRelationshipOptions; track rel) {
|
||||
<mat-option [value]="rel">{{ rel }}</mat-option>
|
||||
}
|
||||
@@ -503,6 +522,7 @@
|
||||
<mifosx-introducer-search
|
||||
[excludedClientIds]="primaryClientId ? [primaryClientId] : []"
|
||||
(introducerChange)="onGuardianChange($event)"
|
||||
[disabled]="readOnly"
|
||||
></mifosx-introducer-search>
|
||||
|
||||
@if (guardianName) {
|
||||
@@ -630,7 +650,8 @@
|
||||
class="soa-cat-tile"
|
||||
[class.soa-cat-tile--selected]="selectedSpecialCategory === opt.value"
|
||||
[class.soa-cat-tile--male]="true"
|
||||
(click)="selectedSpecialCategory = opt.value"
|
||||
[disabled]="readOnly"
|
||||
(click)="!readOnly && (selectedSpecialCategory = opt.value)"
|
||||
>
|
||||
<mat-icon class="soa-cat-tile__icon">{{ opt.icon }}</mat-icon>
|
||||
<span class="soa-cat-tile__label">{{ opt.label }}</span>
|
||||
@@ -680,7 +701,8 @@
|
||||
class="soa-cat-tile"
|
||||
[class.soa-cat-tile--selected]="selectedWeakerSection === opt.value"
|
||||
[class.soa-cat-tile--female]="true"
|
||||
(click)="selectedWeakerSection = opt.value"
|
||||
[disabled]="readOnly"
|
||||
(click)="!readOnly && (selectedWeakerSection = opt.value)"
|
||||
>
|
||||
<mat-icon class="soa-cat-tile__icon">{{ opt.icon }}</mat-icon>
|
||||
<span class="soa-cat-tile__label">{{ opt.label }}</span>
|
||||
|
||||
+5
@@ -53,6 +53,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
|
||||
|
||||
/** Savings Account Template */
|
||||
@Input() savingsAccountTemplate: any;
|
||||
@Input() readOnly = false;
|
||||
|
||||
minDate = new Date(2000, 0, 1);
|
||||
maxDate = new Date();
|
||||
@@ -219,6 +220,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
}
|
||||
if (this.readOnly) {
|
||||
this.savingsAccountDetailsForm.disable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,6 +281,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
|
||||
}
|
||||
|
||||
isTileDisabled(value: string): boolean {
|
||||
if (this.readOnly) return true;
|
||||
if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18;
|
||||
if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60;
|
||||
return false;
|
||||
|
||||
@@ -122,6 +122,7 @@ export class SavingsAccountViewComponent implements OnInit {
|
||||
'Add Charge': 'admin',
|
||||
'Apply Annual Fees': 'admin',
|
||||
'Modify Application': 'admin',
|
||||
'Edit Nominee & Features': 'admin',
|
||||
'Assign Staff': 'admin',
|
||||
'Unassign Staff': 'admin',
|
||||
Approve: 'admin',
|
||||
@@ -156,6 +157,7 @@ export class SavingsAccountViewComponent implements OnInit {
|
||||
'Add Charge': 'plus-circle',
|
||||
'Apply Annual Fees': 'receipt',
|
||||
'Modify Application': 'edit',
|
||||
'Edit Nominee & Features': 'edit',
|
||||
'Assign Staff': 'user-plus',
|
||||
'Unassign Staff': 'user-slash',
|
||||
Approve: 'check-circle',
|
||||
@@ -311,6 +313,9 @@ export class SavingsAccountViewComponent implements OnInit {
|
||||
case 'Modify Application':
|
||||
this.router.navigate(['edit'], { relativeTo: this.route });
|
||||
break;
|
||||
case 'Edit Nominee & Features':
|
||||
this.router.navigate(['edit-nominee-features'], { relativeTo: this.route });
|
||||
break;
|
||||
case 'Delete':
|
||||
this.deleteSavingsAccount();
|
||||
break;
|
||||
|
||||
@@ -161,6 +161,10 @@ export class SavingsButtonsConfiguration {
|
||||
switch (status) {
|
||||
case 'Active':
|
||||
this.optionArray = [
|
||||
{
|
||||
name: 'Edit Nominee & Features',
|
||||
taskPermissionName: 'SUBMITEDIT_SAVINGSACCOUNT'
|
||||
},
|
||||
{
|
||||
name: 'Post Interest',
|
||||
taskPermissionName: 'POSTINTEREST_SAVINGSACCOUNT'
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ExportTransactionsComponent } from './savings-account-view/transactions
|
||||
import { EditTransactionComponent } from './savings-account-view/transactions/edit-transaction/edit-transaction.component';
|
||||
import { CreateGsimAccountComponent } from './gsim-account/create-gsim-account/create-gsim-account.component';
|
||||
import { GsimAccountComponent } from './gsim-account/gsim-account.component';
|
||||
import { EditSavingsAccountFeaturesComponent } from './edit-savings-account-features/edit-savings-account-features.component';
|
||||
|
||||
/** Custom Resolvers */
|
||||
import { SavingsAccountViewResolver } from './common-resolvers/savings-account-view.resolver';
|
||||
@@ -171,6 +172,18 @@ const routes: Routes = [
|
||||
savingsAccountAndTemplate: SavingsAccountAndTemplateResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':savingAccountId/edit-nominee-features',
|
||||
data: {
|
||||
title: 'Edit Nominee & Features',
|
||||
breadcrumb: 'Edit Nominee & Features',
|
||||
routeParamBreadcrumb: false
|
||||
},
|
||||
component: EditSavingsAccountFeaturesComponent,
|
||||
resolve: {
|
||||
savingsAccountAndTemplate: SavingsAccountAndTemplateResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':savingAccountId/transactions/:id',
|
||||
data: { title: 'Savings Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false },
|
||||
|
||||
@@ -63,6 +63,7 @@ import { SavingsTransactionDatatableTabComponent } from './savings-account-view/
|
||||
import { GeneralTabComponent } from './savings-account-view/general-tab/general-tab.component';
|
||||
import { SavingsActiveClientMembersComponent } from './savings-account-stepper/savings-active-client-members/savings-active-client-members.component';
|
||||
import { PassbookTabComponent } from './savings-account-view/passbook-tab/passbook-tab.component';
|
||||
import { EditSavingsAccountFeaturesComponent } from './edit-savings-account-features/edit-savings-account-features.component';
|
||||
|
||||
/**
|
||||
* Savings Module
|
||||
@@ -121,7 +122,8 @@ import { PassbookTabComponent } from './savings-account-view/passbook-tab/passbo
|
||||
SavingsTransactionDatatableTabComponent,
|
||||
GeneralTabComponent,
|
||||
SavingsActiveClientMembersComponent,
|
||||
PassbookTabComponent
|
||||
PassbookTabComponent,
|
||||
EditSavingsAccountFeaturesComponent
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
|
||||
@@ -381,4 +381,49 @@ export class SavingsService {
|
||||
loadSavingsDocument(savingAccountId: any, data: any): Observable<any> {
|
||||
return this.http.post(`/savings/${savingAccountId}/documents`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a maker-checker edit request (Nominee and/or Digital & Account Features change) for a
|
||||
* savings account. The edit is held for checker approval before it is actually applied.
|
||||
* @param {string} accountId Savings Account Id
|
||||
* @param {any} data Edit payload — `accountFacilityConfig` and/or `nominee`, plus locale/dateFormat
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
submitSavingsAccountEdit(accountId: string, data: any): Observable<any> {
|
||||
return this.http.post(`/savingsaccounts/${accountId}/edit`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} accountId Savings Account Id
|
||||
* @returns {Observable<any>} the pending edit for this account, if any.
|
||||
*/
|
||||
getPendingSavingsAccountEdit(accountId: string): Observable<any> {
|
||||
return this.http.get(`/savingsaccounts/${accountId}/edit/pending`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<any>} all savings account edits currently awaiting checker approval.
|
||||
*/
|
||||
getAllPendingSavingsAccountEdits(): Observable<any> {
|
||||
return this.http.get(`/savingsaccounts/edit/pending`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approves a pending savings account edit, releasing any hold and applying the proposed changes.
|
||||
* @param {string} accountId Savings Account Id
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
approveSavingsAccountEdit(accountId: string): Observable<any> {
|
||||
return this.http.post(`/savingsaccounts/${accountId}/edit/approve`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a pending savings account edit, releasing any hold without applying the changes.
|
||||
* @param {string} accountId Savings Account Id
|
||||
* @param {string} rejectReason Optional reason for rejection
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
rejectSavingsAccountEdit(accountId: string, rejectReason?: string): Observable<any> {
|
||||
return this.http.post(`/savingsaccounts/${accountId}/edit/reject`, { rejectReason: rejectReason || '' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,37 @@
|
||||
<p class="nom-page-header__sub">Manage nominees for this account. Nominees are account-specific.</p>
|
||||
</div>
|
||||
</div>
|
||||
@if (!loading && !nomineesLoading && totalShare < 100 && !showAddForm) {
|
||||
@if (!loading && !nomineesLoading && totalShare < 100 && !showAddForm && !hasPendingEdit) {
|
||||
<button mat-flat-button color="primary" class="nom-add-btn" (click)="showAddForm = true">
|
||||
<mat-icon>add</mat-icon> Add Nominee
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (pendingEditLoading) {
|
||||
<div class="esf-loading">
|
||||
<mat-progress-spinner diameter="20" mode="indeterminate"></mat-progress-spinner>
|
||||
<span>Checking for pending edits...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!pendingEditLoading && hasPendingEdit) {
|
||||
<div class="esf-pending-banner">
|
||||
<mat-icon>hourglass_top</mat-icon>
|
||||
<div class="esf-pending-banner__text">
|
||||
<strong>An edit is already pending approval for this account.</strong>
|
||||
<span>
|
||||
Submitted by {{ pendingEdit.makerUsername || 'a maker' }} on {{ pendingEdit.createdOn | dateFormat }}. Status:
|
||||
<span class="esf-pending-banner__status">{{ pendingEdit.status }}</span>
|
||||
@if (pendingEdit.holdAmount) {
|
||||
· Hold placed: {{ pendingEdit.holdAmount }}
|
||||
}
|
||||
</span>
|
||||
<span>You cannot submit another edit until this one is approved or rejected.</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="nom-loading">
|
||||
<mat-progress-spinner diameter="24" mode="indeterminate"></mat-progress-spinner>
|
||||
@@ -98,18 +122,20 @@
|
||||
<button mat-icon-button type="button" matTooltip="View" (click)="viewNominee(nominee)">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button type="button" matTooltip="Edit" (click)="editNominee(nominee, i)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
matTooltip="Delete"
|
||||
class="nom-card__delete"
|
||||
(click)="deleteNominee(nominee, i)"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
@if (!hasPendingEdit) {
|
||||
<button mat-icon-button type="button" matTooltip="Edit" (click)="editNominee(nominee, i)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
matTooltip="Delete"
|
||||
class="nom-card__delete"
|
||||
(click)="deleteNominee(nominee, i)"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (nominee.nomineeIsMinor && (nominee.guardianDisplayName || nominee.guardianName)) {
|
||||
|
||||
@@ -645,3 +645,56 @@ $primary-light: #e8f0fe;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
$border: #e2e8f0;
|
||||
$muted: #64748b;
|
||||
$amber: #92400e;
|
||||
$amber-bg: #fffbeb;
|
||||
$amber-border: #fde68a;
|
||||
|
||||
.esf-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 0;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
|
||||
&--sm {
|
||||
padding: 10px 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.esf-pending-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: $amber-bg;
|
||||
border: 1px solid $amber-border;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
color: $amber;
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12.5px;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||
import { DateFormatPipe } from '../../pipes/date-format.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SavingsService } from 'app/savings/savings.service';
|
||||
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
|
||||
import { CurrentAccountsService } from 'app/current-accounts/current-accounts.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-account-nominees-tab',
|
||||
@@ -41,11 +44,14 @@ export class AccountNomineesTabComponent implements OnInit {
|
||||
private clientsService = inject(ClientsService);
|
||||
private dialog = inject(MatDialog);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
private savingsService = inject(SavingsService);
|
||||
private currentAccountsService = inject(CurrentAccountsService);
|
||||
|
||||
/** Set by route data */
|
||||
accountDataKey: string = '';
|
||||
accountType: string = '';
|
||||
|
||||
accountId: string = '';
|
||||
clientId: string = '';
|
||||
accountNo: string = '';
|
||||
loading = true;
|
||||
@@ -53,6 +59,9 @@ export class AccountNomineesTabComponent implements OnInit {
|
||||
nominees: any[] = [];
|
||||
nomineesLoading = false;
|
||||
|
||||
pendingEdit: any = null;
|
||||
pendingEditLoading = true;
|
||||
|
||||
showAddForm = false;
|
||||
searchText = '';
|
||||
searchResults: any[] = [];
|
||||
@@ -110,15 +119,45 @@ export class AccountNomineesTabComponent implements OnInit {
|
||||
this.route.parent.data.subscribe((data: any) => {
|
||||
const accountData = data[this.accountDataKey];
|
||||
if (accountData) {
|
||||
this.accountId = accountData.id?.toString() || this.route.parent.snapshot.params['savingAccountId'] || '';
|
||||
this.clientId = accountData.clientId?.toString() || '';
|
||||
this.accountNo = accountData.accountNo || '';
|
||||
this.loading = false;
|
||||
this.loadPendingEdit();
|
||||
this.loadNominees();
|
||||
this.loadTemplate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadPendingEdit(): void {
|
||||
this.pendingEditLoading = true;
|
||||
const fetch$ =
|
||||
this.accountType === 'CB'
|
||||
? this.currentAccountsService.getPendingCurrentAccountEdit(this.accountId)
|
||||
: this.savingsService.getPendingSavingsAccountEdit(this.accountId);
|
||||
fetch$.subscribe({
|
||||
next: (edit: any) => {
|
||||
this.pendingEdit = edit && edit.id ? edit : null;
|
||||
this.pendingEditLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.pendingEdit = null;
|
||||
this.pendingEditLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get hasPendingEdit(): boolean {
|
||||
return !!this.pendingEdit && this.pendingEdit.status === 'PENDING';
|
||||
}
|
||||
|
||||
private todayIso(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
private loadTemplate(): void {
|
||||
this.clientsService.getClientNomineeTemplate(this.clientId).subscribe({
|
||||
next: (template: any) => {
|
||||
@@ -356,27 +395,34 @@ export class AccountNomineesTabComponent implements OnInit {
|
||||
if (this.guardianSameAsNominee) return;
|
||||
|
||||
this.nomineeSaving = true;
|
||||
const payload: any = {
|
||||
const nomineePayload: any = {
|
||||
operation: 'ADD',
|
||||
nomineeId: null,
|
||||
nomineeClientId: this.selectedClient.id,
|
||||
accountNo: this.accountNo,
|
||||
accountType: this.accountType,
|
||||
nominationType: this.nomineeNomType,
|
||||
relationshipId: this.nomineeRelationshipId,
|
||||
percentageShare: this.nomineeShare,
|
||||
isActive: true,
|
||||
nomineeIsMinor: this.nomineeIsMinor,
|
||||
remarks: this.nomineeRemarks || null,
|
||||
locale: 'en'
|
||||
guardianClientId: this.nomineeIsMinor && this.selectedGuardian ? this.selectedGuardian.id : null,
|
||||
guardianRelationshipId: null,
|
||||
effectiveDate: this.todayIso(),
|
||||
remarks: this.nomineeRemarks || null
|
||||
};
|
||||
|
||||
if (this.nomineeIsMinor && this.selectedGuardian) {
|
||||
payload.guardianClientId = this.selectedGuardian.id;
|
||||
}
|
||||
const payload = {
|
||||
locale: 'en',
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
nominee: nomineePayload
|
||||
};
|
||||
|
||||
this.clientsService.addNominee(this.clientId, payload).subscribe({
|
||||
const submit$ =
|
||||
this.accountType === 'CB'
|
||||
? this.currentAccountsService.submitCurrentAccountEdit(this.accountId, payload)
|
||||
: this.savingsService.submitSavingsAccountEdit(this.accountId, payload);
|
||||
submit$.subscribe({
|
||||
next: () => {
|
||||
this.nomineeSaving = false;
|
||||
this.loadNominees();
|
||||
this.resetAddForm();
|
||||
this.showSuccess();
|
||||
},
|
||||
error: () => {
|
||||
this.nomineeSaving = false;
|
||||
@@ -384,6 +430,24 @@ export class AccountNomineesTabComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private showSuccess(): void {
|
||||
const ref = this.dialog.open(SuccessDialogComponent, {
|
||||
width: '420px',
|
||||
maxWidth: '92vw',
|
||||
autoFocus: false,
|
||||
data: {
|
||||
title: 'Submitted for Approval',
|
||||
message: 'Your changes have been submitted and are awaiting checker approval.',
|
||||
buttonLabel: 'Done'
|
||||
}
|
||||
});
|
||||
ref.afterClosed().subscribe(() => {
|
||||
this.loadPendingEdit();
|
||||
this.loadNominees();
|
||||
this.resetAddForm();
|
||||
});
|
||||
}
|
||||
|
||||
viewNominee(nominee: any): void {
|
||||
const clientId = nominee.nomineeClientId || nominee.linkedClientId || nominee.clientId;
|
||||
const displayName = nominee.nomineeDisplayName || nominee.firstName || '';
|
||||
@@ -432,17 +496,33 @@ export class AccountNomineesTabComponent implements OnInit {
|
||||
if (!this.editRelId || !this.editShare || this.editShare < 1 || this.editShare > this.maxShareForEdit(index))
|
||||
return;
|
||||
|
||||
const payload: any = {
|
||||
const nomineePayload: any = {
|
||||
operation: 'UPDATE',
|
||||
nomineeId: nominee.id,
|
||||
relationshipId: this.editRelId,
|
||||
percentageShare: this.editShare,
|
||||
nominationType: this.editNomType,
|
||||
locale: 'en'
|
||||
isActive: nominee.isActive !== undefined ? !!nominee.isActive : true,
|
||||
nomineeIsMinor: !!nominee.nomineeIsMinor,
|
||||
guardianClientId: nominee.nomineeIsMinor ? nominee.guardianClientId : null,
|
||||
guardianRelationshipId: nominee.nomineeIsMinor ? nominee.guardianRelationshipId : null,
|
||||
effectiveDate: this.todayIso(),
|
||||
remarks: nominee.remarks || null
|
||||
};
|
||||
|
||||
this.clientsService.updateNominee(this.clientId, nominee.id.toString(), payload).subscribe({
|
||||
const payload = {
|
||||
locale: 'en',
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
nominee: nomineePayload
|
||||
};
|
||||
|
||||
const submit$ =
|
||||
this.accountType === 'CB'
|
||||
? this.currentAccountsService.submitCurrentAccountEdit(this.accountId, payload)
|
||||
: this.savingsService.submitSavingsAccountEdit(this.accountId, payload);
|
||||
submit$.subscribe({
|
||||
next: () => {
|
||||
this.loadNominees();
|
||||
this.editingIndex = null;
|
||||
this.showSuccess();
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
@@ -454,9 +534,26 @@ export class AccountNomineesTabComponent implements OnInit {
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (response?.delete) {
|
||||
this.clientsService.deleteNominee(this.clientId, nominee.id.toString()).subscribe({
|
||||
next: () => this.nominees.splice(index, 1),
|
||||
error: () => this.nominees.splice(index, 1)
|
||||
const nomineePayload: any = {
|
||||
operation: 'DELETE',
|
||||
nomineeId: nominee.id
|
||||
};
|
||||
|
||||
const payload = {
|
||||
locale: 'en',
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
nominee: nomineePayload
|
||||
};
|
||||
|
||||
const submit$ =
|
||||
this.accountType === 'CB'
|
||||
? this.currentAccountsService.submitCurrentAccountEdit(this.accountId, payload)
|
||||
: this.savingsService.submitSavingsAccountEdit(this.accountId, payload);
|
||||
submit$.subscribe({
|
||||
next: () => {
|
||||
this.showSuccess();
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 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 { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
|
||||
export interface CustomerSearchResult {
|
||||
clientId: number;
|
||||
displayName: string;
|
||||
mobileNo: string | null;
|
||||
officeName: string | null;
|
||||
accountNo: string | null;
|
||||
accountType: 'SAVINGS' | 'CURRENT' | 'FIXED_DEPOSIT' | 'RECURRING_DEPOSIT' | null;
|
||||
panNumber: string | null;
|
||||
aadhaarNumber: string | null;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-customer-search-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatCheckboxModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Search Customer</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="hint-text">
|
||||
Fill in any one field, or combine several to narrow the search (e.g. name + account number).
|
||||
</p>
|
||||
<form [formGroup]="form" class="search-form">
|
||||
<mat-form-field appearance="outline" class="field">
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="Customer name" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="field">
|
||||
<mat-label>Account Number</mat-label>
|
||||
<input matInput formControlName="accountNumber" placeholder="Savings / Current / FD / RD" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="field">
|
||||
<mat-label>Aadhaar Number</mat-label>
|
||||
<input matInput formControlName="aadhaarNumber" placeholder="12-digit Aadhaar" maxlength="12" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="field">
|
||||
<mat-label>PAN Number</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="panNumber"
|
||||
placeholder="e.g. ABCDE1234F"
|
||||
maxlength="10"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="field field--wide">
|
||||
<mat-label>Address</mat-label>
|
||||
<input matInput formControlName="address" placeholder="Street, town, city, or postal code" />
|
||||
</mat-form-field>
|
||||
</form>
|
||||
|
||||
<div class="account-type-filter" [formGroup]="form">
|
||||
<span class="account-type-filter__label">Account Type</span>
|
||||
<mat-checkbox formControlName="savings">Savings</mat-checkbox>
|
||||
<mat-checkbox formControlName="current">Current</mat-checkbox>
|
||||
<mat-checkbox formControlName="fixedDeposit">Fixed Deposit</mat-checkbox>
|
||||
<mat-checkbox formControlName="recurringDeposit">Recurring Deposit</mat-checkbox>
|
||||
<span class="account-type-filter__hint">(leave all unchecked to search every account type)</span>
|
||||
</div>
|
||||
|
||||
<div class="results" *ngIf="searched">
|
||||
<p *ngIf="!loading && results.length === 0" class="no-results">No matching customer found.</p>
|
||||
|
||||
<table mat-table [dataSource]="results" *ngIf="results.length > 0">
|
||||
<ng-container matColumnDef="displayName">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.displayName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="account">
|
||||
<th mat-header-cell *matHeaderCellDef>Account</th>
|
||||
<td mat-cell *matCellDef="let r">
|
||||
<span *ngIf="r.accountNo">{{ r.accountNo }} ({{ accountTypeLabel(r.accountType) }})</span>
|
||||
<span *ngIf="!r.accountNo" class="muted">No account</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="mobileNo">
|
||||
<th mat-header-cell *matHeaderCellDef>Mobile</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.mobileNo || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef>Branch</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.officeName || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="panNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>PAN</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.panNumber || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="aadhaarNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>Aadhaar</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.aadhaarNumber || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="address">
|
||||
<th mat-header-cell *matHeaderCellDef>Address</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.address || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let r">
|
||||
<button mat-flat-button color="primary" [disabled]="!r.accountNo" (click)="onSelect(r)">Select</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onClose()">Close</button>
|
||||
<button mat-flat-button color="primary" [disabled]="loading" (click)="onSearch()">Search</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.hint-text {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 16px;
|
||||
}
|
||||
.field {
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
.field--wide {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.account-type-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin: 4px 0 16px 0;
|
||||
}
|
||||
.account-type-filter__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
.account-type-filter__hint {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.results {
|
||||
margin-top: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
td {
|
||||
color: #334155;
|
||||
}
|
||||
.muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.no-results {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
padding: 16px 0;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class CustomerSearchDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private http = inject(HttpClient);
|
||||
private dialogRef = inject(MatDialogRef<CustomerSearchDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
form!: FormGroup;
|
||||
loading = false;
|
||||
searched = false;
|
||||
results: CustomerSearchResult[] = [];
|
||||
displayedColumns = [
|
||||
'displayName',
|
||||
'account',
|
||||
'mobileNo',
|
||||
'officeName',
|
||||
'panNumber',
|
||||
'aadhaarNumber',
|
||||
'address',
|
||||
'select'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
name: [''],
|
||||
accountNumber: [''],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: [''],
|
||||
address: [''],
|
||||
savings: [false],
|
||||
current: [false],
|
||||
fixedDeposit: [false],
|
||||
recurringDeposit: [false]
|
||||
});
|
||||
}
|
||||
|
||||
accountTypeLabel(type: string | null): string {
|
||||
switch (type) {
|
||||
case 'SAVINGS':
|
||||
return 'Savings';
|
||||
case 'CURRENT':
|
||||
return 'Current';
|
||||
case 'FIXED_DEPOSIT':
|
||||
return 'FD';
|
||||
case 'RECURRING_DEPOSIT':
|
||||
return 'RD';
|
||||
default:
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
onSearch(): void {
|
||||
const v = this.form.value;
|
||||
const accountTypes: string[] = [];
|
||||
if (v.savings) accountTypes.push('SAVINGS');
|
||||
if (v.current) accountTypes.push('CURRENT');
|
||||
if (v.fixedDeposit) accountTypes.push('FIXED_DEPOSIT');
|
||||
if (v.recurringDeposit) accountTypes.push('RECURRING_DEPOSIT');
|
||||
|
||||
if (!v.name && !v.accountNumber && !v.aadhaarNumber && !v.panNumber && !v.address && accountTypes.length === 0) {
|
||||
this.snackBar.open('Enter at least one search field, or check an account type.', 'Close', { duration: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
let params = new HttpParams();
|
||||
if (v.name) params = params.set('name', v.name);
|
||||
if (v.accountNumber) params = params.set('accountNumber', v.accountNumber);
|
||||
if (v.aadhaarNumber) params = params.set('aadhaarNumber', v.aadhaarNumber);
|
||||
if (v.panNumber) params = params.set('panNumber', v.panNumber);
|
||||
if (v.address) params = params.set('address', v.address);
|
||||
if (accountTypes.length > 0) params = params.set('accountTypes', accountTypes.join(','));
|
||||
|
||||
this.http.get<CustomerSearchResult[]>('/customer-search', { params }).subscribe({
|
||||
next: (data) => {
|
||||
this.results = data || [];
|
||||
this.searched = true;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSelect(result: CustomerSearchResult): void {
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,13 @@
|
||||
<div class="intro-search-wrap">
|
||||
<mat-form-field appearance="outline" class="intro-search-field">
|
||||
<mat-label>Search by Name or Client ID</mat-label>
|
||||
<input matInput [value]="searchText" (input)="onSearch($any($event.target).value)" autocomplete="off" />
|
||||
<input
|
||||
matInput
|
||||
[value]="searchText"
|
||||
(input)="onSearch($any($event.target).value)"
|
||||
autocomplete="off"
|
||||
[disabled]="disabled"
|
||||
/>
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
@@ -86,9 +92,11 @@
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<button mat-icon-button type="button" (click)="removeClient()" class="intro-card__remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
@if (!disabled) {
|
||||
<button mat-icon-button type="button" (click)="removeClient()" class="intro-card__remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (fetchingLinkedData) {
|
||||
|
||||
@@ -33,6 +33,7 @@ export class IntroducerSearchComponent implements OnChanges {
|
||||
|
||||
@Input() excludedClientIds: number[] = [];
|
||||
@Input() preselectedIntroducer: { clientId: number; displayName: string; cifNumber?: string } | null = null;
|
||||
@Input() disabled = false;
|
||||
@Output() introducerChange = new EventEmitter<{ clientId: number; displayName: string; cifNumber?: string } | null>();
|
||||
|
||||
entryMode: 'linked' = 'linked';
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<!--
|
||||
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="detail-container">
|
||||
<div class="header-row">
|
||||
<button
|
||||
mat-button
|
||||
routerLink="/sponsor-dd-reconciliation"
|
||||
style="margin-bottom: 8px; color: #64748b; padding-left: 0"
|
||||
>
|
||||
← Back to Batches
|
||||
</button>
|
||||
<h2 *ngIf="batchDetails">Reconciliation Batch: {{ batchDetails.batchReference }}</h2>
|
||||
<p *ngIf="batchDetails">
|
||||
File: <strong>{{ batchDetails.fileName }}</strong> | Status: <strong>{{ batchDetails.status }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="batch-summary" *ngIf="batchDetails">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">Total Scroll Items</span>
|
||||
<span class="summary-value">{{ batchDetails.totalRecords }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">Total Amount</span>
|
||||
<span class="summary-value">₹{{ batchDetails.totalAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="summary-card" style="border-left: 4px solid #16a34a">
|
||||
<span class="summary-label" style="color: #16a34a">Auto Matched</span>
|
||||
<span class="summary-value" style="color: #16a34a">{{ batchDetails.successCount }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="summary-card"
|
||||
[style.border-left]="batchDetails.mismatchCount > 0 ? '4px solid #dc2626' : '4px solid #e2e8f0'"
|
||||
>
|
||||
<span class="summary-label" [style.color]="batchDetails.mismatchCount > 0 ? '#dc2626' : '#64748b'"
|
||||
>Exceptions</span
|
||||
>
|
||||
<span class="summary-value" [style.color]="batchDetails.mismatchCount > 0 ? '#dc2626' : '#334155'">{{
|
||||
batchDetails.mismatchCount
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-chips">
|
||||
<span style="font-size: 13px; font-weight: 500; color: #64748b">Filter Status:</span>
|
||||
<mat-chip-listbox>
|
||||
<mat-chip-option [selected]="selectedFilter === 'ALL'" (click)="applyFilter('ALL')">All</mat-chip-option>
|
||||
<mat-chip-option [selected]="selectedFilter === 'EXACT_MATCH'" (click)="applyFilter('EXACT_MATCH')"
|
||||
>Exact Match</mat-chip-option
|
||||
>
|
||||
<mat-chip-option [selected]="selectedFilter === 'AMOUNT_MISMATCH'" (click)="applyFilter('AMOUNT_MISMATCH')"
|
||||
>Amount Mismatch</mat-chip-option
|
||||
>
|
||||
<mat-chip-option [selected]="selectedFilter === 'UNADVISED_CLEARING'" (click)="applyFilter('UNADVISED_CLEARING')"
|
||||
>Unadvised Clearing</mat-chip-option
|
||||
>
|
||||
<mat-chip-option [selected]="selectedFilter === 'DUPLICATE_CLEARING'" (click)="applyFilter('DUPLICATE_CLEARING')"
|
||||
>Duplicate Clearing</mat-chip-option
|
||||
>
|
||||
</mat-chip-listbox>
|
||||
</div>
|
||||
|
||||
<mat-card>
|
||||
<table mat-table [dataSource]="filteredRecords">
|
||||
<!-- DD Number Column -->
|
||||
<ng-container matColumnDef="ddNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>DD Number</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<strong>{{ element.ddNumber }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Clearing Amount Column -->
|
||||
<ng-container matColumnDef="clearingAmount">
|
||||
<th mat-header-cell *matHeaderCellDef>Scroll Amount</th>
|
||||
<td mat-cell *matCellDef="let element">₹{{ element.clearingAmount | number: '1.2-2' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Clearing Date Column -->
|
||||
<ng-container matColumnDef="clearingDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Clearing Date</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.clearingDate }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Ref Number Column -->
|
||||
<ng-container matColumnDef="clearingRefNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>Clearing Ref No.</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.clearingRefNumber }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Match Status Column -->
|
||||
<ng-container matColumnDef="matchStatus">
|
||||
<th mat-header-cell *matHeaderCellDef>Match Status</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<span class="status-badge" [ngClass]="element.matchStatus.toLowerCase()">
|
||||
{{ element.matchStatus.replace('_', ' ') }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Remarks Column -->
|
||||
<ng-container matColumnDef="remarks">
|
||||
<th mat-header-cell *matHeaderCellDef>Remarks / Resolution</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.remarks || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
size="small"
|
||||
*ngIf="element.matchStatus !== 'EXACT_MATCH' && !element.remarks?.includes('Resolved')"
|
||||
(click)="openResolveDialog(element)"
|
||||
>
|
||||
Resolve Match
|
||||
</button>
|
||||
<span
|
||||
*ngIf="element.matchStatus === 'EXACT_MATCH' || element.remarks?.includes('Resolved')"
|
||||
style="color: #16a34a; font-size: 12px; font-weight: 500"
|
||||
>
|
||||
<fa-icon icon="check"></fa-icon> Reconciled
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<div
|
||||
*ngIf="filteredRecords.length === 0"
|
||||
class="no-records-row"
|
||||
style="text-align: center; padding: 32px; color: #64748b"
|
||||
>
|
||||
No scroll records found for the selected status filter.
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* 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 { ActivatedRoute, RouterModule } from '@angular/router';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { SponsorDDReconciliationService } from '../sponsor-dd-reconciliation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-reco-batch-detail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule,
|
||||
FaIconComponent
|
||||
],
|
||||
templateUrl: './reco-batch-detail.component.html',
|
||||
styles: [
|
||||
`
|
||||
.detail-container {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header-row {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header-row h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.batch-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.summary-card {
|
||||
background-color: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
.summary-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
.filter-chips {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.mat-mdc-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
td {
|
||||
color: #334155;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-badge.exact_match {
|
||||
background-color: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.status-badge.amount_mismatch {
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.status-badge.unadvised_clearing {
|
||||
background-color: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
.status-badge.duplicate_clearing {
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.action-btn {
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RecoBatchDetailComponent implements OnInit {
|
||||
private recoService = inject(SponsorDDReconciliationService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
batchId!: number;
|
||||
batchDetails: any = null;
|
||||
records: any[] = [];
|
||||
filteredRecords: any[] = [];
|
||||
selectedFilter: string = 'ALL';
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'ddNumber',
|
||||
'clearingAmount',
|
||||
'clearingDate',
|
||||
'clearingRefNumber',
|
||||
'matchStatus',
|
||||
'remarks',
|
||||
'actions'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.batchId = +this.route.snapshot.params['batchId'];
|
||||
this.loadBatchDetails();
|
||||
this.loadRecords();
|
||||
}
|
||||
|
||||
loadBatchDetails(): void {
|
||||
this.recoService.getReconciliationBatch(this.batchId).subscribe({
|
||||
next: (data) => {
|
||||
this.batchDetails = data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadRecords(): void {
|
||||
this.recoService.getReconciliationRecords(this.batchId).subscribe({
|
||||
next: (data) => {
|
||||
this.records = data;
|
||||
this.applyFilter(this.selectedFilter);
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Error loading batch records: ' + (err.error?.message || err.message), 'Close', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter(filter: string): void {
|
||||
this.selectedFilter = filter;
|
||||
if (filter === 'ALL') {
|
||||
this.filteredRecords = this.records;
|
||||
} else {
|
||||
this.filteredRecords = this.records.filter((r) => r.matchStatus === filter);
|
||||
}
|
||||
}
|
||||
|
||||
openResolveDialog(record: any): void {
|
||||
const dialogRef = this.dialog.open(ResolveMismatchDialogComponent, {
|
||||
width: '500px',
|
||||
data: { record },
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
this.loadBatchDetails();
|
||||
this.loadRecords();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── RESOLVE MISMATCH DIALOG COMPONENT ──
|
||||
@Component({
|
||||
selector: 'mifosx-resolve-mismatch-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Resolve Clearing Exception (DD No: {{ data.record.ddNumber }})</h2>
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="resolveForm" class="dialog-form">
|
||||
<p>
|
||||
Clearing Item status is <strong>{{ data.record.matchStatus.replace('_', ' ') }}</strong
|
||||
>. Sponsor Scroll Amount: <strong>₹{{ data.record.clearingAmount | number: '1.2-2' }}</strong
|
||||
>.
|
||||
</p>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Resolution Action</mat-label>
|
||||
<mat-select formControlName="actionType">
|
||||
<mat-option value="FORCE_MATCH">Force Match (Map to Customer Debit)</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="resolveForm.get('actionType')?.hasError('required')">Action is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Savings Transaction ID</mat-label>
|
||||
<input matInput type="number" formControlName="resolvedSavingsTxnId" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Current Transaction ID</mat-label>
|
||||
<input matInput type="number" formControlName="resolvedCurrentTxnId" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Supervisor Authorization Remarks</mat-label>
|
||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||
<mat-error *ngIf="resolveForm.get('remarks')?.hasError('required')">Remarks are required</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Cancel</button>
|
||||
<button mat-flat-button color="primary" [disabled]="resolveForm.invalid || loading" (click)="onSubmit()">
|
||||
Resolve Mismatch
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.half-width {
|
||||
flex: 1;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class ResolveMismatchDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private recoService = inject(SponsorDDReconciliationService);
|
||||
private dialogRef = inject(MatDialogRef<ResolveMismatchDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
protected data = inject(MAT_DIALOG_DATA);
|
||||
|
||||
resolveForm!: FormGroup;
|
||||
loading = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.resolveForm = this.fb.group({
|
||||
actionType: [
|
||||
'FORCE_MATCH',
|
||||
Validators.required
|
||||
],
|
||||
resolvedSavingsTxnId: [null],
|
||||
resolvedCurrentTxnId: [null],
|
||||
remarks: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.resolveForm.invalid) return;
|
||||
|
||||
this.loading = true;
|
||||
const formVal = this.resolveForm.value;
|
||||
|
||||
const payload = {
|
||||
actionType: formVal.actionType,
|
||||
resolvedSavingsTxnId: formVal.resolvedSavingsTxnId ? formVal.resolvedSavingsTxnId : undefined,
|
||||
resolvedCurrentTxnId: formVal.resolvedCurrentTxnId ? formVal.resolvedCurrentTxnId : undefined
|
||||
};
|
||||
|
||||
this.recoService.resolveMismatch(this.data.record.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Mismatch resolved successfully', 'Close', { duration: 3000 });
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<!--
|
||||
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="reco-container">
|
||||
<div class="header-row">
|
||||
<div class="title-area">
|
||||
<h2>Demand Draft Reconciliation</h2>
|
||||
<p>
|
||||
Upload and process daily clearing scroll statements from the Sponsor Bank to auto-match and reconcile issued
|
||||
drafts.
|
||||
</p>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" [routerLink]="['/sponsor-dd-reconciliation/upload']">
|
||||
Upload Clearing Scroll
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-card>
|
||||
<table mat-table [dataSource]="dataSource">
|
||||
<!-- Batch Reference Column -->
|
||||
<ng-container matColumnDef="batchReference">
|
||||
<th mat-header-cell *matHeaderCellDef>Batch Reference</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<strong>{{ element.batchReference }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- File Name Column -->
|
||||
<ng-container matColumnDef="fileName">
|
||||
<th mat-header-cell *matHeaderCellDef>Clearing File</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.fileName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<span class="status-badge" [ngClass]="element.status.toLowerCase()">
|
||||
{{ element.status.replace('_', ' ') }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Total Records Column -->
|
||||
<ng-container matColumnDef="totalRecords">
|
||||
<th mat-header-cell *matHeaderCellDef>Total Items</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.totalRecords }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Total Amount Column -->
|
||||
<ng-container matColumnDef="totalAmount">
|
||||
<th mat-header-cell *matHeaderCellDef>Total Amount</th>
|
||||
<td mat-cell *matCellDef="let element">₹{{ element.totalAmount | number: '1.2-2' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Success Count Column -->
|
||||
<ng-container matColumnDef="successCount">
|
||||
<th mat-header-cell *matHeaderCellDef>Matched</th>
|
||||
<td mat-cell *matCellDef="let element" style="color: #15803d; font-weight: 500">
|
||||
{{ element.successCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Mismatch Count Column -->
|
||||
<ng-container matColumnDef="mismatchCount">
|
||||
<th mat-header-cell *matHeaderCellDef>Exceptions</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let element"
|
||||
[style.color]="element.mismatchCount > 0 ? '#b91c1c' : '#475569'"
|
||||
[style.font-weight]="element.mismatchCount > 0 ? '600' : '400'"
|
||||
>
|
||||
{{ element.mismatchCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Created Date Column -->
|
||||
<ng-container matColumnDef="createdDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Uploaded On</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.createdDate | date: 'medium' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<button mat-button class="action-btn" [routerLink]="['/sponsor-dd-reconciliation/batches', element.id]">
|
||||
View Details
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<div
|
||||
*ngIf="dataSource.length === 0"
|
||||
class="no-records-row"
|
||||
style="text-align: center; padding: 32px; color: #64748b"
|
||||
>
|
||||
No clearing files uploaded yet. Click "Upload Clearing Scroll" to reconcile clearing sheets.
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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 { RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { SponsorDDReconciliationService } from '../sponsor-dd-reconciliation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-reco-batch-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule,
|
||||
FaIconComponent
|
||||
],
|
||||
templateUrl: './reco-batch-list.component.html',
|
||||
styles: [
|
||||
`
|
||||
.reco-container {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.title-area h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.title-area p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mat-mdc-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
td {
|
||||
color: #334155;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-badge.reconciled {
|
||||
background-color: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.status-badge.partially_reconciled {
|
||||
background-color: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
.status-badge.processing {
|
||||
background-color: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.status-badge.failed {
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.action-btn {
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RecoBatchListComponent implements OnInit {
|
||||
private recoService = inject(SponsorDDReconciliationService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'batchReference',
|
||||
'fileName',
|
||||
'status',
|
||||
'totalRecords',
|
||||
'totalAmount',
|
||||
'successCount',
|
||||
'mismatchCount',
|
||||
'createdDate',
|
||||
'actions'
|
||||
];
|
||||
dataSource: any[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBatches();
|
||||
}
|
||||
|
||||
loadBatches(): void {
|
||||
this.recoService.getReconciliationBatches().subscribe({
|
||||
next: (data) => {
|
||||
this.dataSource = data;
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Error loading reconciliation batches: ' + (err.error?.message || err.message), 'Close', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
<div class="upload-container">
|
||||
<div class="header-row">
|
||||
<button
|
||||
mat-button
|
||||
routerLink="/sponsor-dd-reconciliation"
|
||||
style="margin-bottom: 8px; color: #64748b; padding-left: 0"
|
||||
>
|
||||
← Back to Batches
|
||||
</button>
|
||||
<h2>Upload Sponsor Clearing Scroll</h2>
|
||||
<p>Upload a CSV format clearing scroll file containing Sponsor Bank draft payments.</p>
|
||||
</div>
|
||||
|
||||
<mat-card>
|
||||
<div class="dropzone" (click)="triggerFileSelect(fileInput)">
|
||||
<input type="file" #fileInput (change)="onFileSelected($event)" accept=".csv" style="display: none" />
|
||||
<div class="dropzone-icon">
|
||||
<fa-icon icon="upload"></fa-icon>
|
||||
</div>
|
||||
<h3>Choose a clearing file or drag & drop it here</h3>
|
||||
<p style="color: #64748b; font-size: 13px">Supports only .csv format files.</p>
|
||||
</div>
|
||||
|
||||
<div class="file-details" *ngIf="selectedFile">
|
||||
<div>
|
||||
<div class="file-name">{{ selectedFile.name }}</div>
|
||||
<div class="file-size">{{ selectedFile.size / 1024 | number: '1.1-1' }} KB</div>
|
||||
</div>
|
||||
<button mat-icon-button color="warn" (click)="selectedFile = null" [disabled]="loading">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="progress-section" *ngIf="loading">
|
||||
<mat-progress-bar mode="indeterminate" color="primary"></mat-progress-bar>
|
||||
<div class="progress-text">{{ progressMessage }}</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button mat-button routerLink="/sponsor-dd-reconciliation" [disabled]="loading">Cancel</button>
|
||||
<button mat-flat-button color="primary" [disabled]="!selectedFile || loading" (click)="onSubmit()">
|
||||
Process Reconciliation
|
||||
</button>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { SponsorDDReconciliationService } from '../sponsor-dd-reconciliation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-reco-batch-upload',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
FormsModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule,
|
||||
MatProgressBarModule,
|
||||
FaIconComponent
|
||||
],
|
||||
templateUrl: './reco-batch-upload.component.html',
|
||||
styles: [
|
||||
`
|
||||
.upload-container {
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header-row {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header-row h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.header-row p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mat-mdc-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
padding: 32px;
|
||||
}
|
||||
.dropzone {
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
background-color: #f8fafc;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background-color 0.2s;
|
||||
}
|
||||
.dropzone:hover {
|
||||
border-color: #7c3aed;
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
.dropzone-icon {
|
||||
font-size: 48px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.file-details {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background-color: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
.file-size {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.progress-section {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.progress-text {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 8px;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RecoBatchUploadComponent {
|
||||
private recoService = inject(SponsorDDReconciliationService);
|
||||
private router = inject(Router);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
selectedFile: File | null = null;
|
||||
loading = false;
|
||||
progressMessage = '';
|
||||
|
||||
onFileSelected(event: any): void {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
this.selectedFile = file;
|
||||
}
|
||||
}
|
||||
|
||||
triggerFileSelect(fileInput: HTMLInputElement): void {
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (!this.selectedFile) return;
|
||||
|
||||
this.loading = true;
|
||||
this.progressMessage = 'Reading file and preparing upload payload...';
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const fileContent = reader.result as string;
|
||||
this.progressMessage = 'Uploading clearing scroll for processing...';
|
||||
|
||||
this.recoService
|
||||
.uploadClearingScroll({
|
||||
fileName: this.selectedFile!.name,
|
||||
fileContent: fileContent
|
||||
})
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.snackBar.open(
|
||||
`Reconciliation batch processed successfully. ${res.successCount} matched, ${res.mismatchCount} exceptions.`,
|
||||
'Close',
|
||||
{ duration: 7000 }
|
||||
);
|
||||
this.router.navigate([
|
||||
'/sponsor-dd-reconciliation/batches',
|
||||
res.id
|
||||
]);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.progressMessage = '';
|
||||
this.snackBar.open('Reconciliation failed: ' + (err.error?.message || err.message), 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
reader.readAsDataURL(this.selectedFile); // Reads as Base64 Data URL, which our backend decodes safely.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { Route } from '../core/route/route.service';
|
||||
import { RecoBatchListComponent } from './reco-batch-list/reco-batch-list.component';
|
||||
import { RecoBatchUploadComponent } from './reco-batch-upload/reco-batch-upload.component';
|
||||
import { RecoBatchDetailComponent } from './reco-batch-detail/reco-batch-detail.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'sponsor-dd-reconciliation',
|
||||
data: { title: 'DD Reconciliation', breadcrumb: 'DD Reconciliation' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'batches',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'batches',
|
||||
component: RecoBatchListComponent,
|
||||
data: { title: 'Reconciliation Batches', breadcrumb: 'Batches' }
|
||||
},
|
||||
{
|
||||
path: 'upload',
|
||||
component: RecoBatchUploadComponent,
|
||||
data: { title: 'Upload Clearing Scroll', breadcrumb: 'Upload' }
|
||||
},
|
||||
{
|
||||
path: 'batches/:batchId',
|
||||
component: RecoBatchDetailComponent,
|
||||
data: { title: 'Batch Details', breadcrumb: 'Batch Details' }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class SponsorDDReconciliationRoutingModule {}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
import { SponsorDDReconciliationRoutingModule } from './sponsor-dd-reconciliation-routing.module';
|
||||
import { RecoBatchListComponent } from './reco-batch-list/reco-batch-list.component';
|
||||
import { RecoBatchUploadComponent } from './reco-batch-upload/reco-batch-upload.component';
|
||||
import { RecoBatchDetailComponent } from './reco-batch-detail/reco-batch-detail.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SponsorDDReconciliationRoutingModule,
|
||||
RecoBatchListComponent,
|
||||
RecoBatchUploadComponent,
|
||||
RecoBatchDetailComponent
|
||||
]
|
||||
})
|
||||
export class SponsorDDReconciliationModule {}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SponsorDDReconciliationService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getReconciliationBatches(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/sponsordd/reconcile/batches');
|
||||
}
|
||||
|
||||
getReconciliationBatch(batchId: number): Observable<any> {
|
||||
return this.http.get<any>(`/sponsordd/reconcile/batches/${batchId}`);
|
||||
}
|
||||
|
||||
getReconciliationRecords(batchId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/sponsordd/reconcile/batches/${batchId}/records`);
|
||||
}
|
||||
|
||||
uploadClearingScroll(data: { fileName: string; fileContent: string }): Observable<any> {
|
||||
return this.http.post<any>('/sponsordd/reconcile/upload', data);
|
||||
}
|
||||
|
||||
resolveMismatch(
|
||||
recordId: number,
|
||||
data: {
|
||||
actionType: string; // FORCE_MATCH
|
||||
resolvedSavingsTxnId?: number;
|
||||
resolvedCurrentTxnId?: number;
|
||||
}
|
||||
): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/reconcile/resolve/${recordId}`, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<!--
|
||||
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="inventory-container">
|
||||
<div class="header-row">
|
||||
<div class="title-area">
|
||||
<h2>Sponsor Bank DD Inventory</h2>
|
||||
<p>Manage allocated ranges of physical Demand Draft leaflets received from Sponsor Banks.</p>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" (click)="openAddInventoryDialog()">Register DD Series</button>
|
||||
</div>
|
||||
|
||||
<mat-card>
|
||||
<table mat-table [dataSource]="dataSource">
|
||||
<!-- ID Column -->
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>Batch ID</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Sponsor Bank Column -->
|
||||
<ng-container matColumnDef="sponsorBankName">
|
||||
<th mat-header-cell *matHeaderCellDef>Sponsor Bank</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.sponsorBankName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Branch Column -->
|
||||
<ng-container matColumnDef="branchName">
|
||||
<th mat-header-cell *matHeaderCellDef>Branch</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.branchName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Series Start Column -->
|
||||
<ng-container matColumnDef="seriesStart">
|
||||
<th mat-header-cell *matHeaderCellDef>Start No.</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.seriesStart }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Series End Column -->
|
||||
<ng-container matColumnDef="seriesEnd">
|
||||
<th mat-header-cell *matHeaderCellDef>End No.</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.seriesEnd }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Total Leaves Column -->
|
||||
<ng-container matColumnDef="totalLeaves">
|
||||
<th mat-header-cell *matHeaderCellDef>Total Leaves</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.totalLeaves }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Available Leaves Column -->
|
||||
<ng-container matColumnDef="availableLeaves">
|
||||
<th mat-header-cell *matHeaderCellDef>Available Leaves</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<span [class.leaves-low]="element.availableLeaves === 0">{{ element.availableLeaves }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Received Date Column -->
|
||||
<ng-container matColumnDef="receivedDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Received Date</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.receivedDate }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<span class="status-badge" [ngClass]="element.status.toLowerCase()">
|
||||
{{ element.status }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<button mat-button class="action-btn" [routerLink]="['/sponsor-dd/leaflets', element.id]">
|
||||
View Leaflets
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<div
|
||||
*ngIf="dataSource.length === 0"
|
||||
class="no-records-row"
|
||||
style="text-align: center; padding: 32px; color: #64748b"
|
||||
>
|
||||
No DD series registered yet. Click "Register DD Series" to add stock.
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* 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, DatePipe } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { SponsorDDService } from '../sponsor-dd.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-dd-inventory-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule,
|
||||
FaIconComponent
|
||||
],
|
||||
templateUrl: './dd-inventory-list.component.html',
|
||||
styles: [
|
||||
`
|
||||
.inventory-container {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.title-area h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.title-area p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mat-mdc-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
td {
|
||||
color: #334155;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-badge.active {
|
||||
background-color: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.status-badge.exhausted {
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.action-btn {
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
}
|
||||
.leaves-low {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class DdInventoryListComponent implements OnInit {
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
'sponsorBankName',
|
||||
'branchName',
|
||||
'seriesStart',
|
||||
'seriesEnd',
|
||||
'totalLeaves',
|
||||
'availableLeaves',
|
||||
'receivedDate',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
dataSource: any[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadInventories();
|
||||
}
|
||||
|
||||
loadInventories(): void {
|
||||
this.sponsorDDService.getInventories().subscribe({
|
||||
next: (data) => {
|
||||
this.dataSource = data;
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Error loading inventories: ' + (err.error?.message || err.message), 'Close', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openAddInventoryDialog(): void {
|
||||
const dialogRef = this.dialog.open(AddInventoryDialogComponent, {
|
||||
width: '500px',
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
this.loadInventories();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── ADD INVENTORY DIALOG COMPONENT ──
|
||||
@Component({
|
||||
selector: 'mifosx-add-inventory-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule,
|
||||
MatButtonModule
|
||||
],
|
||||
providers: [DatePipe],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Add Sponsor DD Inventory</h2>
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="inventoryForm" class="dialog-form">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Sponsor Bank</mat-label>
|
||||
<mat-select formControlName="sponsorBankId">
|
||||
<mat-option *ngFor="let sb of sponsorBanks" [value]="sb.id">{{ sb.name }}</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Don't see your sponsor bank? Add it under Organization > Sponsor Banks.</mat-hint>
|
||||
<mat-error *ngIf="inventoryForm.get('sponsorBankId')?.hasError('required')"
|
||||
>Sponsor Bank is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Branch</mat-label>
|
||||
<mat-select formControlName="branchId">
|
||||
<mat-option *ngFor="let branch of branches" [value]="branch.id">{{ branch.name }}</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="inventoryForm.get('branchId')?.hasError('required')">Branch is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Series Start</mat-label>
|
||||
<input matInput type="number" formControlName="seriesStart" />
|
||||
<mat-error *ngIf="inventoryForm.get('seriesStart')?.hasError('required')">Required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Series End</mat-label>
|
||||
<input matInput type="number" formControlName="seriesEnd" />
|
||||
<mat-error *ngIf="inventoryForm.get('seriesEnd')?.hasError('required')">Required</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Received Date</mat-label>
|
||||
<input matInput [matDatepicker]="picker" formControlName="receivedDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #picker></mat-datepicker>
|
||||
<mat-error *ngIf="inventoryForm.get('receivedDate')?.hasError('required')"
|
||||
>Received Date is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Cancel</button>
|
||||
<button mat-flat-button color="primary" [disabled]="inventoryForm.invalid || loading" (click)="onSubmit()">
|
||||
Save
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.half-width {
|
||||
flex: 1;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class AddInventoryDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private http = inject(HttpClient);
|
||||
private datePipe = inject(DatePipe);
|
||||
private dialogRef = inject(MatDialogRef<AddInventoryDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
inventoryForm!: FormGroup;
|
||||
loading = false;
|
||||
sponsorBanks: any[] = [];
|
||||
branches: any[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.inventoryForm = this.fb.group({
|
||||
sponsorBankId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
branchId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
seriesStart: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(1)
|
||||
]
|
||||
],
|
||||
seriesEnd: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(1)
|
||||
]
|
||||
],
|
||||
receivedDate: [
|
||||
new Date(),
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
|
||||
this.loadSponsorBanks();
|
||||
this.loadBranches();
|
||||
}
|
||||
|
||||
loadSponsorBanks(): void {
|
||||
this.http.get<any[]>('/sponsorbanks').subscribe({
|
||||
next: (data) => {
|
||||
this.sponsorBanks = data.map((sb) => ({ id: sb.id, name: sb.name }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadBranches(): void {
|
||||
this.http.get<any[]>('/offices').subscribe({
|
||||
next: (data) => {
|
||||
this.branches = data.map((b) => ({ id: b.id, name: b.name }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.inventoryForm.invalid) return;
|
||||
|
||||
this.loading = true;
|
||||
const formVal = this.inventoryForm.value;
|
||||
const formattedDate = this.datePipe.transform(formVal.receivedDate, 'yyyy-MM-dd') || '';
|
||||
|
||||
const payload = {
|
||||
sponsorBankId: formVal.sponsorBankId,
|
||||
branchId: formVal.branchId,
|
||||
seriesStart: formVal.seriesStart,
|
||||
seriesEnd: formVal.seriesEnd,
|
||||
receivedDate: formattedDate
|
||||
};
|
||||
|
||||
this.sponsorDDService.registerInventory(payload).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Inventory added successfully', 'Close', { duration: 3000 });
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<!--
|
||||
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="approvals-container">
|
||||
<div class="header-row">
|
||||
<div class="title-area">
|
||||
<h2>DD Issuance Approvals</h2>
|
||||
<p>
|
||||
Review and approve/reject DD issuance requests submitted by clerks. Approving posts the real GL entries and
|
||||
issues the draft.
|
||||
</p>
|
||||
</div>
|
||||
<button mat-stroked-button (click)="openSignatureSettings()">My Signature</button>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-label>Search by DD Leaflet No.</mat-label>
|
||||
<input matInput [value]="searchText" (input)="onSearchChange($event.target.value)" placeholder="e.g. 3005" />
|
||||
</mat-form-field>
|
||||
|
||||
<div class="status-filter-row">
|
||||
<button
|
||||
mat-stroked-button
|
||||
*ngFor="let status of statusOptions"
|
||||
[color]="selectedStatus === status ? 'primary' : undefined"
|
||||
[class.filter-active]="selectedStatus === status"
|
||||
(click)="filterByStatus(status)"
|
||||
>
|
||||
{{ status === 'ALL' ? 'All' : status }}
|
||||
<span class="filter-count">({{ statusCount(status) }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-card>
|
||||
<table mat-table [dataSource]="dataSource">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>Req. ID</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="ddNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>DD No.</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<strong>{{ element.ddNumber }}</strong>
|
||||
<div *ngIf="element.batchId" style="color: #7c3aed; font-size: 11px">
|
||||
Slip · {{ batchSiblingCount(element) }} drafts
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="clientName">
|
||||
<th mat-header-cell *matHeaderCellDef>Client</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.clientName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="beneficiaryName">
|
||||
<th mat-header-cell *matHeaderCellDef>Beneficiary</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.beneficiaryName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="faceValue">
|
||||
<th mat-header-cell *matHeaderCellDef>Face Value</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.faceValue | number: '1.2-2' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="paymentMethod">
|
||||
<th mat-header-cell *matHeaderCellDef>Payment Method</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<ng-container [ngSwitch]="element.paymentMethod">
|
||||
<span *ngSwitchCase="'CHEQUE'">Cheque {{ element.chequeNumber || '' }}</span>
|
||||
<span *ngSwitchCase="'CASH'"
|
||||
>Cash (Walk-in{{ element.walkinPurchaserName ? ': ' + element.walkinPurchaserName : '' }})</span
|
||||
>
|
||||
<span *ngSwitchDefault>Account Debit</span>
|
||||
</ng-container>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="paymentType">
|
||||
<th mat-header-cell *matHeaderCellDef>Payment Type</th>
|
||||
<td mat-cell *matCellDef="let element">{{ paymentTypeLabel(element) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<span class="status-badge" [ngClass]="element.status.toLowerCase()">
|
||||
{{ element.status }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="makerUsername">
|
||||
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.makerUsername }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="createdDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.createdDate | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<div class="action-btn-group" *ngIf="element.status === 'PENDING'">
|
||||
<button mat-flat-button color="primary" size="small" (click)="approve(element)">
|
||||
{{ element.batchId ? 'Approve Slip' : 'Approve' }}
|
||||
</button>
|
||||
<button mat-stroked-button color="warn" size="small" (click)="openRejectDialog(element)">
|
||||
{{ element.batchId ? 'Reject Slip' : 'Reject' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="element.status === 'PENDING' && element.batchId"
|
||||
style="color: #7c3aed; font-size: 11px; margin-top: 4px"
|
||||
>
|
||||
Approving/rejecting applies to all {{ batchSiblingCount(element) }} drafts in this slip.
|
||||
</div>
|
||||
<div
|
||||
*ngIf="element.status === 'PENDING' && element.cashReceiptJournalEntryId"
|
||||
style="color: #64748b; font-size: 11px; margin-top: 4px"
|
||||
>
|
||||
Cash received: GL entry #{{ element.cashReceiptJournalEntryId }} (pending approval)
|
||||
</div>
|
||||
<div *ngIf="element.status === 'REJECTED'" style="color: #64748b; font-size: 12px">
|
||||
<div>{{ element.rejectionReason }}</div>
|
||||
<div *ngIf="element.cashRefundJournalEntryId">
|
||||
Cash refunded to purchaser: GL entry #{{ element.cashRefundJournalEntryId }}
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="element.status === 'APPROVED'" style="color: #64748b; font-size: 12px; line-height: 1.6">
|
||||
<div *ngIf="element.resultingSavingsTransactionId">
|
||||
Savings txn #{{ element.resultingSavingsTransactionId }}
|
||||
</div>
|
||||
<div *ngIf="element.resultingCurrentTransactionId">
|
||||
Current txn #{{ element.resultingCurrentTransactionId }}
|
||||
</div>
|
||||
<div *ngIf="element.cashReceiptJournalEntryId">
|
||||
Cash received: GL entry #{{ element.cashReceiptJournalEntryId }}
|
||||
</div>
|
||||
<div *ngIf="element.resultingJournalEntryId">GL entry #{{ element.resultingJournalEntryId }}</div>
|
||||
<div>by {{ element.checkerUsername }}, {{ element.checkerDate | date: 'dd MMM, HH:mm' }}</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<div
|
||||
*ngIf="dataSource.filteredData.length === 0"
|
||||
class="no-records-row"
|
||||
style="text-align: center; padding: 32px; color: #64748b"
|
||||
>
|
||||
No DD issuance requests match the selected filter.
|
||||
</div>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[25, 50, 100]" [pageSize]="25" showFirstLastButtons></mat-paginator>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* 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, AfterViewInit, ViewChild, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { SponsorDDService } from '../sponsor-dd.service';
|
||||
import { SignatureSettingsDialogComponent } from '../signature-settings-dialog/signature-settings-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-dd-issue-approvals',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule
|
||||
],
|
||||
templateUrl: './dd-issue-approvals.component.html',
|
||||
styles: [
|
||||
`
|
||||
.approvals-container {
|
||||
padding: 24px;
|
||||
max-width: 1300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.title-area h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.title-area p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.search-field {
|
||||
width: 320px;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mat-mdc-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
td {
|
||||
color: #334155;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-badge.pending {
|
||||
background-color: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
.status-badge.approved {
|
||||
background-color: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.status-badge.rejected {
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.action-btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.status-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.status-filter-row button.filter-active {
|
||||
background-color: #ede9fe;
|
||||
}
|
||||
.filter-count {
|
||||
color: #64748b;
|
||||
margin-left: 4px;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
'ddNumber',
|
||||
'clientName',
|
||||
'beneficiaryName',
|
||||
'faceValue',
|
||||
'paymentMethod',
|
||||
'paymentType',
|
||||
'status',
|
||||
'makerUsername',
|
||||
'createdDate',
|
||||
'actions'
|
||||
];
|
||||
dataSource = new MatTableDataSource<any>([]);
|
||||
|
||||
readonly statusOptions: string[] = [
|
||||
'PENDING',
|
||||
'APPROVED',
|
||||
'REJECTED',
|
||||
'ALL'
|
||||
];
|
||||
selectedStatus = 'PENDING';
|
||||
searchText = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.dataSource.filterPredicate = (data) => {
|
||||
const statusMatches = this.selectedStatus === 'ALL' || data.status === this.selectedStatus;
|
||||
const searchMatches =
|
||||
!this.searchText || (data.ddNumber || '').toLowerCase().includes(this.searchText.trim().toLowerCase());
|
||||
return statusMatches && searchMatches;
|
||||
};
|
||||
this.loadRequests();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.dataSource.paginator = this.paginator;
|
||||
}
|
||||
|
||||
loadRequests(): void {
|
||||
this.sponsorDDService.getAllIssueRequests().subscribe({
|
||||
next: (data) => {
|
||||
this.dataSource.data = data;
|
||||
this.applyFilter();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Error loading requests: ' + (err.error?.message || err.message), 'Close', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
paymentTypeLabel(element: any): string {
|
||||
if (element.paymentMethod === 'CASH') {
|
||||
return 'Cash';
|
||||
}
|
||||
if (element.paymentMethod === 'CHEQUE') {
|
||||
return 'Cheque';
|
||||
}
|
||||
if (element.savingsAccountId) {
|
||||
return 'Savings';
|
||||
}
|
||||
if (element.currentAccountId) {
|
||||
return 'Current';
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
statusCount(status: string): number {
|
||||
const all = this.dataSource.data;
|
||||
return status === 'ALL' ? all.length : all.filter((r) => r.status === status).length;
|
||||
}
|
||||
|
||||
filterByStatus(status: string): void {
|
||||
this.selectedStatus = status;
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
onSearchChange(value: string): void {
|
||||
this.searchText = value;
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
private applyFilter(): void {
|
||||
this.dataSource.filter = `${this.selectedStatus}|${this.searchText}`;
|
||||
if (this.dataSource.paginator) {
|
||||
this.dataSource.paginator.firstPage();
|
||||
}
|
||||
}
|
||||
|
||||
/** Every row sharing one batchId was submitted together as one multi-DD slip. */
|
||||
batchSiblings(request: any): any[] {
|
||||
return request.batchId ? this.dataSource.data.filter((r) => r.batchId === request.batchId) : [request];
|
||||
}
|
||||
|
||||
batchSiblingCount(request: any): number {
|
||||
return this.batchSiblings(request).length;
|
||||
}
|
||||
|
||||
approve(request: any): void {
|
||||
if (request.batchId) {
|
||||
this.approveBatch(request);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
`Approve DD issuance for DD No. ${request.ddNumber} (₹${request.faceValue})? This will debit the account (or clear the cheque) and issue the draft.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.sponsorDDService.approveIssueRequest(request.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('DD issuance approved and posted successfully', 'Close', { duration: 3000 });
|
||||
this.loadRequests();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private approveBatch(request: any): void {
|
||||
const siblings = this.batchSiblings(request);
|
||||
const lines = siblings.map((r) => ` • ${r.beneficiaryName} — ₹${r.faceValue}`).join('\n');
|
||||
const total = siblings.reduce((sum, r) => sum + (Number(r.faceValue) || 0), 0);
|
||||
if (
|
||||
!confirm(
|
||||
`Approve this DD slip - ${siblings.length} drafts, combined face value ₹${total}?\n${lines}\n\n` +
|
||||
'This debits the account (or clears the cheque) ONCE for the combined total and issues every draft in the slip.'
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 });
|
||||
this.loadRequests();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openRejectDialog(request: any): void {
|
||||
const dialogRef = this.dialog.open(RejectIssueRequestDialogComponent, {
|
||||
width: '450px',
|
||||
data: { request, siblingCount: this.batchSiblingCount(request) },
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
this.loadRequests();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openSignatureSettings(): void {
|
||||
this.dialog.open(SignatureSettingsDialogComponent, {
|
||||
width: '450px'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── REJECT DIALOG COMPONENT ──
|
||||
@Component({
|
||||
selector: 'mifosx-reject-issue-request-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
Reject DD Issuance {{ data.request.batchId ? 'Slip' : 'Request' }} (DD No: {{ data.request.ddNumber
|
||||
}}{{ data.siblingCount > 1 ? ' + ' + (data.siblingCount - 1) + ' more' : '' }})
|
||||
</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="hint-text" *ngIf="data.request.batchId">
|
||||
This slip has {{ data.siblingCount }} drafts — rejecting applies to all of them together.
|
||||
</p>
|
||||
<form [formGroup]="rejectForm" class="dialog-form">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Rejection Reason</mat-label>
|
||||
<textarea matInput formControlName="rejectionReason" rows="3"></textarea>
|
||||
<mat-error *ngIf="rejectForm.get('rejectionReason')?.hasError('required')">Reason is required</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Cancel</button>
|
||||
<button mat-flat-button color="warn" [disabled]="rejectForm.invalid || loading" (click)="onSubmit()">
|
||||
Confirm Rejection
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.hint-text {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RejectIssueRequestDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private dialogRef = inject(MatDialogRef<RejectIssueRequestDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
protected data = inject(MAT_DIALOG_DATA);
|
||||
|
||||
rejectForm!: FormGroup;
|
||||
loading = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.rejectForm = this.fb.group({
|
||||
rejectionReason: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.rejectForm.invalid) return;
|
||||
|
||||
this.loading = true;
|
||||
const reason = this.rejectForm.value.rejectionReason;
|
||||
const request$ = this.data.request.batchId
|
||||
? this.sponsorDDService.rejectIssueRequestBatch(this.data.request.batchId, reason)
|
||||
: this.sponsorDDService.rejectIssueRequest(this.data.request.id, reason);
|
||||
request$.subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open(this.data.request.batchId ? 'DD slip rejected' : 'DD issuance request rejected', 'Close', {
|
||||
duration: 3000
|
||||
});
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<!--
|
||||
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="leaflet-container">
|
||||
<div class="header-row">
|
||||
<div class="title-area">
|
||||
<button mat-button routerLink="/sponsor-dd/inventory" style="margin-bottom: 8px; color: #64748b; padding-left: 0">
|
||||
← Back to Inventories
|
||||
</button>
|
||||
<h2>DD Leaflet Registry</h2>
|
||||
<p *ngIf="inventoryDetails">
|
||||
Sponsor Bank: <strong>{{ inventoryDetails.sponsorBankName }}</strong> | Series Range:
|
||||
<strong>{{ inventoryDetails.seriesStart }} - {{ inventoryDetails.seriesEnd }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-label>Search by DD Leaflet No.</mat-label>
|
||||
<input matInput [value]="searchText" (input)="onSearchChange($event.target.value)" placeholder="e.g. 3005" />
|
||||
</mat-form-field>
|
||||
|
||||
<div class="status-filter-row">
|
||||
<button mat-stroked-button color="accent" [class.filter-active]="todayOnly" (click)="toggleTodayOnly()">
|
||||
{{ todayOnly ? "Today's Issued DDs Only" : 'Showing All Dates' }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
*ngFor="let status of statusOptions"
|
||||
[color]="selectedStatus === status ? 'primary' : undefined"
|
||||
[class.filter-active]="selectedStatus === status"
|
||||
(click)="filterByStatus(status)"
|
||||
>
|
||||
{{ status === 'ALL' ? 'All' : status }}
|
||||
<span class="filter-count">({{ statusCount(status) }})</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint-text" *ngIf="todayOnly">
|
||||
Only DDs actually ISSUED today are shown - AVAILABLE, CANCELLED, STOPPED and CLEARED are hidden. Click "Showing All
|
||||
Dates" above to see everything.
|
||||
</p>
|
||||
|
||||
<mat-card>
|
||||
<table mat-table [dataSource]="dataSource">
|
||||
<!-- DD Number Column -->
|
||||
<ng-container matColumnDef="ddNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>DD Leaflet No.</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<strong>{{ element.ddNumber }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<span class="status-badge" [ngClass]="element.status.toLowerCase()">
|
||||
{{ element.status }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Face Value Column -->
|
||||
<ng-container matColumnDef="faceValue">
|
||||
<th mat-header-cell *matHeaderCellDef>Face Value</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
{{ element.faceValue > 0 ? (element.faceValue | number: '1.2-2') : '-' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Client Name Column -->
|
||||
<ng-container matColumnDef="issuedToClientName">
|
||||
<th mat-header-cell *matHeaderCellDef>Purchaser / Client</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.issuedToClientName || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Beneficiary Column -->
|
||||
<ng-container matColumnDef="beneficiaryName">
|
||||
<th mat-header-cell *matHeaderCellDef>Beneficiary</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.beneficiaryName || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Issued Date Column -->
|
||||
<ng-container matColumnDef="issuedDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Issued Date</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.issuedDate || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Advice Status Column -->
|
||||
<ng-container matColumnDef="adviceStatus">
|
||||
<th mat-header-cell *matHeaderCellDef>Advice Status</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
{{ element.status === 'ISSUED' ? element.adviceStatus : '-' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<div class="action-btn-group">
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
size="small"
|
||||
*ngIf="element.status === 'AVAILABLE' && !element.pendingIssueRequestId"
|
||||
(click)="openIssueDialog(element)"
|
||||
>
|
||||
Issue Draft
|
||||
</button>
|
||||
<span class="pending-badge" *ngIf="element.status === 'AVAILABLE' && element.pendingIssueRequestId">
|
||||
Pending Approval (Req #{{ element.pendingIssueRequestId }})
|
||||
</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="warn"
|
||||
size="small"
|
||||
*ngIf="element.status === 'ISSUED'"
|
||||
(click)="openCancelDialog(element)"
|
||||
>
|
||||
Cancel Draft
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
color="warn"
|
||||
size="small"
|
||||
*ngIf="element.status === 'ISSUED'"
|
||||
(click)="onStopPayment(element)"
|
||||
>
|
||||
Stop Payment
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
size="small"
|
||||
*ngIf="element.status === 'ISSUED' && !element.printCount"
|
||||
(click)="openPrintDialog(element)"
|
||||
>
|
||||
Print
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
size="small"
|
||||
*ngIf="
|
||||
element.status === 'ISSUED' && element.printCount > 0 && element.approvedUnconsumedReprintRequestId
|
||||
"
|
||||
(click)="openPrintDialog(element)"
|
||||
>
|
||||
Print (Reprint Approved)
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
size="small"
|
||||
*ngIf="
|
||||
element.status === 'ISSUED' &&
|
||||
element.printCount > 0 &&
|
||||
!element.approvedUnconsumedReprintRequestId &&
|
||||
!element.pendingReprintRequestId
|
||||
"
|
||||
(click)="openRequestReprintDialog(element)"
|
||||
>
|
||||
Request Reprint
|
||||
</button>
|
||||
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingReprintRequestId">
|
||||
Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }})
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<div
|
||||
*ngIf="dataSource.filteredData.length === 0"
|
||||
class="no-records-row"
|
||||
style="text-align: center; padding: 32px; color: #64748b"
|
||||
>
|
||||
{{ dataSource.data.length === 0 ? 'Loading leaflets...' : 'No leaflets match the selected filter.' }}
|
||||
</div>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[25, 50, 100, 250]" [pageSize]="25" showFirstLastButtons></mat-paginator>
|
||||
</mat-card>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
<!--
|
||||
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="approvals-container">
|
||||
<div class="header-row">
|
||||
<div class="title-area">
|
||||
<h2>DD Reprint Approvals</h2>
|
||||
<p>
|
||||
A DD leaflet can only be printed once without approval. Review and approve/reject requests to print an
|
||||
already-printed leaflet again.
|
||||
</p>
|
||||
</div>
|
||||
<button mat-stroked-button (click)="openSignatureSettings()">My Signature</button>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-label>Search by DD Leaflet No.</mat-label>
|
||||
<input matInput [value]="searchText" (input)="onSearchChange($event.target.value)" placeholder="e.g. 3005" />
|
||||
</mat-form-field>
|
||||
|
||||
<div class="status-filter-row">
|
||||
<button
|
||||
mat-stroked-button
|
||||
*ngFor="let status of statusOptions"
|
||||
[color]="selectedStatus === status ? 'primary' : undefined"
|
||||
[class.filter-active]="selectedStatus === status"
|
||||
(click)="filterByStatus(status)"
|
||||
>
|
||||
{{ status === 'ALL' ? 'All' : status }}
|
||||
<span class="filter-count">({{ statusCount(status) }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-card>
|
||||
<table mat-table [dataSource]="dataSource">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>Req. ID</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="ddNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>DD No.</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<strong>{{ element.ddNumber }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="reason">
|
||||
<th mat-header-cell *matHeaderCellDef>Reason</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.reason }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<span class="status-badge" [ngClass]="element.status.toLowerCase()">
|
||||
{{ element.status }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="makerUsername">
|
||||
<th mat-header-cell *matHeaderCellDef>Requested By</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.makerUsername }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="createdDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Requested On</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.createdDate | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<div class="action-btn-group" *ngIf="element.status === 'PENDING'">
|
||||
<button mat-flat-button color="primary" size="small" (click)="approve(element)">Approve</button>
|
||||
<button mat-stroked-button color="warn" size="small" (click)="openRejectDialog(element)">Reject</button>
|
||||
</div>
|
||||
<span *ngIf="element.status === 'REJECTED'" style="color: #64748b; font-size: 12px">{{
|
||||
element.rejectionReason
|
||||
}}</span>
|
||||
<div *ngIf="element.status === 'APPROVED'" style="color: #64748b; font-size: 12px; line-height: 1.6">
|
||||
<div>{{ element.consumed ? 'Reprinted' : 'Awaiting print' }}</div>
|
||||
<div>by {{ element.checkerUsername }}, {{ element.checkerDate | date: 'dd MMM, HH:mm' }}</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<div
|
||||
*ngIf="dataSource.filteredData.length === 0"
|
||||
class="no-records-row"
|
||||
style="text-align: center; padding: 32px; color: #64748b"
|
||||
>
|
||||
No DD reprint requests match the selected filter.
|
||||
</div>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[25, 50, 100]" [pageSize]="25" showFirstLastButtons></mat-paginator>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 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, AfterViewInit, ViewChild, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { SponsorDDService } from '../sponsor-dd.service';
|
||||
import { SignatureSettingsDialogComponent } from '../signature-settings-dialog/signature-settings-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-dd-reprint-approvals',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule
|
||||
],
|
||||
templateUrl: './dd-reprint-approvals.component.html',
|
||||
styles: [
|
||||
`
|
||||
.approvals-container {
|
||||
padding: 24px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.title-area h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.title-area p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.search-field {
|
||||
width: 320px;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mat-mdc-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
td {
|
||||
color: #334155;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-badge.pending {
|
||||
background-color: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
.status-badge.approved {
|
||||
background-color: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.status-badge.rejected {
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.action-btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.status-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.status-filter-row button.filter-active {
|
||||
background-color: #ede9fe;
|
||||
}
|
||||
.filter-count {
|
||||
color: #64748b;
|
||||
margin-left: 4px;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class DdReprintApprovalsComponent implements OnInit, AfterViewInit {
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
'ddNumber',
|
||||
'reason',
|
||||
'status',
|
||||
'makerUsername',
|
||||
'createdDate',
|
||||
'actions'
|
||||
];
|
||||
dataSource = new MatTableDataSource<any>([]);
|
||||
|
||||
readonly statusOptions: string[] = [
|
||||
'PENDING',
|
||||
'APPROVED',
|
||||
'REJECTED',
|
||||
'ALL'
|
||||
];
|
||||
selectedStatus = 'PENDING';
|
||||
searchText = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.dataSource.filterPredicate = (data) => {
|
||||
const statusMatches = this.selectedStatus === 'ALL' || data.status === this.selectedStatus;
|
||||
const searchMatches =
|
||||
!this.searchText || (data.ddNumber || '').toLowerCase().includes(this.searchText.trim().toLowerCase());
|
||||
return statusMatches && searchMatches;
|
||||
};
|
||||
this.loadRequests();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.dataSource.paginator = this.paginator;
|
||||
}
|
||||
|
||||
loadRequests(): void {
|
||||
this.sponsorDDService.getAllReprintRequests().subscribe({
|
||||
next: (data) => {
|
||||
this.dataSource.data = data;
|
||||
this.applyFilter();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Error loading reprint requests: ' + (err.error?.message || err.message), 'Close', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
statusCount(status: string): number {
|
||||
const all = this.dataSource.data;
|
||||
return status === 'ALL' ? all.length : all.filter((r) => r.status === status).length;
|
||||
}
|
||||
|
||||
filterByStatus(status: string): void {
|
||||
this.selectedStatus = status;
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
onSearchChange(value: string): void {
|
||||
this.searchText = value;
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
private applyFilter(): void {
|
||||
this.dataSource.filter = `${this.selectedStatus}|${this.searchText}`;
|
||||
if (this.dataSource.paginator) {
|
||||
this.dataSource.paginator.firstPage();
|
||||
}
|
||||
}
|
||||
|
||||
approve(request: any): void {
|
||||
if (!confirm(`Approve reprint of DD No. ${request.ddNumber}? The clerk will then be able to print it once more.`)) {
|
||||
return;
|
||||
}
|
||||
this.sponsorDDService.approveReprintRequest(request.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Reprint request approved', 'Close', { duration: 3000 });
|
||||
this.loadRequests();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openRejectDialog(request: any): void {
|
||||
const dialogRef = this.dialog.open(RejectReprintRequestDialogComponent, {
|
||||
width: '450px',
|
||||
data: { request },
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
this.loadRequests();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openSignatureSettings(): void {
|
||||
this.dialog.open(SignatureSettingsDialogComponent, {
|
||||
width: '450px'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── REJECT DIALOG COMPONENT ──
|
||||
@Component({
|
||||
selector: 'mifosx-reject-reprint-request-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Reject Reprint Request (DD No: {{ data.request.ddNumber }})</h2>
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="rejectForm" class="dialog-form">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Rejection Reason</mat-label>
|
||||
<textarea matInput formControlName="rejectionReason" rows="3"></textarea>
|
||||
<mat-error *ngIf="rejectForm.get('rejectionReason')?.hasError('required')">Reason is required</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Cancel</button>
|
||||
<button mat-flat-button color="warn" [disabled]="rejectForm.invalid || loading" (click)="onSubmit()">
|
||||
Confirm Rejection
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RejectReprintRequestDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private dialogRef = inject(MatDialogRef<RejectReprintRequestDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
protected data = inject(MAT_DIALOG_DATA);
|
||||
|
||||
rejectForm!: FormGroup;
|
||||
loading = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.rejectForm = this.fb.group({
|
||||
rejectionReason: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.rejectForm.invalid) return;
|
||||
|
||||
this.loading = true;
|
||||
this.sponsorDDService.rejectReprintRequest(this.data.request.id, this.rejectForm.value.rejectionReason).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Reprint request rejected', 'Close', { duration: 3000 });
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { SponsorDDService } from '../sponsor-dd.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-gl-settings-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>GST / GL Settings</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="hint-text">
|
||||
GST is charged on the DD issuance commission/charges only, never on the DD face value. The rate below applies to
|
||||
every DD submitted from now on - it's locked in per-request at submission time, so changing it here never
|
||||
affects requests already in flight.
|
||||
</p>
|
||||
|
||||
<form [formGroup]="form" class="dialog-form" *ngIf="!loading">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>GST Percentage (%)</mat-label>
|
||||
<input matInput type="number" formControlName="gstPercentage" step="0.01" />
|
||||
<mat-error *ngIf="form.get('gstPercentage')?.hasError('required')">Required</mat-error>
|
||||
<mat-error *ngIf="form.get('gstPercentage')?.hasError('min') || form.get('gstPercentage')?.hasError('max')">
|
||||
Must be between 0 and 100
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>DD Limit Amount</mat-label>
|
||||
<input matInput type="number" formControlName="ddLimitAmount" step="0.01" />
|
||||
<mat-hint>
|
||||
Maximum face value for a single DD, and the maximum combined face value of all DDs issued to the same
|
||||
beneficiary on the same day. Leave blank for no limit.
|
||||
</mat-hint>
|
||||
<mat-error *ngIf="form.get('ddLimitAmount')?.hasError('min')">Cannot be negative</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="readonly-accounts">
|
||||
<div class="account-row">
|
||||
<span>DD Payable</span><strong>{{ mapping?.ddPayableGlAccountName || '-' }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>Cheque Clearing</span><strong>{{ mapping?.chequeClearingGlAccountName || '-' }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>Cash</span><strong>{{ mapping?.cashGlAccountName || '-' }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>Cash (Pending Approval)</span><strong>{{ mapping?.cashSuspenseGlAccountName || '-' }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>Charges Income</span><strong>{{ mapping?.chargesIncomeGlAccountName || '-' }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>GST Payable</span><strong>{{ mapping?.gstPayableGlAccountName || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-text">
|
||||
GL account assignments above are set up once by an administrator and shown here read-only.
|
||||
</p>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onClose()">Close</button>
|
||||
<button mat-flat-button color="primary" [disabled]="loading || form.invalid || saving" (click)="onSave()">
|
||||
Save
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.hint-text {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
.readonly-accounts {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.account-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
padding: 4px 0;
|
||||
color: #334155;
|
||||
}
|
||||
.account-row span {
|
||||
color: #64748b;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class GlSettingsDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private dialogRef = inject(MatDialogRef<GlSettingsDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
form!: FormGroup;
|
||||
loading = true;
|
||||
saving = false;
|
||||
mapping: any = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
gstPercentage: [
|
||||
18,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0),
|
||||
Validators.max(100)
|
||||
]
|
||||
],
|
||||
ddLimitAmount: [
|
||||
null,
|
||||
[Validators.min(0)]
|
||||
]
|
||||
});
|
||||
this.sponsorDDService.getGlMappings().subscribe({
|
||||
next: (mappings) => {
|
||||
this.mapping = mappings && mappings.length ? mappings[0] : null;
|
||||
if (this.mapping) {
|
||||
this.form.patchValue({
|
||||
gstPercentage: this.mapping.gstPercentage ?? 18,
|
||||
ddLimitAmount: this.mapping.ddLimitAmount ?? null
|
||||
});
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error loading GL settings: ' + (err.error?.message || err.message), 'Close', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
if (this.form.invalid || !this.mapping) return;
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService
|
||||
.upsertGlMapping({
|
||||
sponsorBankId: 0,
|
||||
ddPayableGlAccountId: this.mapping.ddPayableGlAccountId,
|
||||
chequeClearingGlAccountId: this.mapping.chequeClearingGlAccountId,
|
||||
cashGlAccountId: this.mapping.cashGlAccountId,
|
||||
chargesIncomeGlAccountId: this.mapping.chargesIncomeGlAccountId,
|
||||
cashSuspenseGlAccountId: this.mapping.cashSuspenseGlAccountId,
|
||||
gstPercentage: this.form.value.gstPercentage,
|
||||
gstPayableGlAccountId: this.mapping.gstPayableGlAccountId,
|
||||
ddLimitAmount: this.form.value.ddLimitAmount
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('GST percentage updated', 'Close', { duration: 3000 });
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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 { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { SponsorDDService } from '../sponsor-dd.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-signature-settings-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>My Signature</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="hint-text">
|
||||
Upload your signature once - it gets stamped onto any Sponsor DD you approve when it's printed. Only used for
|
||||
DDs you personally approve; other approvers use their own.
|
||||
</p>
|
||||
|
||||
<div class="signature-preview" *ngIf="signatureDataUri">
|
||||
<img [src]="signatureDataUri" alt="Current signature" />
|
||||
</div>
|
||||
<p *ngIf="!loading && !signatureDataUri" class="no-signature">No signature uploaded yet.</p>
|
||||
|
||||
<input type="file" accept="image/png,image/jpeg" #fileInput hidden (change)="onFileSelected($event)" />
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onClose()">Close</button>
|
||||
<button mat-stroked-button color="warn" *ngIf="signatureDataUri" [disabled]="loading" (click)="onRemove()">
|
||||
Remove
|
||||
</button>
|
||||
<button mat-flat-button color="primary" [disabled]="loading" (click)="fileInput.click()">
|
||||
{{ signatureDataUri ? 'Replace' : 'Upload' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.hint-text {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
.signature-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
border: 1px dashed #94a3b8;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.signature-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 120px;
|
||||
}
|
||||
.no-signature {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class SignatureSettingsDialogComponent implements OnInit {
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private dialogRef = inject(MatDialogRef<SignatureSettingsDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
loading = false;
|
||||
signatureDataUri: string | null = null;
|
||||
private existingDocumentId: number | null = null;
|
||||
private userId!: number;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.userId = Number(this.authenticationService.getCredentials()?.userId);
|
||||
this.loadSignature();
|
||||
}
|
||||
|
||||
private loadSignature(): void {
|
||||
this.loading = true;
|
||||
this.sponsorDDService.findSignatureDocument(this.userId).subscribe({
|
||||
next: (doc) => {
|
||||
this.existingDocumentId = doc ? doc.id : null;
|
||||
if (doc) {
|
||||
this.sponsorDDService.getSignatureDataUri(this.userId).subscribe({
|
||||
next: (dataUri) => {
|
||||
this.signatureDataUri = dataUri;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => (this.loading = false)
|
||||
});
|
||||
} else {
|
||||
this.signatureDataUri = null;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
error: () => (this.loading = false)
|
||||
});
|
||||
}
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
this.loading = true;
|
||||
const afterOldRemoved = this.existingDocumentId
|
||||
? this.sponsorDDService.deleteSignature(this.userId, this.existingDocumentId)
|
||||
: null;
|
||||
|
||||
const upload = () => {
|
||||
this.sponsorDDService.uploadSignature(this.userId, file).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Signature uploaded', 'Close', { duration: 3000 });
|
||||
this.loadSignature();
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (afterOldRemoved) {
|
||||
afterOldRemoved.subscribe({ next: upload, error: upload });
|
||||
} else {
|
||||
upload();
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
onRemove(): void {
|
||||
if (!this.existingDocumentId || !confirm('Remove your uploaded signature?')) return;
|
||||
|
||||
this.loading = true;
|
||||
this.sponsorDDService.deleteSignature(this.userId, this.existingDocumentId).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Signature removed', 'Close', { duration: 3000 });
|
||||
this.existingDocumentId = null;
|
||||
this.signatureDataUri = null;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 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 { RouterModule } from '@angular/router';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-sponsor-bank-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
<div class="sponsor-bank-container">
|
||||
<div class="header-row">
|
||||
<div class="title-area">
|
||||
<h2>Sponsor Banks</h2>
|
||||
<p>Banks on whose behalf your bank issues demand drafts. Used when registering DD inventory.</p>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" (click)="openAddDialog()">+ Add Sponsor Bank</button>
|
||||
</div>
|
||||
|
||||
<mat-card>
|
||||
<table mat-table [dataSource]="dataSource">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>ID</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<strong>{{ element.name }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="description">
|
||||
<th mat-header-cell *matHeaderCellDef>Description</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.description || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="ifscCode">
|
||||
<th mat-header-cell *matHeaderCellDef>IFSC Code</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.ifscCode || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="bankCode">
|
||||
<th mat-header-cell *matHeaderCellDef>Bank Code</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.bankCode || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="swiftCode">
|
||||
<th mat-header-cell *matHeaderCellDef>SWIFT Code</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.swiftCode || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<div *ngIf="dataSource.length === 0" class="no-records-row">No sponsor banks registered yet.</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.sponsor-bank-container {
|
||||
padding: 24px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.title-area h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.title-area p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mat-mdc-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
td {
|
||||
color: #334155;
|
||||
}
|
||||
.no-records-row {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: #64748b;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class SponsorBankListComponent implements OnInit {
|
||||
private http = inject(HttpClient);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'ifscCode',
|
||||
'bankCode',
|
||||
'swiftCode'
|
||||
];
|
||||
dataSource: any[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSponsorBanks();
|
||||
}
|
||||
|
||||
loadSponsorBanks(): void {
|
||||
this.http.get<any[]>('/sponsorbanks').subscribe({
|
||||
next: (data) => (this.dataSource = data),
|
||||
error: (err) => {
|
||||
this.snackBar.open('Error loading sponsor banks: ' + (err.error?.message || err.message), 'Close', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openAddDialog(): void {
|
||||
const dialogRef = this.dialog.open(AddSponsorBankDialogComponent, {
|
||||
width: '450px',
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
this.loadSponsorBanks();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── ADD SPONSOR BANK DIALOG COMPONENT ──
|
||||
@Component({
|
||||
selector: 'mifosx-add-sponsor-bank-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Add Sponsor Bank</h2>
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="form" class="dialog-form">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Sponsor Bank Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="e.g. State Bank of India" />
|
||||
<mat-error *ngIf="form.get('name')?.hasError('required')">Name is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Description</mat-label>
|
||||
<input matInput formControlName="description" placeholder="e.g. Sponsor Bank" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>IFSC Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="ifscCode"
|
||||
placeholder="e.g. SBIN0001234"
|
||||
maxlength="11"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-hint>Settlement branch's IFSC - 4 letters, '0', then 6 alphanumeric characters</mat-hint>
|
||||
<mat-error *ngIf="form.get('ifscCode')?.hasError('pattern')"
|
||||
>Invalid IFSC format (e.g. SBIN0001234)</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Bank Code</mat-label>
|
||||
<input matInput formControlName="bankCode" placeholder="e.g. SBIN" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>SWIFT Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="swiftCode"
|
||||
placeholder="e.g. SBININBB104"
|
||||
maxlength="11"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Cancel</button>
|
||||
<button mat-flat-button color="primary" [disabled]="form.invalid || loading" (click)="onSubmit()">Save</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.half-width {
|
||||
flex: 1;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class AddSponsorBankDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private http = inject(HttpClient);
|
||||
private dialogRef = inject(MatDialogRef<AddSponsorBankDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
form!: FormGroup;
|
||||
loading = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
name: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
description: ['Sponsor Bank'],
|
||||
ifscCode: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Za-z]{4}0[A-Za-z0-9]{6}$/)
|
||||
],
|
||||
bankCode: [''],
|
||||
swiftCode: ['']
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
|
||||
this.loading = true;
|
||||
this.http.post<any>('/sponsorbanks', this.form.value).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Sponsor Bank added successfully', 'Close', { duration: 3000 });
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { Route } from '../core/route/route.service';
|
||||
import { DdInventoryListComponent } from './dd-inventory-list/dd-inventory-list.component';
|
||||
import { DdListComponent } from './dd-list/dd-list.component';
|
||||
import { DdIssueApprovalsComponent } from './dd-issue-approvals/dd-issue-approvals.component';
|
||||
import { DdReprintApprovalsComponent } from './dd-reprint-approvals/dd-reprint-approvals.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'sponsor-dd',
|
||||
data: { title: 'Sponsor DD', breadcrumb: 'Sponsor DD' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'inventory',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'inventory',
|
||||
component: DdInventoryListComponent,
|
||||
data: { title: 'DD Inventory', breadcrumb: 'Inventory' }
|
||||
},
|
||||
{
|
||||
path: 'leaflets/:inventoryId',
|
||||
component: DdListComponent,
|
||||
data: { title: 'DD Leaflets', breadcrumb: 'Leaflets' }
|
||||
},
|
||||
{
|
||||
path: 'issue-approvals',
|
||||
component: DdIssueApprovalsComponent,
|
||||
data: { title: 'DD Issuance Approvals', breadcrumb: 'Issuance Approvals' }
|
||||
},
|
||||
{
|
||||
path: 'reprint-approvals',
|
||||
component: DdReprintApprovalsComponent,
|
||||
data: { title: 'DD Reprint Approvals', breadcrumb: 'Reprint Approvals' }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class SponsorDDRoutingModule {}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
import { SponsorDDRoutingModule } from './sponsor-dd-routing.module';
|
||||
import { DdInventoryListComponent } from './dd-inventory-list/dd-inventory-list.component';
|
||||
import { DdListComponent } from './dd-list/dd-list.component';
|
||||
import { DdIssueApprovalsComponent } from './dd-issue-approvals/dd-issue-approvals.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SponsorDDRoutingModule,
|
||||
DdInventoryListComponent,
|
||||
DdListComponent,
|
||||
DdIssueApprovalsComponent
|
||||
]
|
||||
})
|
||||
export class SponsorDDModule {}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SponsorDDService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// ── Inventory ──
|
||||
getInventories(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/sponsordd/inventory');
|
||||
}
|
||||
|
||||
getInventoryById(id: number): Observable<any> {
|
||||
return this.http.get<any>(`/sponsordd/inventory/${id}`);
|
||||
}
|
||||
|
||||
registerInventory(data: {
|
||||
sponsorBankId: number;
|
||||
branchId: number;
|
||||
seriesStart: number;
|
||||
seriesEnd: number;
|
||||
receivedDate: string; // yyyy-MM-dd
|
||||
}): Observable<any> {
|
||||
return this.http.post<any>('/sponsordd/inventory', data);
|
||||
}
|
||||
|
||||
// ── Leaflets ──
|
||||
getLeafletsByInventory(inventoryId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/sponsordd/inventory/${inventoryId}/leaflets`);
|
||||
}
|
||||
|
||||
getLeafletById(leafletId: number): Observable<any> {
|
||||
return this.http.get<any>(`/sponsordd/leaflets/${leafletId}`);
|
||||
}
|
||||
|
||||
getLeafletByDdNumber(ddNumber: string): Observable<any> {
|
||||
return this.http.get<any>(`/sponsordd/leaflets/by-number/${ddNumber}`);
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
issueDemandDraft(
|
||||
leafletId: number,
|
||||
data: {
|
||||
clientId: number;
|
||||
beneficiaryName: string;
|
||||
faceValue: number;
|
||||
issuingBranchId: number;
|
||||
issuedDate: string; // yyyy-MM-dd
|
||||
savingsTransactionId?: number;
|
||||
currentTransactionId?: number;
|
||||
journalEntryId?: number;
|
||||
}
|
||||
): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/issue`, data);
|
||||
}
|
||||
|
||||
cancelDemandDraft(
|
||||
leafletId: number,
|
||||
data: {
|
||||
remarks: string;
|
||||
refundSavingsTransactionId?: number;
|
||||
refundCurrentTransactionId?: number;
|
||||
}
|
||||
): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/cancel`, data);
|
||||
}
|
||||
|
||||
stopPaymentDemandDraft(leafletId: number, data: { remarks: string }): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/stop`, data);
|
||||
}
|
||||
|
||||
// ── DD Issuance Requests (maker-checker) ──
|
||||
submitIssueRequest(
|
||||
leafletId: number,
|
||||
data: {
|
||||
clientId?: number;
|
||||
walkinPurchaserName?: string;
|
||||
walkinPurchaserAddress?: string;
|
||||
walkinPurchaserAadhaar?: string;
|
||||
beneficiaryName: string;
|
||||
payableAtBranch: string;
|
||||
payableAtCity: string;
|
||||
faceValue: number;
|
||||
chargesAmount?: number;
|
||||
issuingBranchId: number;
|
||||
issuedDate: string; // yyyy-MM-dd
|
||||
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH';
|
||||
savingsAccountId?: number;
|
||||
currentAccountId?: number;
|
||||
chequeNumber?: string;
|
||||
chequeBankName?: string;
|
||||
cashierDepositRequestId?: number;
|
||||
}
|
||||
): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/issue-request`, data);
|
||||
}
|
||||
|
||||
getAvailableCashierDepositRequests(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/sponsordd/cashier-deposit-requests/available');
|
||||
}
|
||||
|
||||
// ── Multi-DD slip (one account debit/cheque funds several drafts) ──
|
||||
submitIssueRequestBatch(
|
||||
inventoryId: number,
|
||||
data: {
|
||||
clientId?: number;
|
||||
issuingBranchId: number;
|
||||
issuedDate: string; // yyyy-MM-dd
|
||||
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE';
|
||||
savingsAccountId?: number;
|
||||
currentAccountId?: number;
|
||||
chequeNumber?: string;
|
||||
chequeBankName?: string;
|
||||
lines: {
|
||||
beneficiaryName: string;
|
||||
payableAtBranch: string;
|
||||
payableAtCity: string;
|
||||
faceValue: number;
|
||||
chargesAmount?: number;
|
||||
}[];
|
||||
}
|
||||
): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/inventory/${inventoryId}/issue-request-batch`, data);
|
||||
}
|
||||
|
||||
approveIssueRequestBatch(batchId: number): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/issue-requests/batch/${batchId}/approve`, {});
|
||||
}
|
||||
|
||||
rejectIssueRequestBatch(batchId: number, rejectionReason: string): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/issue-requests/batch/${batchId}/reject`, { rejectionReason });
|
||||
}
|
||||
|
||||
// ── Sponsor DD GL mapping (back-office configuration) ──
|
||||
getGlMappings(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/sponsordd/gl-mapping');
|
||||
}
|
||||
|
||||
upsertGlMapping(data: {
|
||||
sponsorBankId: number;
|
||||
ddPayableGlAccountId: number;
|
||||
chequeClearingGlAccountId?: number;
|
||||
cashGlAccountId?: number;
|
||||
chargesIncomeGlAccountId?: number;
|
||||
cashSuspenseGlAccountId?: number;
|
||||
gstPercentage?: number;
|
||||
gstPayableGlAccountId?: number;
|
||||
ddLimitAmount?: number | null;
|
||||
}): Observable<any> {
|
||||
return this.http.post<any>('/sponsordd/gl-mapping', data);
|
||||
}
|
||||
|
||||
getAllIssueRequests(status?: string): Observable<any[]> {
|
||||
const params = status ? { status } : {};
|
||||
return this.http.get<any[]>('/sponsordd/issue-requests', { params });
|
||||
}
|
||||
|
||||
getMyIssueRequests(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/sponsordd/issue-requests/mine');
|
||||
}
|
||||
|
||||
getIssueRequestById(id: number): Observable<any> {
|
||||
return this.http.get<any>(`/sponsordd/issue-requests/${id}`);
|
||||
}
|
||||
|
||||
approveIssueRequest(id: number): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/issue-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
rejectIssueRequest(id: number, rejectionReason: string): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/issue-requests/${id}/reject`, { rejectionReason });
|
||||
}
|
||||
|
||||
// ── DD reprint requests (maker-checker) ──
|
||||
recordPrint(leafletId: number): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/print`, {});
|
||||
}
|
||||
|
||||
requestReprint(leafletId: number, reason: string): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/reprint-request`, { reason });
|
||||
}
|
||||
|
||||
getAllReprintRequests(status?: string): Observable<any[]> {
|
||||
const params = status ? { status } : {};
|
||||
return this.http.get<any[]>('/sponsordd/reprint-requests', { params });
|
||||
}
|
||||
|
||||
approveReprintRequest(id: number): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/reprint-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
rejectReprintRequest(id: number, rejectionReason: string): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/reprint-requests/${id}/reject`, { rejectionReason });
|
||||
}
|
||||
|
||||
// ── Approver signature (stamped onto the printed DD) ──
|
||||
// Reuses Fineract's generic document-attachment API (entityType is a free-form string with
|
||||
// no per-type backend wiring required) rather than building dedicated signature storage.
|
||||
private static readonly SIGNATURE_ENTITY_TYPE = 'users';
|
||||
private static readonly SIGNATURE_DOCUMENT_NAME = 'signature';
|
||||
|
||||
getUserDocuments(userId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/${SponsorDDService.SIGNATURE_ENTITY_TYPE}/${userId}/documents`);
|
||||
}
|
||||
|
||||
uploadSignature(userId: number, file: File): Observable<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
formData.append('name', SponsorDDService.SIGNATURE_DOCUMENT_NAME);
|
||||
formData.append('description', 'Approver signature, stamped onto printed demand drafts');
|
||||
return this.http.post<any>(`/${SponsorDDService.SIGNATURE_ENTITY_TYPE}/${userId}/documents`, formData);
|
||||
}
|
||||
|
||||
deleteSignature(userId: number, documentId: number): Observable<any> {
|
||||
return this.http.delete<any>(`/${SponsorDDService.SIGNATURE_ENTITY_TYPE}/${userId}/documents/${documentId}`);
|
||||
}
|
||||
|
||||
getSignatureBlob(userId: number, documentId: number): Observable<Blob> {
|
||||
return this.http.get(`/${SponsorDDService.SIGNATURE_ENTITY_TYPE}/${userId}/documents/${documentId}/attachment`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
}
|
||||
|
||||
/** Finds this user's uploaded signature document, if any, from their full document list. */
|
||||
findSignatureDocument(userId: number): Observable<any> {
|
||||
return this.getUserDocuments(userId).pipe(
|
||||
map((docs) => (docs || []).find((d) => d.name === SponsorDDService.SIGNATURE_DOCUMENT_NAME) || null)
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetches this user's signature (if any) as a data URI ready to use in an <img> src or print HTML. */
|
||||
getSignatureDataUri(userId: number): Observable<string | null> {
|
||||
return this.findSignatureDocument(userId).pipe(
|
||||
switchMap((doc) => {
|
||||
if (!doc) {
|
||||
return of(null);
|
||||
}
|
||||
return this.getSignatureBlob(userId, doc.id).pipe(
|
||||
switchMap(
|
||||
(blob) =>
|
||||
new Observable<string>((subscriber) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
subscriber.next(reader.result as string);
|
||||
subscriber.complete();
|
||||
};
|
||||
reader.onerror = () => subscriber.error(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -104,18 +104,22 @@ export class CheckerInboxComponent implements OnInit {
|
||||
];
|
||||
|
||||
/**
|
||||
* Entities whose pending edits are gated by our own m_savings_product_temp lock rather than the
|
||||
* native command-source pipeline. They're excluded from Checker Inbox entirely - approving/rejecting
|
||||
* them here would call the native /makercheckers/{id}?command=approve endpoint, which replays the
|
||||
* original command handler directly and never touches our temp table, leaving its lock stuck forever.
|
||||
* Action them from Approvals -> Pending Product Edits instead, where that's handled correctly.
|
||||
* Entities whose pending edits are gated by our own custom staging tables (m_savings_product_temp
|
||||
* for products, the savings account edit-request table for Nominee/Digital-Feature edits) rather
|
||||
* than the native command-source pipeline. They're excluded from Checker Inbox entirely -
|
||||
* approving/rejecting them here would call the native /makercheckers/{id}?command=approve endpoint,
|
||||
* which replays the original command handler directly and never touches our staging table, leaving
|
||||
* its lock/hold stuck forever. Action them from Approvals -> Pending Product Edits / Pending Account
|
||||
* Edits instead, where that's handled correctly.
|
||||
*/
|
||||
private readonly productEditEntities = [
|
||||
'SAVINGSPRODUCT',
|
||||
'CURRENTPRODUCT',
|
||||
'SHAREPRODUCT',
|
||||
'FIXEDDEPOSITPRODUCT',
|
||||
'RECURRINGDEPOSITPRODUCT'
|
||||
'RECURRINGDEPOSITPRODUCT',
|
||||
'SAVINGSACCOUNT',
|
||||
'CURRENTACCOUNT'
|
||||
];
|
||||
|
||||
private excludeProductEdits(items: any[]): any[] {
|
||||
|
||||
+20
@@ -212,6 +212,26 @@
|
||||
<span class="dri-detail__val">{{ reviewRequest.description }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (reviewRequest.dd_face_value != null) {
|
||||
<div class="dri-detail">
|
||||
<span class="dri-detail__lbl">DD Face Value</span>
|
||||
<span class="dri-detail__val">₹{{ reviewRequest.dd_face_value | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="dri-detail">
|
||||
<span class="dri-detail__lbl">Charges</span>
|
||||
<span class="dri-detail__val">₹{{ reviewRequest.dd_charges | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="dri-detail">
|
||||
<span class="dri-detail__lbl">GST ({{ reviewRequest.dd_gst_percentage }}%)</span>
|
||||
<span class="dri-detail__val">₹{{ reviewRequest.dd_gst_amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="dri-detail">
|
||||
<span class="dri-detail__lbl">Total Collected</span>
|
||||
<span class="dri-detail__val"
|
||||
><strong>₹{{ reviewRequest.amount | number: '1.2-2' }}</strong></span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@if (reviewRequest.status === 'REJECTED' && reviewRequest.rejection_reason) {
|
||||
<div class="dri-detail dri-detail--wide">
|
||||
<span class="dri-detail__lbl">Rejection Reason</span>
|
||||
|
||||
+2
@@ -247,10 +247,12 @@ export class DepositRequestsComponent implements OnInit {
|
||||
this.actioning = false;
|
||||
if (res) {
|
||||
this.reviewRequest.status = 'APPROVED';
|
||||
const voucherNo = res.voucherNo || this.reviewRequest.voucher_no;
|
||||
this.actionResult = {
|
||||
type: 'success',
|
||||
lines: [
|
||||
'Deposit approved — amount credited to the customer account.',
|
||||
voucherNo ? `Voucher No: ${voucherNo}` : '',
|
||||
res.txnId ? `Transaction Ref: ${res.txnId}` : ''
|
||||
].filter(Boolean)
|
||||
};
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
<!--
|
||||
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="psae-tab-content">
|
||||
@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="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="createdOn">
|
||||
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
|
||||
<td mat-cell *matCellDef="let edit">{{ 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 {
|
||||
—
|
||||
}
|
||||
</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 — Account "{{ selectedEdit.accountNo }}"</h3>
|
||||
<button mat-icon-button (click)="closeCompare()"><fa-icon icon="times"></fa-icon></button>
|
||||
</div>
|
||||
|
||||
@if (selectedEdit.facilityConfigSubmitted) {
|
||||
<div class="psae-diff-block">
|
||||
<h4>Digital & Account Features</h4>
|
||||
<table class="psae-diff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feature</th>
|
||||
<th>Proposed Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of entries(selectedEdit.proposedFacilityConfig); 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.nomineeSubmitted) {
|
||||
<div class="psae-diff-block">
|
||||
<h4>Nominee Change</h4>
|
||||
<table class="psae-diff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Proposed Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of entries(selectedEdit.proposedNominee); 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.facilityConfigSubmitted && !selectedEdit.nomineeSubmitted) {
|
||||
<p class="psae-diff-loading">No proposed changes recorded on this edit.</p>
|
||||
}
|
||||
|
||||
@if (selectedEdit.rejectReason) {
|
||||
<p class="psae-reject-reason"><strong>Reject Reason:</strong> {{ selectedEdit.rejectReason }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!showData) {
|
||||
<div class="empty-state">
|
||||
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
|
||||
<p class="empty-title">No pending account edits</p>
|
||||
<p class="empty-text">There are no Nominee / Digital-Feature edits awaiting approval at this time.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 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;
|
||||
$text-muted: #64748b;
|
||||
|
||||
.psae-tab-content {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.psae-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.psae-search-field {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.psae-btn {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px !important;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
margin-right: 6px;
|
||||
|
||||
fa-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.psae-btn-view {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.psae-btn-approve {
|
||||
border-color: #16a34a;
|
||||
color: #16a34a;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
.psae-btn-reject {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
&--pending {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
&--hold {
|
||||
background: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
&--savings {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
&--current {
|
||||
background: #f3e8ff;
|
||||
color: #6b21a8;
|
||||
}
|
||||
|
||||
&--fd {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
&--rd {
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 24px;
|
||||
background: #fff;
|
||||
border: 1px dashed $border;
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
margin: 16px 0;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 42px;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: $text-muted;
|
||||
max-width: 380px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
MatTableDataSource,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} 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';
|
||||
import { CurrentAccountsService } from 'app/current-accounts/current-accounts.service';
|
||||
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Checker-facing list of savings account Nominee / Digital-Feature edits awaiting approval.
|
||||
* Mirrors PendingSavingsProductsComponent's list + compare + approve/reject pattern.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-pending-savings-account-edits',
|
||||
templateUrl: './pending-savings-account-edits.component.html',
|
||||
styleUrls: ['./pending-savings-account-edits.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatIcon,
|
||||
MatIconButton
|
||||
]
|
||||
})
|
||||
export class PendingSavingsAccountEditsComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private dialog = inject(MatDialog);
|
||||
private savingsService = inject(SavingsService);
|
||||
private currentAccountsService = inject(CurrentAccountsService);
|
||||
|
||||
dataSource: MatTableDataSource<any>;
|
||||
showData = false;
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'accountNo',
|
||||
'accountType',
|
||||
'makerUsername',
|
||||
'createdOn',
|
||||
'holdAmount',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
|
||||
selectedEdit: any = null;
|
||||
|
||||
constructor() {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter(filterValue: string = '') {
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
}
|
||||
|
||||
formatDate(date: any): string {
|
||||
if (!date) return '';
|
||||
const d = Array.isArray(date)
|
||||
? new Date(date[0], date[1] - 1, date[2], date[3] || 0, date[4] || 0)
|
||||
: new Date(date);
|
||||
return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
/** Readable list of "key: value" pairs for the proposed facility/nominee changes. */
|
||||
entries(obj: any): { key: string; value: any }[] {
|
||||
if (!obj) return [];
|
||||
return Object.keys(obj).map((key) => ({ key, value: obj[key] }));
|
||||
}
|
||||
|
||||
viewCompare(edit: any): void {
|
||||
this.selectedEdit = this.selectedEdit === edit ? null : edit;
|
||||
}
|
||||
|
||||
closeCompare(): void {
|
||||
this.selectedEdit = null;
|
||||
}
|
||||
|
||||
approve(edit: any): void {
|
||||
const isCurrent = edit.accountType === 'Current';
|
||||
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
|
||||
data: {
|
||||
heading: isCurrent ? 'Approve Current Account Edit' : 'Approve Savings Account Edit',
|
||||
dialogContext: `Apply the pending Nominee/Digital-Feature edit for account "${edit.accountNo}"? This cannot be undone.`,
|
||||
type: 'basic'
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response?.confirm) {
|
||||
if (isCurrent) {
|
||||
this.currentAccountsService.approveCurrentAccountEdit(edit.currentAccountId).subscribe(() => this.reload());
|
||||
} else {
|
||||
this.savingsService.approveSavingsAccountEdit(edit.savingsAccountId).subscribe(() => this.reload());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reject(edit: any): void {
|
||||
const isCurrent = edit.accountType === 'Current';
|
||||
const formfields: FormfieldBase[] = [
|
||||
new InputBase({
|
||||
controlName: 'rejectReason',
|
||||
label: 'Reason for rejection',
|
||||
value: '',
|
||||
type: 'text',
|
||||
required: false
|
||||
})
|
||||
];
|
||||
const dialogRef = this.dialog.open(FormDialogComponent, {
|
||||
data: {
|
||||
title: `Reject edit for account "${edit.accountNo}"`,
|
||||
layout: { addButtonText: 'Reject' },
|
||||
formfields
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response?.data) {
|
||||
const rejectReason = response.data.value.rejectReason || '';
|
||||
if (isCurrent) {
|
||||
this.currentAccountsService
|
||||
.rejectCurrentAccountEdit(edit.currentAccountId, rejectReason)
|
||||
.subscribe(() => this.reload());
|
||||
} else {
|
||||
this.savingsService
|
||||
.rejectSavingsAccountEdit(edit.savingsAccountId, rejectReason)
|
||||
.subscribe(() => this.reload());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.closeCompare();
|
||||
const url = this.router.url;
|
||||
this.router
|
||||
.navigateByUrl('/checker-inbox-and-tasks', { skipLocationChange: true })
|
||||
.then(() => this.router.navigate([url]));
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,18 @@
|
||||
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Product Edits</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./pending-savings-account-edits']"
|
||||
routerLinkActive
|
||||
#pendingSavingsAccountEdits="routerLinkActive"
|
||||
[active]="pendingSavingsAccountEdits.isActive"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Account Edits</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Resolve } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { CurrentAccountsService } from 'app/current-accounts/current-accounts.service';
|
||||
|
||||
/**
|
||||
* Resolves all current account Nominee/Digital-Feature edits currently awaiting checker approval,
|
||||
* for the Pending Current Account Edits screen.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GetPendingCurrentAccountEdits implements Resolve<any> {
|
||||
private currentAccountsService = inject(CurrentAccountsService);
|
||||
|
||||
resolve(): Observable<any> {
|
||||
return this.currentAccountsService.getAllPendingCurrentAccountEdits();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Resolve } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SavingsService } from 'app/savings/savings.service';
|
||||
|
||||
/**
|
||||
* Resolves all savings account Nominee/Digital-Feature edits currently awaiting checker approval,
|
||||
* for the Pending Savings Account Edits screen.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GetPendingSavingsAccountEdits implements Resolve<any> {
|
||||
private savingsService = inject(SavingsService);
|
||||
|
||||
resolve(): Observable<any> {
|
||||
return this.savingsService.getAllPendingSavingsAccountEdits();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { RejectedAccountComponent } from './checker-inbox-and-tasks-tabs/rejecte
|
||||
import { TerminatedAccountComponent } from './checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component';
|
||||
import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component';
|
||||
import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component';
|
||||
import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component';
|
||||
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
|
||||
|
||||
/** Custom Resolvers */
|
||||
@@ -43,6 +44,8 @@ import { GetPendingAccounts } from './common-resolvers/getPendingAccounts.resolv
|
||||
import { GetRejectedAccounts } from './common-resolvers/getRejectedAccounts.resolver';
|
||||
import { GetTerminatedAccounts } from './common-resolvers/getTerminatedAccounts.resolver';
|
||||
import { GetPendingSavingsProducts } from './common-resolvers/getPendingSavingsProducts.resolver';
|
||||
import { GetPendingSavingsAccountEdits } from './common-resolvers/getPendingSavingsAccountEdits.resolver';
|
||||
import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurrentAccountEdits.resolver';
|
||||
|
||||
/** Tasks Routes */
|
||||
const routes: Routes = [
|
||||
@@ -126,6 +129,19 @@ const routes: Routes = [
|
||||
pendingSavingsProductsData: GetPendingSavingsProducts
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'pending-savings-account-edits',
|
||||
component: PendingSavingsAccountEditsComponent,
|
||||
data: { title: 'Pending Account Edits' },
|
||||
resolve: {
|
||||
pendingSavingsAccountEditsData: GetPendingSavingsAccountEdits,
|
||||
pendingCurrentAccountEditsData: GetPendingCurrentAccountEdits
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'pending-current-account-edits',
|
||||
redirectTo: 'pending-savings-account-edits'
|
||||
},
|
||||
{
|
||||
path: 'withdrawal-requests',
|
||||
component: WithdrawalRequestsComponent,
|
||||
@@ -203,7 +219,9 @@ const routes: Routes = [
|
||||
GetPendingAccounts,
|
||||
GetRejectedAccounts,
|
||||
GetTerminatedAccounts,
|
||||
GetPendingSavingsProducts
|
||||
GetPendingSavingsProducts,
|
||||
GetPendingSavingsAccountEdits,
|
||||
GetPendingCurrentAccountEdits
|
||||
]
|
||||
})
|
||||
export class TasksRoutingModule {}
|
||||
|
||||
Reference in New Issue
Block a user