Add customer search dialog and sponsor DD/reconciliation modules

Adds sponsor-dd and sponsor-dd-reconciliation feature modules, a
shared customer search dialog, and wires them into routing/shell/
organization screens alongside cashier and deposit-request updates.
This commit is contained in:
2026-07-22 11:59:31 +05:30
parent 1692925d0d
commit 3cf01e2733
36 changed files with 6257 additions and 18 deletions
+4
View File
@@ -66,6 +66,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 */
@@ -168,6 +170,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 &amp; 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 &amp; 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% {
@@ -22,6 +22,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;
@@ -39,6 +44,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 {
@@ -659,6 +666,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[] = [];
@@ -804,6 +817,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
this.loadCashVaultGl();
this.loadDdGstPercentage();
if (this.reviewMode) {
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
@@ -888,6 +902,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()
@@ -1054,7 +1080,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;
}
}
@@ -1082,7 +1110,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
tokNo: '',
paymentTypeId: this.findTransferPaymentTypeId(),
bankName: '',
routingCode: ''
routingCode: '',
ddCharges: null
};
this.rows.push(mirror);
this.selectedRow = mirror;
@@ -1103,7 +1132,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
tokNo: '',
paymentTypeId: null,
bankName: '',
routingCode: ''
routingCode: '',
ddCharges: null
};
this.rows.push(row);
}
@@ -1142,6 +1172,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.
@@ -1418,6 +1482,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;
@@ -2430,20 +2522,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
@@ -2738,7 +2865,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;
}
@@ -2746,7 +2878,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;
@@ -3151,7 +3283,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)
}
: {})
};
}
+10 -1
View File
@@ -976,6 +976,15 @@
</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">
@@ -1300,7 +1309,7 @@
<!-- Instruments & Members -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Instruments &amp; 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>
@@ -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'
});
}
}
@@ -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();
}
}
@@ -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"
>
&larr; 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"
>
&larr; 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 &amp; 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 &gt; 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">
&larr; 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 {}
+23
View File
@@ -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 {}
+267
View File
@@ -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);
})
)
);
})
);
}
}
@@ -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>
@@ -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)
};
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260710',
'hash': '96c4a28c'
'version': '260722',
'hash': '1692925d'
},
'allow_switching_backend_instance': true
};