diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 09d2415c2..d69269b7a 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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, diff --git a/src/app/cashier/my-deposit-requests/my-deposit-requests.component.html b/src/app/cashier/my-deposit-requests/my-deposit-requests.component.html index 04711b259..24baa334b 100644 --- a/src/app/cashier/my-deposit-requests/my-deposit-requests.component.html +++ b/src/app/cashier/my-deposit-requests/my-deposit-requests.component.html @@ -218,6 +218,26 @@ {{ viewRequest.txn_id }} } + @if (viewRequest.dd_face_value != null) { +
+ DD Face Value + ₹{{ viewRequest.dd_face_value | number: '1.2-2' }} +
+
+ Charges + ₹{{ viewRequest.dd_charges | number: '1.2-2' }} +
+
+ GST ({{ viewRequest.dd_gst_percentage }}%) + ₹{{ viewRequest.dd_gst_amount | number: '1.2-2' }} +
+
+ Total Collected + ₹{{ viewRequest.amount | number: '1.2-2' }} +
+ } @if (viewRequest.description) {
Description diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 631fdfbca..02417e843 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -70,6 +70,13 @@ Transaction Entry
+ @@ -300,6 +307,59 @@
+ + + @if (isDdCashGlRow(row)) { +
+
+ DD Charges & GST — Admin Rate {{ ddGstPercentage }}% +
+
+ +
+ ₹ + +
+
+
+ +
+ ₹ + +
+
+
+ +
+ ₹ + +
+
+
+
+
+ } } @@ -957,11 +1017,15 @@ Denomination Entry - @if (denominationTotal > 0) { - ₹{{ denominationTotal | number: '1.2-2' }} - } - @if (isCashPayment && denominationTotal <= 0) { - ⚠ Required for Cash + @if (isSelectedRowDdCashGl) { + Target ₹{{ ddTargetTotal | number: '1.2-2' }} + } @else { + @if (denominationTotal > 0) { + ₹{{ denominationTotal | number: '1.2-2' }} + } + @if (isCashPayment && denominationTotal <= 0) { + ⚠ Required for Cash + } }
@@ -1011,7 +1075,26 @@ - @if (denominationTotal > 0) { + @if (isSelectedRowDdCashGl) { +
+ @if (denominationTotal <= 0) { + ⚠ Count notes/coins totalling ₹{{ ddTargetTotal | number: '1.2-2' }} + } @else if (ddDenomMatchesTarget()) { + ✓ Matches Total To Collect ₹{{ ddTargetTotal | number: '1.2-2' }} + } @else { + + ⚠ Must total ₹{{ ddTargetTotal | number: '1.2-2' }} (counted ₹{{ + denominationTotal | number: '1.2-2' + }}) + + } + +
+ } @else if (denominationTotal > 0) {
+ @@ -1325,6 +1415,58 @@
+ + + @if (isDdCashGlRow(row)) { +
+
+ DD Charges & GST — Admin Rate {{ ddGstPercentage }}% +
+
+ +
+ ₹ + +
+
+
+ +
+ ₹ + +
+
+
+ +
+ ₹ + +
+
+
+
+
+ } } @@ -1539,8 +1681,26 @@ >{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }} + @if (confirmData.isDdCashGl) { +
+ DD Face Value{{ confirmData.symbol }}{{ confirmData.ddFaceValue | number: '1.2-2' }} +
+
+ Charges{{ confirmData.symbol }}{{ confirmData.ddCharges | number: '1.2-2' }} +
+
+ GST ({{ confirmData.ddGstPercentage }}%){{ confirmData.symbol }}{{ confirmData.ddGstAmount | number: '1.2-2' }} +
+ }
- Deposit Amount + {{ confirmData.isDdCashGl ? 'Total To Collect' : 'Deposit Amount' }} +{{ confirmData.symbol }}{{ confirmData.amount | number: '1.2-2' }} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss index 7edb43e45..ecb496960 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.scss +++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss @@ -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% { diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index f20434ee7..36ac86cf0 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -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 = { + 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) + } + : {}) }; } diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 9e87b4350..02bbf43c2 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -976,6 +976,15 @@ + + + @@ -1300,7 +1309,7 @@
Instruments & Members
- diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index cb1ec686f..58bd89ba6 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -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, diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index dd7a1e7bc..f54ef9507 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -228,6 +228,34 @@
+ +
+ +
+ +
+
+

Sponsor Banks

+

Manage banks on whose behalf demand drafts are issued

+
+ +
+
+ + +
+ +
+ +
+
+

Sponsor DD GST / GL Settings

+

Set the GST rate charged on DD issuance commission, and its GL account

+
+ +
+
+
diff --git a/src/app/organization/organization.component.ts b/src/app/organization/organization.component.ts index bc8508799..73f5b0bd6 100644 --- a/src/app/organization/organization.component.ts +++ b/src/app/organization/organization.component.ts @@ -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' + }); + } } diff --git a/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts b/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts new file mode 100644 index 000000000..6b3aa53ce --- /dev/null +++ b/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts @@ -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: ` +

Search Customer

+ +

+ Fill in any one field, or combine several to narrow the search (e.g. name + account number). +

+
+ + Name + + + + + Account Number + + + + + Aadhaar Number + + + + + PAN Number + + + + + Address + + +
+ + + +
+

No matching customer found.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ r.displayName }}Account + {{ r.accountNo }} ({{ accountTypeLabel(r.accountType) }}) + No account + Mobile{{ r.mobileNo || '-' }}Branch{{ r.officeName || '-' }}PAN{{ r.panNumber || '-' }}Aadhaar{{ r.aadhaarNumber || '-' }}Address{{ r.address || '-' }} + +
+
+
+ + + + + `, + 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); + 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('/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(); + } +} diff --git a/src/app/sponsor-dd-reconciliation/reco-batch-detail/reco-batch-detail.component.html b/src/app/sponsor-dd-reconciliation/reco-batch-detail/reco-batch-detail.component.html new file mode 100644 index 000000000..6885b2d19 --- /dev/null +++ b/src/app/sponsor-dd-reconciliation/reco-batch-detail/reco-batch-detail.component.html @@ -0,0 +1,146 @@ + +
+
+ +

Reconciliation Batch: {{ batchDetails.batchReference }}

+

+ File: {{ batchDetails.fileName }} | Status: {{ batchDetails.status }} +

+
+ +
+
+ Total Scroll Items + {{ batchDetails.totalRecords }} +
+
+ Total Amount + ₹{{ batchDetails.totalAmount | number: '1.2-2' }} +
+
+ Auto Matched + {{ batchDetails.successCount }} +
+
+ Exceptions + {{ + batchDetails.mismatchCount + }} +
+
+ +
+ Filter Status: + + All + Exact Match + Amount Mismatch + Unadvised Clearing + Duplicate Clearing + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DD Number + {{ element.ddNumber }} + Scroll Amount₹{{ element.clearingAmount | number: '1.2-2' }}Clearing Date{{ element.clearingDate }}Clearing Ref No.{{ element.clearingRefNumber }}Match Status + + {{ element.matchStatus.replace('_', ' ') }} + + Remarks / Resolution{{ element.remarks || '-' }}Actions + + + Reconciled + +
+ +
+ No scroll records found for the selected status filter. +
+
+
diff --git a/src/app/sponsor-dd-reconciliation/reco-batch-detail/reco-batch-detail.component.ts b/src/app/sponsor-dd-reconciliation/reco-batch-detail/reco-batch-detail.component.ts new file mode 100644 index 000000000..93d1740b6 --- /dev/null +++ b/src/app/sponsor-dd-reconciliation/reco-batch-detail/reco-batch-detail.component.ts @@ -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: ` +

Resolve Clearing Exception (DD No: {{ data.record.ddNumber }})

+ +
+

+ Clearing Item status is {{ data.record.matchStatus.replace('_', ' ') }}. Sponsor Scroll Amount: ₹{{ data.record.clearingAmount | number: '1.2-2' }}. +

+ + + Resolution Action + + Force Match (Map to Customer Debit) + + Action is required + + +
+ + Savings Transaction ID + + + + + Current Transaction ID + + +
+ + + Supervisor Authorization Remarks + + Remarks are required + +
+
+ + + + + `, + 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); + 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 }); + } + }); + } +} diff --git a/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.html b/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.html new file mode 100644 index 000000000..07641675c --- /dev/null +++ b/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.html @@ -0,0 +1,109 @@ + +
+
+
+

Demand Draft Reconciliation

+

+ Upload and process daily clearing scroll statements from the Sponsor Bank to auto-match and reconcile issued + drafts. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Batch Reference + {{ element.batchReference }} + Clearing File{{ element.fileName }}Status + + {{ element.status.replace('_', ' ') }} + + Total Items{{ element.totalRecords }}Total Amount₹{{ element.totalAmount | number: '1.2-2' }}Matched + {{ element.successCount }} + Exceptions + {{ element.mismatchCount }} + Uploaded On{{ element.createdDate | date: 'medium' }}Actions + +
+ +
+ No clearing files uploaded yet. Click "Upload Clearing Scroll" to reconcile clearing sheets. +
+
+
diff --git a/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.ts b/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.ts new file mode 100644 index 000000000..9bebd7216 --- /dev/null +++ b/src/app/sponsor-dd-reconciliation/reco-batch-list/reco-batch-list.component.ts @@ -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 + }); + } + }); + } +} diff --git a/src/app/sponsor-dd-reconciliation/reco-batch-upload/reco-batch-upload.component.html b/src/app/sponsor-dd-reconciliation/reco-batch-upload/reco-batch-upload.component.html new file mode 100644 index 000000000..7473c13bd --- /dev/null +++ b/src/app/sponsor-dd-reconciliation/reco-batch-upload/reco-batch-upload.component.html @@ -0,0 +1,53 @@ + +
+
+ +

Upload Sponsor Clearing Scroll

+

Upload a CSV format clearing scroll file containing Sponsor Bank draft payments.

+
+ + +
+ +
+ +
+

Choose a clearing file or drag & drop it here

+

Supports only .csv format files.

+
+ +
+
+
{{ selectedFile.name }}
+
{{ selectedFile.size / 1024 | number: '1.1-1' }} KB
+
+ +
+ +
+ +
{{ progressMessage }}
+
+ +
+ + +
+
+
diff --git a/src/app/sponsor-dd-reconciliation/reco-batch-upload/reco-batch-upload.component.ts b/src/app/sponsor-dd-reconciliation/reco-batch-upload/reco-batch-upload.component.ts new file mode 100644 index 000000000..d0db7ede4 --- /dev/null +++ b/src/app/sponsor-dd-reconciliation/reco-batch-upload/reco-batch-upload.component.ts @@ -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. + } +} diff --git a/src/app/sponsor-dd-reconciliation/sponsor-dd-reconciliation-routing.module.ts b/src/app/sponsor-dd-reconciliation/sponsor-dd-reconciliation-routing.module.ts new file mode 100644 index 000000000..f8d1dcf2f --- /dev/null +++ b/src/app/sponsor-dd-reconciliation/sponsor-dd-reconciliation-routing.module.ts @@ -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 {} diff --git a/src/app/sponsor-dd-reconciliation/sponsor-dd-reconciliation.module.ts b/src/app/sponsor-dd-reconciliation/sponsor-dd-reconciliation.module.ts new file mode 100644 index 000000000..51feea6af --- /dev/null +++ b/src/app/sponsor-dd-reconciliation/sponsor-dd-reconciliation.module.ts @@ -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 {} diff --git a/src/app/sponsor-dd-reconciliation/sponsor-dd-reconciliation.service.ts b/src/app/sponsor-dd-reconciliation/sponsor-dd-reconciliation.service.ts new file mode 100644 index 000000000..dc99ff212 --- /dev/null +++ b/src/app/sponsor-dd-reconciliation/sponsor-dd-reconciliation.service.ts @@ -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 { + return this.http.get('/sponsordd/reconcile/batches'); + } + + getReconciliationBatch(batchId: number): Observable { + return this.http.get(`/sponsordd/reconcile/batches/${batchId}`); + } + + getReconciliationRecords(batchId: number): Observable { + return this.http.get(`/sponsordd/reconcile/batches/${batchId}/records`); + } + + uploadClearingScroll(data: { fileName: string; fileContent: string }): Observable { + return this.http.post('/sponsordd/reconcile/upload', data); + } + + resolveMismatch( + recordId: number, + data: { + actionType: string; // FORCE_MATCH + resolvedSavingsTxnId?: number; + resolvedCurrentTxnId?: number; + } + ): Observable { + return this.http.post(`/sponsordd/reconcile/resolve/${recordId}`, data); + } +} diff --git a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html new file mode 100644 index 000000000..da5657779 --- /dev/null +++ b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html @@ -0,0 +1,101 @@ + +
+
+
+

Sponsor Bank DD Inventory

+

Manage allocated ranges of physical Demand Draft leaflets received from Sponsor Banks.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Batch ID{{ element.id }}Sponsor Bank{{ element.sponsorBankName }}Branch{{ element.branchName }}Start No.{{ element.seriesStart }}End No.{{ element.seriesEnd }}Total Leaves{{ element.totalLeaves }}Available Leaves + {{ element.availableLeaves }} + Received Date{{ element.receivedDate }}Status + + {{ element.status }} + + Actions + +
+ +
+ No DD series registered yet. Click "Register DD Series" to add stock. +
+
+
diff --git a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts new file mode 100644 index 000000000..edd2f06f9 --- /dev/null +++ b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts @@ -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: ` +

Add Sponsor DD Inventory

+ +
+ + Sponsor Bank + + {{ sb.name }} + + Don't see your sponsor bank? Add it under Organization > Sponsor Banks. + Sponsor Bank is required + + + + Branch + + {{ branch.name }} + + Branch is required + + +
+ + Series Start + + Required + + + + Series End + + Required + +
+ + + Received Date + + + + Received Date is required + +
+
+ + + + + `, + 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); + 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('/sponsorbanks').subscribe({ + next: (data) => { + this.sponsorBanks = data.map((sb) => ({ id: sb.id, name: sb.name })); + } + }); + } + + loadBranches(): void { + this.http.get('/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 }); + } + }); + } +} diff --git a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html new file mode 100644 index 000000000..49ee7aed2 --- /dev/null +++ b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html @@ -0,0 +1,166 @@ + +
+
+
+

DD Issuance Approvals

+

+ Review and approve/reject DD issuance requests submitted by clerks. Approving posts the real GL entries and + issues the draft. +

+
+ +
+ + + Search by DD Leaflet No. + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Req. ID{{ element.id }}DD No. + {{ element.ddNumber }} +
+ Slip · {{ batchSiblingCount(element) }} drafts +
+
Client{{ element.clientName }}Beneficiary{{ element.beneficiaryName }}Face Value{{ element.faceValue | number: '1.2-2' }}Payment Method + + Cheque {{ element.chequeNumber || '' }} + Cash (Walk-in{{ element.walkinPurchaserName ? ': ' + element.walkinPurchaserName : '' }}) + Account Debit + + Payment Type{{ paymentTypeLabel(element) }}Status + + {{ element.status }} + + Submitted By{{ element.makerUsername }}Submitted On{{ element.createdDate | date: 'dd MMM yyyy, HH:mm' }}Actions +
+ + +
+
+ Approving/rejecting applies to all {{ batchSiblingCount(element) }} drafts in this slip. +
+
+ Cash received: GL entry #{{ element.cashReceiptJournalEntryId }} (pending approval) +
+
+
{{ element.rejectionReason }}
+
+ Cash refunded to purchaser: GL entry #{{ element.cashRefundJournalEntryId }} +
+
+
+
+ Savings txn #{{ element.resultingSavingsTransactionId }} +
+
+ Current txn #{{ element.resultingCurrentTransactionId }} +
+
+ Cash received: GL entry #{{ element.cashReceiptJournalEntryId }} +
+
GL entry #{{ element.resultingJournalEntryId }}
+
by {{ element.checkerUsername }}, {{ element.checkerDate | date: 'dd MMM, HH:mm' }}
+
+
+ +
+ No DD issuance requests match the selected filter. +
+ + +
+
diff --git a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts new file mode 100644 index 000000000..2354fda34 --- /dev/null +++ b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts @@ -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([]); + + 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: ` +

+ Reject DD Issuance {{ data.request.batchId ? 'Slip' : 'Request' }} (DD No: {{ data.request.ddNumber + }}{{ data.siblingCount > 1 ? ' + ' + (data.siblingCount - 1) + ' more' : '' }}) +

+ +

+ This slip has {{ data.siblingCount }} drafts — rejecting applies to all of them together. +

+
+ + Rejection Reason + + Reason is required + +
+
+ + + + + `, + 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); + 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 }); + } + }); + } +} diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.html b/src/app/sponsor-dd/dd-list/dd-list.component.html new file mode 100644 index 000000000..ab18d5ec0 --- /dev/null +++ b/src/app/sponsor-dd/dd-list/dd-list.component.html @@ -0,0 +1,189 @@ + +
+
+
+ +

DD Leaflet Registry

+

+ Sponsor Bank: {{ inventoryDetails.sponsorBankName }} | Series Range: + {{ inventoryDetails.seriesStart }} - {{ inventoryDetails.seriesEnd }} +

+
+
+ + + Search by DD Leaflet No. + + + +
+ + +
+

+ Only DDs actually ISSUED today are shown - AVAILABLE, CANCELLED, STOPPED and CLEARED are hidden. Click "Showing All + Dates" above to see everything. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DD Leaflet No. + {{ element.ddNumber }} + Status + + {{ element.status }} + + Face Value + {{ element.faceValue > 0 ? (element.faceValue | number: '1.2-2') : '-' }} + Purchaser / Client{{ element.issuedToClientName || '-' }}Beneficiary{{ element.beneficiaryName || '-' }}Issued Date{{ element.issuedDate || '-' }}Advice Status + {{ element.status === 'ISSUED' ? element.adviceStatus : '-' }} + Actions +
+ + + Pending Approval (Req #{{ element.pendingIssueRequestId }}) + + + + + + + + Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }}) + +
+
+ +
+ {{ dataSource.data.length === 0 ? 'Loading leaflets...' : 'No leaflets match the selected filter.' }} +
+ + +
+
diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.ts b/src/app/sponsor-dd/dd-list/dd-list.component.ts new file mode 100644 index 000000000..01bf708a3 --- /dev/null +++ b/src/app/sponsor-dd/dd-list/dd-list.component.ts @@ -0,0 +1,1772 @@ +/** + * 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, DatePipe } from '@angular/common'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, 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 { 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 { debounceTime } from 'rxjs/operators'; +import { SponsorDDService } from '../sponsor-dd.service'; + +@Component({ + selector: 'mifosx-dd-list', + standalone: true, + imports: [ + CommonModule, + RouterModule, + MatCardModule, + MatTableModule, + MatPaginatorModule, + MatButtonModule, + MatDialogModule, + MatSnackBarModule, + MatFormFieldModule, + MatInputModule, + FaIconComponent + ], + templateUrl: './dd-list.component.html', + styles: [ + ` + .leaflet-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; + } + .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.available { + background-color: #dcfce7; + color: #15803d; + } + .status-badge.issued { + background-color: #dbeafe; + color: #1d4ed8; + } + .status-badge.cleared { + background-color: #f1f5f9; + color: #475569; + } + .status-badge.cancelled { + background-color: #fee2e2; + color: #b91c1c; + } + .status-badge.stopped { + background-color: #fef3c7; + color: #b45309; + } + .action-btn-group { + display: flex; + gap: 8px; + } + .pending-badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; + background-color: #fef3c7; + color: #b45309; + } + .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; + } + .hint-text { + color: #64748b; + font-size: 13px; + margin: -8px 0 16px 0; + } + ` + ] +}) +export class DdListComponent implements OnInit, AfterViewInit { + private sponsorDDService = inject(SponsorDDService); + private route = inject(ActivatedRoute); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + + @ViewChild(MatPaginator) paginator!: MatPaginator; + + inventoryId!: number; + inventoryDetails: any = null; + displayedColumns: string[] = [ + 'ddNumber', + 'status', + 'faceValue', + 'issuedToClientName', + 'beneficiaryName', + 'issuedDate', + 'adviceStatus', + 'actions' + ]; + dataSource = new MatTableDataSource([]); + + readonly statusOptions: string[] = [ + 'AVAILABLE', + 'ALL', + 'ISSUED', + 'CANCELLED', + 'STOPPED', + 'CLEARED' + ]; + selectedStatus = 'ALL'; + searchText = ''; + /** Default view: only leaflets actually ISSUED today - excludes AVAILABLE (unissued) and CANCELLED/STOPPED/CLEARED. */ + todayOnly = true; + private readonly todayStr = DdListComponent.toDateStr(new Date()); + + private static toDateStr(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + ngOnInit(): void { + this.inventoryId = +this.route.snapshot.params['inventoryId']; + 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()); + // Today Only means exactly that: DDs actually ISSUED today - not AVAILABLE (unissued), and not + // CANCELLED/STOPPED/CLEARED either, regardless of which status filter button is also selected. + const todayMatches = !this.todayOnly || (data.status === 'ISSUED' && data.issuedDate === this.todayStr); + return statusMatches && searchMatches && todayMatches; + }; + this.loadInventoryDetails(); + this.loadLeaflets(); + } + + ngAfterViewInit(): void { + this.dataSource.paginator = this.paginator; + } + + loadInventoryDetails(): void { + this.sponsorDDService.getInventoryById(this.inventoryId).subscribe({ + next: (data) => { + this.inventoryDetails = data; + } + }); + } + + loadLeaflets(): void { + this.sponsorDDService.getLeafletsByInventory(this.inventoryId).subscribe({ + next: (data) => { + this.dataSource.data = data; + this.applyFilter(); + }, + error: (err) => { + this.snackBar.open('Error loading leaflets: ' + (err.error?.message || err.message), 'Close', { + duration: 5000 + }); + } + }); + } + + statusCount(status: string): number { + const all = this.dataSource.data; + return status === 'ALL' ? all.length : all.filter((leaflet) => leaflet.status === status).length; + } + + filterByStatus(status: string): void { + this.selectedStatus = status; + // Today Only forces status=ISSUED under the hood - picking any other specific status (AVAILABLE, + // CANCELLED, STOPPED, CLEARED) would otherwise always show zero results while it's active, since + // those statuses can never be "issued today". Explicitly picking one of them means the user wants + // that status regardless of date, so drop the Today Only override rather than fight it. + if (this.todayOnly && status !== 'ALL' && status !== 'ISSUED') { + this.todayOnly = false; + } + this.applyFilter(); + } + + onSearchChange(value: string): void { + this.searchText = value; + this.applyFilter(); + } + + toggleTodayOnly(): void { + this.todayOnly = !this.todayOnly; + // Same conflict the other way round: turning Today Only back on while AVAILABLE/CANCELLED/STOPPED/ + // CLEARED is still selected would silently show zero results again - switch to ISSUED so what's + // highlighted actually matches what's displayed. + if (this.todayOnly && this.selectedStatus !== 'ALL' && this.selectedStatus !== 'ISSUED') { + this.selectedStatus = 'ISSUED'; + } + this.applyFilter(); + } + + private applyFilter(): void { + this.dataSource.filter = `${this.selectedStatus}|${this.searchText}|${this.todayOnly}`; + if (this.dataSource.paginator) { + this.dataSource.paginator.firstPage(); + } + } + + openIssueDialog(leaflet: any): void { + const dialogRef = this.dialog.open(IssueDraftDialogComponent, { + width: '560px', + data: { leaflet, inventoryId: this.inventoryId }, + disableClose: true + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.loadLeaflets(); + } + }); + } + + openCancelDialog(leaflet: any): void { + const dialogRef = this.dialog.open(CancelDraftDialogComponent, { + width: '500px', + data: { leaflet, action: 'cancel' }, + disableClose: true + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.loadLeaflets(); + } + }); + } + + onStopPayment(leaflet: any): void { + if (confirm(`Are you sure you want to place a Stop Payment on DD No. ${leaflet.ddNumber}?`)) { + this.sponsorDDService + .stopPaymentDemandDraft(leaflet.id, { remarks: 'Stop Payment instruction requested by customer.' }) + .subscribe({ + next: () => { + this.snackBar.open('Stop Payment instruction submitted successfully', 'Close', { duration: 3000 }); + this.loadLeaflets(); + }, + error: (err) => { + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 }); + } + }); + } + } + + openPrintDialog(leaflet: any): void { + const dialogRef = this.dialog.open(PrintDraftDialogComponent, { + width: '95vw', + maxWidth: '1000px', + maxHeight: '90vh', + hasBackdrop: false, + data: { leaflet, sponsorBankName: this.inventoryDetails?.sponsorBankName || '' } + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.loadLeaflets(); + } + }); + } + + openRequestReprintDialog(leaflet: any): void { + const dialogRef = this.dialog.open(RequestReprintDialogComponent, { + width: '450px', + data: { leaflet }, + disableClose: true + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.loadLeaflets(); + } + }); + } +} + +// ── ISSUE DRAFT DIALOG COMPONENT ── +@Component({ + selector: 'mifosx-issue-draft-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatDatepickerModule, + MatNativeDateModule, + MatButtonModule + ], + providers: [DatePipe], + template: ` +

Issue Demand Draft (DD No: {{ data.leaflet.ddNumber }})

+ +

+ This submits a request to a Bank Manager / Head Cashier for approval. Money only moves (client debited, Sponsor + DD Payable GL credited) once the request is approved. +

+
+ + How is the customer paying? + + Debit from their Savings/Current Account + Cheque + Cash (Walk-in, non-customer) + + + + + + Account Type + + Savings Account + Current Account + + + + + {{ issueForm.get('accountType')?.value === 'CURRENT' ? 'Current' : 'Savings' }} Account Number + + Looking up account... + Client: {{ resolvedClientName }} + Account Number is required + +

+ No {{ issueForm.get('accountType')?.value === 'CURRENT' ? 'current' : 'savings' }} account found with that + number. +

+
+ + +
+ + Cheque Number + + Required + + + + Cheque Bank Name + + Required + +
+
+ + +

+ Walk-in purchaser paying cash — need not be an existing bank customer. Record their details for the DD + record; the Aadhaar number is masked before it is stored. +

+ + Purchaser Name + + Purchaser Name is required + + + + Purchaser Address / Location + + Address is required + + + + Aadhaar Number + + Aadhaar Number is required + Enter a valid 12-digit Aadhaar number + + +

+ Before selecting a request below, ask the cashier to submit it via Transaction Entry: + AC.TP = GL, target account = DD Cash Suspense, TXN TP = Cash, amount = + ₹{{ expectedCashTotal | number: '1.2-2' }} (face value + charges + GST). +

+ + Cashier Deposit Request + + + Voucher #{{ d.voucherNo || d.id }} — ₹{{ d.amount | number: '1.2-2' }} + + (Face ₹{{ d.ddFaceValue | number: '1.2-2' }} + Charges ₹{{ d.ddCharges | number: '1.2-2' }} + GST ₹{{ + d.ddGstAmount | number: '1.2-2' + }}) + + by {{ d.cashierUsername }} + + + + No approved cashier deposit requests found yet. +
Refresh after the cashier + submits and a manager approves theirs. + + Required + + + + + + Client (Customer) ID + + Auto-detected from the account above + Client ID is required + + + + Beneficiary Name + + Beneficiary is required + Beneficiary name cannot exceed 120 characters + + +
+ + Payable At - Branch + + Required + + + + Payable At - City + + Required + +
+ + + DD Face Value (₹) + + + Locked — from the DD Charges & GST breakdown entered at Transaction Entry. + + Face Value is required + + + + Charges / Commission (₹) + + + Locked — from the DD Charges & GST breakdown entered at Transaction Entry. + + + Optional — credited to Charges Income if entered. + Plus GST ({{ gstPercentage }}%): ₹{{ gstAmountPreview | number: '1.2-2' }} + + Charges cannot be negative + + + +

+ {{ combinedDraftCount }} drafts in this slip — combined face value ₹{{ + combinedFaceValueTotal | number: '1.2-2' + }} + (plus each line's own charges/GST) will be debited in one transaction. A leaflet is auto-allocated per extra + draft. +

+
+
+ Draft #{{ i + 2 }} + +
+ + Beneficiary Name + + Beneficiary is required + +
+ + Payable At - Branch + + Required + + + Payable At - City + + Required + +
+
+ + DD Face Value (₹) + + Required + + + Charges / Commission (₹) + + Cannot be negative + +
+
+ +
+ + + Issuing Branch + + {{ branch.name }} + + Issuing Branch is required + + + + Issue Date + + + + Issue Date is required + +
+ + + + + + `, + 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; + } + .hint-text { + color: #64748b; + font-size: 13px; + margin: 0 0 12px 0; + } + .lookup-success { + color: #15803d; + } + .lookup-error { + color: #b91c1c; + font-size: 12px; + margin: -8px 0 0 0; + } + .slip-line { + display: flex; + flex-direction: column; + gap: 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 12px; + background: #f8fafc; + } + .slip-line-header { + display: flex; + justify-content: space-between; + align-items: center; + } + ` + ] +}) +export class IssueDraftDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private sponsorDDService = inject(SponsorDDService); + private http = inject(HttpClient); + private datePipe = inject(DatePipe); + private dialogRef = inject(MatDialogRef); + private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + protected data = inject(MAT_DIALOG_DATA); + + issueForm!: FormGroup; + loading = false; + branches: any[] = []; + + accountLookupState: 'idle' | 'loading' | 'found' | 'not-found' = 'idle'; + resolvedClientName = ''; + resolvedAccountId: number | null = null; + private accountLookupToken = 0; + + gstPercentage = 0; + gstAmountPreview = 0; + ddLimitAmount: number | null = null; + availableCashierDeposits: any[] = []; + + ngOnInit(): void { + this.sponsorDDService.getGlMappings().subscribe({ + next: (mappings) => { + this.gstPercentage = mappings && mappings.length ? (mappings[0].gstPercentage ?? 0) : 0; + this.ddLimitAmount = mappings && mappings.length ? (mappings[0].ddLimitAmount ?? null) : null; + this.recalculateGstPreview(); + } + }); + this.loadAvailableCashierDeposits(); + + this.issueForm = this.fb.group({ + clientId: [ + { value: '', disabled: true }, + Validators.required + ], + beneficiaryName: [ + '', + [ + Validators.required, + Validators.maxLength(120) + ] + ], + payableAtBranch: [ + '', + Validators.required + ], + payableAtCity: [ + '', + Validators.required + ], + faceValue: [ + '', + [ + Validators.required, + Validators.min(0.01) + ] + ], + chargesAmount: [ + 0, + [Validators.min(0)] + ], + issuingBranchId: [ + '', + Validators.required + ], + issuedDate: [ + new Date(), + Validators.required + ], + paymentMethod: [ + 'ACCOUNT_DEBIT', + Validators.required + ], + accountType: ['SAVINGS'], + accountNumber: [ + '', + Validators.required + ], + chequeNumber: [''], + chequeBankName: [''], + walkinPurchaserName: [''], + walkinPurchaserAddress: [''], + walkinPurchaserAadhaar: [''], + cashierDepositRequestId: [''], + // Extra drafts for a multi-DD slip (one account debit/cheque funds this draft plus every + // line here) - not supported for CASH, cleared automatically if the method switches to it. + additionalLines: this.fb.array([]) + }); + + this.forceUppercase([ + 'beneficiaryName', + 'payableAtBranch', + 'payableAtCity', + 'chequeBankName', + 'walkinPurchaserName', + 'walkinPurchaserAddress' + ]); + + this.issueForm.get('chargesAmount')?.valueChanges.subscribe(() => this.recalculateGstPreview()); + + this.issueForm.get('paymentMethod')?.valueChanges.subscribe((method) => { + const accountNumber = this.issueForm.get('accountNumber'); + const clientId = this.issueForm.get('clientId'); + const chequeNumber = this.issueForm.get('chequeNumber'); + const chequeBankName = this.issueForm.get('chequeBankName'); + const walkinPurchaserName = this.issueForm.get('walkinPurchaserName'); + const walkinPurchaserAddress = this.issueForm.get('walkinPurchaserAddress'); + const walkinPurchaserAadhaar = this.issueForm.get('walkinPurchaserAadhaar'); + const cashierDepositRequestId = this.issueForm.get('cashierDepositRequestId'); + + accountNumber?.clearValidators(); + chequeNumber?.clearValidators(); + chequeBankName?.clearValidators(); + walkinPurchaserName?.clearValidators(); + walkinPurchaserAddress?.clearValidators(); + walkinPurchaserAadhaar?.clearValidators(); + cashierDepositRequestId?.clearValidators(); + clientId?.setValue(''); + cashierDepositRequestId?.setValue(''); + this.issueForm.get('faceValue')?.enable(); + this.issueForm.get('chargesAmount')?.enable(); + this.accountLookupState = 'idle'; + this.resolvedClientName = ''; + this.resolvedAccountId = null; + + if (method === 'CHEQUE') { + chequeNumber?.setValidators([Validators.required]); + chequeBankName?.setValidators([Validators.required]); + clientId?.enable(); + } else if (method === 'CASH') { + walkinPurchaserName?.setValidators([Validators.required]); + walkinPurchaserAddress?.setValidators([Validators.required]); + walkinPurchaserAadhaar?.setValidators([ + Validators.required, + Validators.pattern(/^\d{12}$/) + ]); + cashierDepositRequestId?.setValidators([Validators.required]); + clientId?.disable(); + this.loadAvailableCashierDeposits(); + // A multi-DD slip is only supported for ACCOUNT_DEBIT/CHEQUE - drop any extra lines rather + // than silently ignore them if the method switches to CASH. + while (this.additionalLinesArray.length > 0) { + this.additionalLinesArray.removeAt(0); + } + } else { + accountNumber?.setValidators([Validators.required]); + clientId?.disable(); + } + accountNumber?.updateValueAndValidity(); + chequeNumber?.updateValueAndValidity(); + chequeBankName?.updateValueAndValidity(); + walkinPurchaserName?.updateValueAndValidity(); + walkinPurchaserAddress?.updateValueAndValidity(); + walkinPurchaserAadhaar?.updateValueAndValidity(); + cashierDepositRequestId?.updateValueAndValidity(); + }); + + this.issueForm + .get('accountNumber') + ?.valueChanges.pipe(debounceTime(400)) + .subscribe(() => this.lookupAccount()); + this.issueForm.get('accountType')?.valueChanges.subscribe(() => this.lookupAccount()); + + // Voucher already carries the DD Face Value/Charges breakdown the cashier entered at + // Transaction Entry - pre-fill and lock these fields instead of asking staff to retype them. + this.issueForm.get('cashierDepositRequestId')?.valueChanges.subscribe((id) => { + const faceValueCtl = this.issueForm.get('faceValue'); + const chargesCtl = this.issueForm.get('chargesAmount'); + const selected = this.availableCashierDeposits.find((d: any) => d.id === id); + if (selected && selected.ddFaceValue != null) { + faceValueCtl?.setValue(selected.ddFaceValue); + chargesCtl?.setValue(selected.ddCharges ?? 0); + faceValueCtl?.disable(); + chargesCtl?.disable(); + } else { + faceValueCtl?.enable(); + chargesCtl?.enable(); + } + this.recalculateGstPreview(); + }); + + this.loadBranches(); + } + + private forceUppercase(controlNames: string[]): void { + controlNames.forEach((name) => { + this.issueForm.get(name)?.valueChanges.subscribe((value) => { + if (typeof value === 'string' && value !== value.toUpperCase()) { + this.issueForm.get(name)?.setValue(value.toUpperCase(), { emitEvent: false }); + } + }); + }); + } + + private recalculateGstPreview(): void { + const charges = Number(this.issueForm?.get('chargesAmount')?.value) || 0; + this.gstAmountPreview = charges > 0 && this.gstPercentage > 0 ? (charges * this.gstPercentage) / 100 : 0; + } + + get expectedCashTotal(): number { + const faceValue = Number(this.issueForm?.get('faceValue')?.value) || 0; + const charges = Number(this.issueForm?.get('chargesAmount')?.value) || 0; + return faceValue + charges + this.gstAmountPreview; + } + + loadAvailableCashierDeposits(): void { + this.sponsorDDService.getAvailableCashierDepositRequests().subscribe({ + next: (data) => (this.availableCashierDeposits = data || []), + error: () => (this.availableCashierDeposits = []) + }); + } + + private lookupAccount(): void { + const accountNumber = (this.issueForm.get('accountNumber')?.value || '').trim(); + const clientIdCtrl = this.issueForm.get('clientId'); + const token = ++this.accountLookupToken; + + if (!accountNumber) { + this.accountLookupState = 'idle'; + this.resolvedClientName = ''; + this.resolvedAccountId = null; + clientIdCtrl?.setValue(''); + return; + } + + this.accountLookupState = 'loading'; + const accountType = this.issueForm.get('accountType')?.value; + const endpoint = accountType === 'CURRENT' ? '/currentaccounts' : '/savingsaccounts'; + + this.http.get(`${endpoint}?accountNo=${encodeURIComponent(accountNumber)}&offset=0&limit=1`).subscribe({ + next: (res: any) => { + if (token !== this.accountLookupToken) return; + const items: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []); + const match = items.find((x: any) => x.accountNo === accountNumber) ?? null; + if (match?.id && match?.clientId) { + this.accountLookupState = 'found'; + this.resolvedClientName = match.clientName; + this.resolvedAccountId = match.id; + clientIdCtrl?.setValue(match.clientId); + } else { + this.accountLookupState = 'not-found'; + this.resolvedAccountId = null; + clientIdCtrl?.setValue(''); + } + }, + error: () => { + if (token !== this.accountLookupToken) return; + this.accountLookupState = 'not-found'; + this.resolvedAccountId = null; + clientIdCtrl?.setValue(''); + } + }); + } + + loadBranches(): void { + this.http.get('/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.issueForm.invalid) return; + + const formVal = this.issueForm.getRawValue(); + if (formVal.paymentMethod === 'ACCOUNT_DEBIT' && this.accountLookupState !== 'found') { + this.snackBar.open( + 'Enter a valid account number and wait for the client to be detected before submitting', + 'Close', + { duration: 4000 } + ); + return; + } + const additionalLines = this.additionalLinesArray.controls.map((c) => c.value); + const allFaceValues = [ + formVal.faceValue, + ...additionalLines.map((l) => l.faceValue) + ]; + if (this.ddLimitAmount != null && this.ddLimitAmount > 0) { + const overLimit = allFaceValues.find((fv) => Number(fv) > this.ddLimitAmount!); + if (overLimit != null) { + this.snackBar.open(`A draft's face value exceeds the configured DD limit of ₹${this.ddLimitAmount}`, 'Close', { + duration: 5000 + }); + return; + } + } + + this.loading = true; + const formattedDate = this.datePipe.transform(formVal.issuedDate, 'yyyy-MM-dd') || ''; + const isCheque = formVal.paymentMethod === 'CHEQUE'; + const isCash = formVal.paymentMethod === 'CASH'; + const isAccountDebit = formVal.paymentMethod === 'ACCOUNT_DEBIT'; + + if (additionalLines.length > 0) { + // Multi-DD slip: one account debit/cheque funds this draft plus every additional line, + // submitted together so the checker approves/rejects the whole slip as one unit. + const batchPayload = { + clientId: formVal.clientId, + issuingBranchId: formVal.issuingBranchId, + issuedDate: formattedDate, + paymentMethod: formVal.paymentMethod as 'ACCOUNT_DEBIT' | 'CHEQUE', + savingsAccountId: + isAccountDebit && formVal.accountType === 'SAVINGS' ? (this.resolvedAccountId ?? undefined) : undefined, + currentAccountId: + isAccountDebit && formVal.accountType === 'CURRENT' ? (this.resolvedAccountId ?? undefined) : undefined, + chequeNumber: isCheque ? formVal.chequeNumber : undefined, + chequeBankName: isCheque ? formVal.chequeBankName : undefined, + lines: [ + { + beneficiaryName: formVal.beneficiaryName, + payableAtBranch: formVal.payableAtBranch, + payableAtCity: formVal.payableAtCity, + faceValue: formVal.faceValue, + chargesAmount: formVal.chargesAmount || 0 + }, + ...additionalLines.map((l) => ({ + beneficiaryName: l.beneficiaryName, + payableAtBranch: l.payableAtBranch, + payableAtCity: l.payableAtCity, + faceValue: l.faceValue, + chargesAmount: l.chargesAmount || 0 + })) + ] + }; + + this.sponsorDDService.submitIssueRequestBatch(this.data.inventoryId, batchPayload).subscribe({ + next: (result) => { + this.dialogRef.close(true); + this.dialog.open(SlipSubmittedDialogComponent, { width: '480px', data: { result } }); + }, + error: (err) => { + this.loading = false; + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 }); + } + }); + return; + } + + const payload = { + clientId: isCash ? undefined : formVal.clientId, + walkinPurchaserName: isCash ? formVal.walkinPurchaserName : undefined, + walkinPurchaserAddress: isCash ? formVal.walkinPurchaserAddress : undefined, + walkinPurchaserAadhaar: isCash ? formVal.walkinPurchaserAadhaar : undefined, + beneficiaryName: formVal.beneficiaryName, + payableAtBranch: formVal.payableAtBranch, + payableAtCity: formVal.payableAtCity, + faceValue: formVal.faceValue, + chargesAmount: formVal.chargesAmount || 0, + issuingBranchId: formVal.issuingBranchId, + issuedDate: formattedDate, + paymentMethod: formVal.paymentMethod, + savingsAccountId: isAccountDebit && formVal.accountType === 'SAVINGS' ? this.resolvedAccountId : undefined, + currentAccountId: isAccountDebit && formVal.accountType === 'CURRENT' ? this.resolvedAccountId : undefined, + chequeNumber: isCheque ? formVal.chequeNumber : undefined, + chequeBankName: isCheque ? formVal.chequeBankName : undefined, + cashierDepositRequestId: isCash ? formVal.cashierDepositRequestId : undefined + }; + + this.sponsorDDService.submitIssueRequest(this.data.leaflet.id, payload).subscribe({ + next: () => { + this.snackBar.open('DD issuance request submitted for approval', 'Close', { duration: 3000 }); + this.dialogRef.close(true); + }, + error: (err) => { + this.loading = false; + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 }); + } + }); + } + + get additionalLinesArray(): FormArray { + return this.issueForm.get('additionalLines') as FormArray; + } + + get combinedDraftCount(): number { + return 1 + this.additionalLinesArray.controls.length; + } + + get combinedFaceValueTotal(): number { + const extra = this.additionalLinesArray.controls.reduce( + (sum, c) => sum + (Number(c.get('faceValue')?.value) || 0), + 0 + ); + return (Number(this.issueForm.get('faceValue')?.value) || 0) + extra; + } + + addLine(): void { + const group = this.fb.group({ + beneficiaryName: [ + '', + [ + Validators.required, + Validators.maxLength(120) + ] + ], + payableAtBranch: [ + '', + Validators.required + ], + payableAtCity: [ + '', + Validators.required + ], + faceValue: [ + '', + [ + Validators.required, + Validators.min(0.01) + ] + ], + chargesAmount: [ + 0, + [Validators.min(0)] + ] + }); + [ + 'beneficiaryName', + 'payableAtBranch', + 'payableAtCity' + ].forEach((name) => { + group.get(name)?.valueChanges.subscribe((value) => { + if (typeof value === 'string' && value !== value.toUpperCase()) { + group.get(name)?.setValue(value.toUpperCase(), { emitEvent: false }); + } + }); + }); + this.additionalLinesArray.push(group); + } + + removeLine(index: number): void { + this.additionalLinesArray.removeAt(index); + } +} + +// ── SLIP SUBMITTED SUMMARY DIALOG COMPONENT ── +// Shown right after a multi-DD slip is submitted - leaflet auto-allocation only happens inside that +// submit call (locked against a concurrent slip grabbing the same leaflet), so this is the first +// point the maker can actually see which DD number/beneficiary/amount was assigned to each line. +@Component({ + selector: 'mifosx-slip-submitted-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule + ], + template: ` +

DD Slip Submitted — Awaiting Approval

+ +

+ Slip #{{ data.result.batchId }} — {{ data.result.lines.length }} draft(s). Leaflet just auto-allocated per line: +

+ + + + + + + + + + + + + + + +
DD No.BeneficiaryFace Value (₹)
{{ l.ddNumber }}{{ l.beneficiaryName }}{{ l.faceValue | number: '1.2-2' }}
+
+ + + + `, + styles: [ + ` + .hint-text { + color: #64748b; + font-size: 13px; + margin: 0 0 12px 0; + } + .slip-summary-table { + width: 100%; + border-collapse: collapse; + } + .slip-summary-table th, + .slip-summary-table td { + text-align: left; + padding: 6px 8px; + border-bottom: 1px solid #e2e8f0; + font-size: 13px; + } + ` + ] +}) +export class SlipSubmittedDialogComponent { + protected dialogRef = inject(MatDialogRef); + protected data = inject(MAT_DIALOG_DATA); +} + +// ── CANCEL DRAFT DIALOG COMPONENT ── +@Component({ + selector: 'mifosx-cancel-draft-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule + ], + template: ` +

Cancel Demand Draft (DD No: {{ data.leaflet.ddNumber }})

+ +
+

+ You are about to cancel DD number {{ data.leaflet.ddNumber }} valued at + ₹{{ data.leaflet.faceValue | number: '1.2-2' }}. +

+ + + Cancellation Reason / Remarks + + Remarks/Reason is required + + +
+ + Refund Savings Transaction ID + + + + + Refund Current Transaction ID + + +
+
+
+ + + + + `, + 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 CancelDraftDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private sponsorDDService = inject(SponsorDDService); + private dialogRef = inject(MatDialogRef); + private snackBar = inject(MatSnackBar); + protected data = inject(MAT_DIALOG_DATA); + + cancelForm!: FormGroup; + loading = false; + + ngOnInit(): void { + this.cancelForm = this.fb.group({ + remarks: [ + '', + Validators.required + ], + refundSavingsTransactionId: [null], + refundCurrentTransactionId: [null] + }); + } + + onCancel(): void { + this.dialogRef.close(false); + } + + onSubmit(): void { + if (this.cancelForm.invalid) return; + + this.loading = true; + const formVal = this.cancelForm.value; + + const payload = { + remarks: formVal.remarks, + refundSavingsTransactionId: formVal.refundSavingsTransactionId ? formVal.refundSavingsTransactionId : undefined, + refundCurrentTransactionId: formVal.refundCurrentTransactionId ? formVal.refundCurrentTransactionId : undefined + }; + + this.sponsorDDService.cancelDemandDraft(this.data.leaflet.id, payload).subscribe({ + next: () => { + this.snackBar.open('Demand draft cancelled 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 }); + } + }); + } +} + +// ── REQUEST REPRINT DIALOG COMPONENT ── +@Component({ + selector: 'mifosx-request-reprint-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule + ], + template: ` +

Request Reprint (DD No: {{ data.leaflet.ddNumber }})

+ +

+ This DD has already been printed once. Printing it again requires approval from a Bank Manager / Head Cashier + before you can print it. +

+
+ + Reason for Reprint + + Reason is required + +
+
+ + + + + `, + 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 RequestReprintDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private sponsorDDService = inject(SponsorDDService); + private dialogRef = inject(MatDialogRef); + private snackBar = inject(MatSnackBar); + protected data = inject(MAT_DIALOG_DATA); + + reprintForm!: FormGroup; + loading = false; + + ngOnInit(): void { + this.reprintForm = this.fb.group({ + reason: [ + '', + Validators.required + ] + }); + } + + onCancel(): void { + this.dialogRef.close(false); + } + + onSubmit(): void { + if (this.reprintForm.invalid) return; + + this.loading = true; + this.sponsorDDService.requestReprint(this.data.leaflet.id, this.reprintForm.value.reason).subscribe({ + next: () => { + this.snackBar.open('Reprint request submitted for approval', 'Close', { duration: 3000 }); + this.dialogRef.close(true); + }, + error: (err) => { + this.loading = false; + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 }); + } + }); + } +} + +// ── PRINT DRAFT DIALOG COMPONENT ── +@Component({ + selector: 'mifosx-print-draft-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule + ], + template: ` + +
+
+
{{ data.leaflet.ddNumber }}
+
{{ data.leaflet.issuedDate }}
+
{{ data.leaflet.beneficiaryName }}
+
{{ amountInWords }} Only
+
₹{{ amountInFigures }}/-
+
+ Payable at: {{ payableAtText }} +
+ +
+
+
+ + + + + `, + styles: [ + ` + .dd-preview-wrapper { + overflow: auto; + max-width: 100%; + display: flex; + justify-content: center; + padding: 24px 0 24px 0; + } + .dd-preview-wrapper .dd-leaflet-sheet { + transform: scale(0.8); + transform-origin: top left; + flex-shrink: 0; + } + .dd-leaflet-sheet { + position: relative; + margin: 0 auto; + border: 1px dashed #94a3b8; + background: #f8fafc; + } + .dd-field { + position: absolute; + font-family: 'Courier New', monospace; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + color: #1e293b; + } + .dd-signature { + position: absolute; + object-fit: contain; + } + ` + ] +}) +export class PrintDraftDialogComponent implements OnInit { + protected data = inject(MAT_DIALOG_DATA); + private dialogRef = inject(MatDialogRef); + private sponsorDDService = inject(SponsorDDService); + private snackBar = inject(MatSnackBar); + + signatureDataUri: string | null = null; + + ngOnInit(): void { + if (this.data.leaflet.approverUserId) { + this.sponsorDDService.getSignatureDataUri(this.data.leaflet.approverUserId).subscribe({ + next: (dataUri) => (this.signatureDataUri = dataUri), + error: () => (this.signatureDataUri = null) + }); + } + } + + /** + * Physical size of the pre-printed DD leaflet stationery, in millimetres — matches the + * ~203mm x 90mm SBI DD reference this was calibrated against. + */ + readonly pageWidthMm = 203; + readonly pageHeightMm = 90; + + /** + * Where each variable field lands on the leaflet, in millimetres from the top-left corner. + * Date / Beneficiary Name / Amount in Words / Amount in Figures / DD Number / Signature match + * the coordinates measured off a real SBI DD sample. PLACEHOLDERS regardless — do a test print + * on a spare/voided leaflet and adjust each top/left until the text lines up with the blank + * space already printed on YOUR bank's stationery (every bank's layout differs). + * + * Every field prints at the same font/size (12px Courier New, set once on `.dd-field`) — none + * of them shrink to fit. `width` is set on fields that may need to wrap onto a second/third + * line (Beneficiary Name up to 120 characters, Amount in Words, Payable At) so a long value + * wraps downward instead of overlapping the field to its right - widened to 120mm so even a + * full 120-character name only needs 2 lines (~8mm) at this font size. The real DD's row + * spacing here is tight (only ~10mm between the Beneficiary and Amount-in-Words rows), so a + * maximum-length name leaves only ~2mm of clearance - fine in practice since real beneficiary + * names rarely approach the 120-char cap, but verify on a real test print. + */ + readonly fieldPositions = { + ddNumber: { top: 6, left: 20 }, + date: { top: 6, left: 160 }, + payee: { top: 22, left: 40, width: 120 }, + amountWords: { top: 32, left: 38, width: 120 }, + amountFigures: { top: 40, left: 165 }, + /** Same row as Signature, kept clear of it (which starts at left:150). */ + payableAt: { top: 57, left: 40, width: 105 }, + signature: { top: 57, left: 150, width: 45, height: 15 } + }; + + fieldStyle(pos: { top: number; left: number; width?: number; height?: number }): Record { + const style: Record = { top: pos.top + 'mm', left: pos.left + 'mm' }; + if (pos.width) { + style['width'] = pos.width + 'mm'; + style['whiteSpace'] = 'normal'; + style['lineHeight'] = '1.3'; + } + if (pos.height) { + style['height'] = pos.height + 'mm'; + } + return style; + } + + get amountInWords(): string { + return PrintDraftDialogComponent.numberToIndianWords(Math.round(this.data.leaflet.faceValue || 0)); + } + + get amountInFigures(): string { + return PrintDraftDialogComponent.formatIndianNumber(this.data.leaflet.faceValue || 0); + } + + get payableAtText(): string { + const branch = this.data.leaflet.payableAtBranch; + const city = this.data.leaflet.payableAtCity; + return [ + branch, + city + ] + .filter((part) => !!part) + .join(', '); + } + + onClose(): void { + this.dialogRef.close(); + } + + /** + * Recording the print (consuming the first-print allowance or an approved reprint request) + * happens only here, when the user actually clicks Print - not when the dialog was opened. + * That way just previewing and closing without printing never consumes anything. + */ + onPrint(): void { + this.sponsorDDService.recordPrint(this.data.leaflet.id).subscribe({ + next: () => { + this.doPrint(); + this.dialogRef.close(true); + }, + error: (err) => { + this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 }); + } + }); + } + + /** + * Prints via a hidden iframe instead of window.print() on the main document. This is the + * standard technique for "print just this content" features — the main app page's DOM/CSS + * is never touched by the print process, so there's nothing to get stuck or fail to revert + * afterward (the class of bug behind window.print() leaving the app looking broken until a + * manual refresh). + */ + private doPrint(): void { + const iframe = document.createElement('iframe'); + iframe.style.position = 'fixed'; + iframe.style.right = '0'; + iframe.style.bottom = '0'; + iframe.style.width = '0'; + iframe.style.height = '0'; + iframe.style.border = '0'; + document.body.appendChild(iframe); + + const doc = iframe.contentWindow?.document; + if (!doc) { + document.body.removeChild(iframe); + return; + } + doc.open(); + doc.write(this.buildPrintHtml()); + doc.close(); + + const cleanup = () => { + if (iframe.parentNode) { + document.body.removeChild(iframe); + } + }; + + iframe.onload = () => { + iframe.contentWindow?.focus(); + iframe.contentWindow?.print(); + // No reliable afterprint on every browser for iframe content — remove it shortly after + // the print dialog would reasonably have been shown/dismissed. + setTimeout(cleanup, 1000); + }; + } + + private buildPrintHtml(): string { + const l = this.data.leaflet; + const p = this.fieldPositions; + const field = (text: string, pos: { top: number; left: number; width?: number }) => { + const wrapStyle = pos.width + ? `width:${pos.width}mm; white-space:normal; line-height:1.3;` + : 'white-space:nowrap;'; + return `
${this.escapeHtml(text)}
`; + }; + + return ` + + + + + + +
+ ${field(l.ddNumber, p.ddNumber)} + ${field(l.issuedDate, p.date)} + ${field(l.beneficiaryName, p.payee)} + ${field(this.amountInWords + ' Only', p.amountWords)} + ${field('₹' + this.amountInFigures + '/-', p.amountFigures)} + ${this.payableAtText ? field('Payable at: ' + this.payableAtText, p.payableAt) : ''} + ${ + this.signatureDataUri + ? `` + : '' + } +
+ + `; + } + + private escapeHtml(value: string): string { + const div = document.createElement('div'); + div.textContent = value ?? ''; + return div.innerHTML; + } + + private static formatIndianNumber(amount: number): string { + const fixed = amount.toFixed(2); + const [ + intPart, + decPart + ] = fixed.split('.'); + const isNegative = intPart.startsWith('-'); + const digits = isNegative ? intPart.slice(1) : intPart; + const lastThree = digits.slice(-3); + const rest = digits.slice(0, -3); + const grouped = rest === '' ? lastThree : rest.replace(/\B(?=(\d{2})+(?!\d))/g, ',') + ',' + lastThree; + return (isNegative ? '-' : '') + grouped + '.' + decPart; + } + + private static readonly ONES = [ + '', + 'One', + 'Two', + 'Three', + 'Four', + 'Five', + 'Six', + 'Seven', + 'Eight', + 'Nine', + 'Ten', + 'Eleven', + 'Twelve', + 'Thirteen', + 'Fourteen', + 'Fifteen', + 'Sixteen', + 'Seventeen', + 'Eighteen', + 'Nineteen' + ]; + private static readonly TENS = [ + '', + '', + 'Twenty', + 'Thirty', + 'Forty', + 'Fifty', + 'Sixty', + 'Seventy', + 'Eighty', + 'Ninety' + ]; + + private static twoDigits(n: number): string { + if (n < 20) return PrintDraftDialogComponent.ONES[n]; + const tens = Math.floor(n / 10); + const ones = n % 10; + return PrintDraftDialogComponent.TENS[tens] + (ones ? ' ' + PrintDraftDialogComponent.ONES[ones] : ''); + } + + private static threeDigits(n: number): string { + const hundreds = Math.floor(n / 100); + const rest = n % 100; + let out = hundreds ? PrintDraftDialogComponent.ONES[hundreds] + ' Hundred' : ''; + if (rest) out += (out ? ' ' : '') + PrintDraftDialogComponent.twoDigits(rest); + return out; + } + + /** Indian numbering system: crore / lakh / thousand / hundred. */ + static numberToIndianWords(amount: number): string { + if (amount === 0) return 'Zero Rupees'; + const crore = Math.floor(amount / 10000000); + const lakh = Math.floor((amount % 10000000) / 100000); + const thousand = Math.floor((amount % 100000) / 1000); + const hundred = amount % 1000; + + const parts: string[] = []; + if (crore) parts.push(PrintDraftDialogComponent.threeDigits(crore) + ' Crore'); + if (lakh) parts.push(PrintDraftDialogComponent.threeDigits(lakh) + ' Lakh'); + if (thousand) parts.push(PrintDraftDialogComponent.threeDigits(thousand) + ' Thousand'); + if (hundred) parts.push(PrintDraftDialogComponent.threeDigits(hundred)); + + return parts.join(' ') + ' Rupees'; + } +} diff --git a/src/app/sponsor-dd/dd-reprint-approvals/dd-reprint-approvals.component.html b/src/app/sponsor-dd/dd-reprint-approvals/dd-reprint-approvals.component.html new file mode 100644 index 000000000..56725ea6c --- /dev/null +++ b/src/app/sponsor-dd/dd-reprint-approvals/dd-reprint-approvals.component.html @@ -0,0 +1,107 @@ + +
+
+
+

DD Reprint Approvals

+

+ A DD leaflet can only be printed once without approval. Review and approve/reject requests to print an + already-printed leaflet again. +

+
+ +
+ + + Search by DD Leaflet No. + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Req. ID{{ element.id }}DD No. + {{ element.ddNumber }} + Reason{{ element.reason }}Status + + {{ element.status }} + + Requested By{{ element.makerUsername }}Requested On{{ element.createdDate | date: 'dd MMM yyyy, HH:mm' }}Actions +
+ + +
+ {{ + element.rejectionReason + }} +
+
{{ element.consumed ? 'Reprinted' : 'Awaiting print' }}
+
by {{ element.checkerUsername }}, {{ element.checkerDate | date: 'dd MMM, HH:mm' }}
+
+
+ +
+ No DD reprint requests match the selected filter. +
+ + +
+
diff --git a/src/app/sponsor-dd/dd-reprint-approvals/dd-reprint-approvals.component.ts b/src/app/sponsor-dd/dd-reprint-approvals/dd-reprint-approvals.component.ts new file mode 100644 index 000000000..ab8c0b4df --- /dev/null +++ b/src/app/sponsor-dd/dd-reprint-approvals/dd-reprint-approvals.component.ts @@ -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([]); + + 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: ` +

Reject Reprint Request (DD No: {{ data.request.ddNumber }})

+ +
+ + Rejection Reason + + Reason is required + +
+
+ + + + + `, + 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); + 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 }); + } + }); + } +} diff --git a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts new file mode 100644 index 000000000..7cbdd7af1 --- /dev/null +++ b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts @@ -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: ` +

GST / GL Settings

+ +

+ 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. +

+ +
+ + GST Percentage (%) + + Required + + Must be between 0 and 100 + + + + + DD Limit Amount + + + 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. + + Cannot be negative + + +
+ + + + + + +
+

+ GL account assignments above are set up once by an administrator and shown here read-only. +

+
+
+ + + + + `, + 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); + 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(); + } +} diff --git a/src/app/sponsor-dd/signature-settings-dialog/signature-settings-dialog.component.ts b/src/app/sponsor-dd/signature-settings-dialog/signature-settings-dialog.component.ts new file mode 100644 index 000000000..76477f247 --- /dev/null +++ b/src/app/sponsor-dd/signature-settings-dialog/signature-settings-dialog.component.ts @@ -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: ` +

My Signature

+ +

+ 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. +

+ +
+ Current signature +
+

No signature uploaded yet.

+ + +
+ + + + + + `, + 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); + 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(); + } +} diff --git a/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts b/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts new file mode 100644 index 000000000..3138111e7 --- /dev/null +++ b/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts @@ -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: ` + + `, + 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('/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: ` +

Add Sponsor Bank

+ +
+ + Sponsor Bank Name + + Name is required + + + + Description + + + + + IFSC Code + + Settlement branch's IFSC - 4 letters, '0', then 6 alphanumeric characters + Invalid IFSC format (e.g. SBIN0001234) + + +
+ + Bank Code + + + + + SWIFT Code + + +
+
+
+ + + + + `, + 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); + 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('/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 }); + } + }); + } +} diff --git a/src/app/sponsor-dd/sponsor-dd-routing.module.ts b/src/app/sponsor-dd/sponsor-dd-routing.module.ts new file mode 100644 index 000000000..d9d6c3500 --- /dev/null +++ b/src/app/sponsor-dd/sponsor-dd-routing.module.ts @@ -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 {} diff --git a/src/app/sponsor-dd/sponsor-dd.module.ts b/src/app/sponsor-dd/sponsor-dd.module.ts new file mode 100644 index 000000000..43a6a723f --- /dev/null +++ b/src/app/sponsor-dd/sponsor-dd.module.ts @@ -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 {} diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts new file mode 100644 index 000000000..463135776 --- /dev/null +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -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 { + return this.http.get('/sponsordd/inventory'); + } + + getInventoryById(id: number): Observable { + return this.http.get(`/sponsordd/inventory/${id}`); + } + + registerInventory(data: { + sponsorBankId: number; + branchId: number; + seriesStart: number; + seriesEnd: number; + receivedDate: string; // yyyy-MM-dd + }): Observable { + return this.http.post('/sponsordd/inventory', data); + } + + // ── Leaflets ── + getLeafletsByInventory(inventoryId: number): Observable { + return this.http.get(`/sponsordd/inventory/${inventoryId}/leaflets`); + } + + getLeafletById(leafletId: number): Observable { + return this.http.get(`/sponsordd/leaflets/${leafletId}`); + } + + getLeafletByDdNumber(ddNumber: string): Observable { + return this.http.get(`/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 { + return this.http.post(`/sponsordd/leaflets/${leafletId}/issue`, data); + } + + cancelDemandDraft( + leafletId: number, + data: { + remarks: string; + refundSavingsTransactionId?: number; + refundCurrentTransactionId?: number; + } + ): Observable { + return this.http.post(`/sponsordd/leaflets/${leafletId}/cancel`, data); + } + + stopPaymentDemandDraft(leafletId: number, data: { remarks: string }): Observable { + return this.http.post(`/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 { + return this.http.post(`/sponsordd/leaflets/${leafletId}/issue-request`, data); + } + + getAvailableCashierDepositRequests(): Observable { + return this.http.get('/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 { + return this.http.post(`/sponsordd/inventory/${inventoryId}/issue-request-batch`, data); + } + + approveIssueRequestBatch(batchId: number): Observable { + return this.http.post(`/sponsordd/issue-requests/batch/${batchId}/approve`, {}); + } + + rejectIssueRequestBatch(batchId: number, rejectionReason: string): Observable { + return this.http.post(`/sponsordd/issue-requests/batch/${batchId}/reject`, { rejectionReason }); + } + + // ── Sponsor DD GL mapping (back-office configuration) ── + getGlMappings(): Observable { + return this.http.get('/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 { + return this.http.post('/sponsordd/gl-mapping', data); + } + + getAllIssueRequests(status?: string): Observable { + const params = status ? { status } : {}; + return this.http.get('/sponsordd/issue-requests', { params }); + } + + getMyIssueRequests(): Observable { + return this.http.get('/sponsordd/issue-requests/mine'); + } + + getIssueRequestById(id: number): Observable { + return this.http.get(`/sponsordd/issue-requests/${id}`); + } + + approveIssueRequest(id: number): Observable { + return this.http.post(`/sponsordd/issue-requests/${id}/approve`, {}); + } + + rejectIssueRequest(id: number, rejectionReason: string): Observable { + return this.http.post(`/sponsordd/issue-requests/${id}/reject`, { rejectionReason }); + } + + // ── DD reprint requests (maker-checker) ── + recordPrint(leafletId: number): Observable { + return this.http.post(`/sponsordd/leaflets/${leafletId}/print`, {}); + } + + requestReprint(leafletId: number, reason: string): Observable { + return this.http.post(`/sponsordd/leaflets/${leafletId}/reprint-request`, { reason }); + } + + getAllReprintRequests(status?: string): Observable { + const params = status ? { status } : {}; + return this.http.get('/sponsordd/reprint-requests', { params }); + } + + approveReprintRequest(id: number): Observable { + return this.http.post(`/sponsordd/reprint-requests/${id}/approve`, {}); + } + + rejectReprintRequest(id: number, rejectionReason: string): Observable { + return this.http.post(`/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 { + return this.http.get(`/${SponsorDDService.SIGNATURE_ENTITY_TYPE}/${userId}/documents`); + } + + uploadSignature(userId: number, file: File): Observable { + 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(`/${SponsorDDService.SIGNATURE_ENTITY_TYPE}/${userId}/documents`, formData); + } + + deleteSignature(userId: number, documentId: number): Observable { + return this.http.delete(`/${SponsorDDService.SIGNATURE_ENTITY_TYPE}/${userId}/documents/${documentId}`); + } + + getSignatureBlob(userId: number, documentId: number): Observable { + 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 { + 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 src or print HTML. */ + getSignatureDataUri(userId: number): Observable { + return this.findSignatureDocument(userId).pipe( + switchMap((doc) => { + if (!doc) { + return of(null); + } + return this.getSignatureBlob(userId, doc.id).pipe( + switchMap( + (blob) => + new Observable((subscriber) => { + const reader = new FileReader(); + reader.onload = () => { + subscriber.next(reader.result as string); + subscriber.complete(); + }; + reader.onerror = () => subscriber.error(reader.error); + reader.readAsDataURL(blob); + }) + ) + ); + }) + ); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html index 5bbb46dd1..a42722cf0 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.html @@ -212,6 +212,26 @@ {{ reviewRequest.description }}
} + @if (reviewRequest.dd_face_value != null) { +
+ DD Face Value + ₹{{ reviewRequest.dd_face_value | number: '1.2-2' }} +
+
+ Charges + ₹{{ reviewRequest.dd_charges | number: '1.2-2' }} +
+
+ GST ({{ reviewRequest.dd_gst_percentage }}%) + ₹{{ reviewRequest.dd_gst_amount | number: '1.2-2' }} +
+
+ Total Collected + ₹{{ reviewRequest.amount | number: '1.2-2' }} +
+ } @if (reviewRequest.status === 'REJECTED' && reviewRequest.rejection_reason) {
Rejection Reason diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts index 21b8c8393..66c862d59 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts @@ -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) }; diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 346448b2f..326dc6572 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260710', - 'hash': '96c4a28c' + 'version': '260722', + 'hash': '1692925d' }, 'allow_switching_backend_instance': true };