diff --git a/src/app/app.module.ts b/src/app/app.module.ts index e10b19872..24a8d269b 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -69,6 +69,8 @@ import { DigitalModule } from './digital/digital.module'; import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module'; import { AccountsModule } from './accounts/accounts.module'; import { ElectionModule } from './election/election.module'; +import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module'; +import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module'; import { PortalModule } from '@angular/cdk/portal'; /** Main Routing Module */ @@ -174,6 +176,8 @@ export function HttpLoaderFactory(http: HttpClient) { LetterMgmtModule, AccountsModule, ElectionModule, + SponsorDDModule, + SponsorDDReconciliationModule, AppRoutingModule, NotFoundComponent, CallbackComponent, 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 07fee8993..9275e0c3e 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 7caf560e6..63fb83315 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -23,6 +23,11 @@ import { ClerkWithdrawalService } from '../clerk-withdrawal.service'; import { ClerkDisbursementService } from '../clerk-disbursement.service'; import { CashierDepositService } from '../cashier-deposit.service'; import { SettingsService } from '../../settings/settings.service'; +import { + CustomerSearchDialogComponent, + CustomerSearchResult +} from '../../shared/customer-search-dialog/customer-search-dialog.component'; +import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service'; interface TxnRow { typ: string; @@ -40,6 +45,8 @@ interface TxnRow { paymentTypeId: number | null; bankName: string; routingCode: string; + /** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */ + ddCharges: number | null; } interface Denomination { @@ -744,6 +751,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { private cashierDepositService = inject(CashierDepositService); readonly router = inject(Router); private settingsService = inject(SettingsService); + private sponsorDDService = inject(SponsorDDService); + + /** GST % on DD issuance charges — loaded from the admin GST/GL Settings (sponsor-dd module). */ + ddGstPercentage = 18; + /** Admin-configured DD limit (sponsor-dd module) — caps a single DD's face value. Null = no limit. */ + ddLimitAmount: number | null = null; today = ''; rows: TxnRow[] = []; @@ -889,6 +902,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }); this.loadCashVaultGl(); + this.loadDdGstPercentage(); if (this.reviewMode) { this.loadRequestIntoForm(this.reviewRequestId!, 'review'); @@ -973,6 +987,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }); } + /** Admin GST rate (set via GST / GL Settings in the sponsor-dd module) applied to DD issuance charges. */ + private loadDdGstPercentage(): void { + this.sponsorDDService + .getGlMappings() + .pipe(catchError(() => of([]))) + .subscribe((mappings: any[]) => { + const mapping = mappings && mappings.length ? mappings[0] : null; + this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18); + this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null; + }); + } + private loadDayStatus(): void { this.cashierService .getCashierDayStatus() @@ -1139,7 +1165,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } applyDenominationTotal(): void { - if (this.selectedRow) { + // DD + Cash + GL: amount is derived from Face Value + Charges + GST, not from counted cash — + // overwriting it here would feed back into the Charges/GST sum and compound on every click. + if (this.selectedRow && !this.isDdCashGlRow(this.selectedRow)) { this.selectedRow.amount = this.denominationTotal; } } @@ -1167,7 +1195,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { tokNo: '', paymentTypeId: this.findTransferPaymentTypeId(), bankName: '', - routingCode: '' + routingCode: '', + ddCharges: null }; this.rows.push(mirror); this.selectedRow = mirror; @@ -1188,7 +1217,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { tokNo: '', paymentTypeId: null, bankName: '', - routingCode: '' + routingCode: '', + ddCharges: null }; this.rows.push(row); } @@ -1227,6 +1257,40 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return pt?.id ?? null; } + /** TYPE=DD + TXN TP=Cash + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */ + isDdCashGlRow(row: TxnRow): boolean { + const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId); + return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL'; + } + + /** GST on DD charges only (never on the DD face value), at the admin-configured rate. */ + ddGstAmountFor(row: TxnRow): number { + const charges = Number(row.ddCharges) || 0; + if (charges <= 0) return 0; + return Math.round(charges * this.ddGstPercentage) / 100; + } + + /** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */ + ddTotalPayableFor(row: TxnRow): number { + const amount = Number(row.amount) || 0; + const charges = Number(row.ddCharges) || 0; + const gst = this.ddGstAmountFor(row); + return Math.ceil(amount + charges + gst - 1e-9); + } + + get isSelectedRowDdCashGl(): boolean { + return !!this.selectedRow && this.isDdCashGlRow(this.selectedRow); + } + + /** Cash the cashier must physically count for the selected DD row — Face Value + Charges + GST. */ + get ddTargetTotal(): number { + return this.selectedRow ? this.ddTotalPayableFor(this.selectedRow) : 0; + } + + ddDenomMatchesTarget(): boolean { + return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005; + } + /** * Submits a clerk double-entry request (maker-checker, PENDING until manager approves). * Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations. @@ -1503,6 +1567,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } } + private static readonly SEARCH_ACCOUNT_TYPE_TO_AC_TYP: Record = { + SAVINGS: 'SD', + CURRENT: 'CA', + FIXED_DEPOSIT: 'FD', + RECURRING_DEPOSIT: 'RD' + }; + + openCustomerSearch(): void { + const dialogRef = this.dialog.open(CustomerSearchDialogComponent, { + width: '960px', + maxWidth: '95vw' + }); + + dialogRef.afterClosed().subscribe((result: CustomerSearchResult | undefined) => { + if (!result || !result.accountNo) return; + // selectedRow is only set once the clerk types into A/C NO. themselves - on a fresh screen + // it's still null, so fall back to the first (only) row that addRow() created at startup. + const targetRow = this.selectedRow ?? this.rows[0]; + if (!targetRow) return; + + const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[result.accountType || ''] || 'SD'; + targetRow.acTyp = acTyp; + targetRow.acNo = result.accountNo; + this.selectedRow = targetRow; + this.loadAccountDetails(result.accountNo, acTyp); + }); + } + private loadAccountDetails(acNo: string, acTyp: string): void { this.lastLoadedAcNo = acNo; this.loadingAccount = true; @@ -2532,20 +2624,55 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId); const isCash = !!selectedPtEarly?.isCashPayment; + const isDdCashGl = this.isDdCashGlRow(row); // Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination - const amount = isCash && !this.clerkMode ? this.denominationTotal : (row.amount ?? this.denominationTotal); + // DD + Cash + GL: amount is DD face value + charges + GST (rounded up), not the counted denomination + const amount = isDdCashGl + ? this.ddTotalPayableFor(row) + : isCash && !this.clerkMode + ? this.denominationTotal + : (row.amount ?? this.denominationTotal); - if (isCash && this.denominationTotal <= 0 && !this.clerkMode) { + if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl) { this.showAlert('error', 'Denomination Required', [ 'Cash transaction: Please enter denomination (notes/coins count) first.' ]); return; } + + // DD + Cash + GL: physical cash counted must reconcile with Face Value + Charges + GST + if (isDdCashGl && !this.clerkMode) { + if (this.denominationTotal <= 0) { + this.showAlert('error', 'Denomination Required', [ + `Cash transaction: Please count notes/coins totalling ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (DD Face Value + Charges + GST).` + ]); + return; + } + if (Math.abs(this.denominationTotal - amount) > 0.005) { + this.showAlert('error', 'Denomination Mismatch', [ + `Cash counted: ₹${this.denominationTotal.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, + `Total To Collect: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (Face Value + Charges + GST)`, + 'Adjust the notes/coins breakdown to match the Total To Collect exactly.' + ]); + return; + } + } + if (!amount || amount <= 0) { this.showAlert('error', 'Amount Required', ['Please enter a valid transaction amount.']); return; } + // DD + Cash + GL: reject at cash intake, before the cashier even accepts the money, if the DD + // Face Value exceeds the admin-configured DD limit (same limit enforced again at DD issuance). + if (isDdCashGl && this.ddLimitAmount != null && this.ddLimitAmount > 0 && Number(row.amount) > this.ddLimitAmount) { + this.showAlert('error', 'DD Limit Exceeded', [ + `DD Face Value (₹${Number(row.amount).toLocaleString('en-IN', { minimumFractionDigits: 2 })}) exceeds the configured DD limit of ₹${this.ddLimitAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`, + 'Reduce the amount, or issue this as separate DDs within the limit each.' + ]); + return; + } + const isDeposit = row.dbCr === 'Cr'; // Clerk cash deposit is not permitted — cash handling (notes/coins) is cashier's responsibility @@ -2848,7 +2975,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { paymentType: selectedPt?.name ?? 'Cash', chequeNo: row.chequeNo || null, symbol: this.currencySymbol, - tokNo: this.activeWithdrawalRequest?.tok_no ?? null + tokNo: this.activeWithdrawalRequest?.tok_no ?? null, + isDdCashGl: this.isDdCashGlRow(row), + ddFaceValue: row.amount ?? 0, + ddCharges: row.ddCharges ?? 0, + ddGstPercentage: this.ddGstPercentage, + ddGstAmount: this.ddGstAmountFor(row) }; this.showConfirmDialog = true; } @@ -2856,7 +2988,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { confirmSubmit(): void { this.showConfirmDialog = false; const row = this.selectedRow!; - const amount = row.amount ?? this.denominationTotal; + const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal); const isDeposit = row.dbCr === 'Cr'; const command = isDeposit ? 'deposit' : 'withdrawal'; const accountId = this.accountDetails.id; @@ -3261,7 +3393,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { description: row.description || null, chequeNo: row.chequeNo || null, bankName: row.bankName || null, - denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null + denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null, + // DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face + // value/charges/GST back once this request is approved, instead of retyping them. + ...(this.isDdCashGlRow(row) + ? { + ddFaceValue: row.amount ?? 0, + ddCharges: row.ddCharges ?? 0, + ddGstPercentage: this.ddGstPercentage, + ddGstAmount: this.ddGstAmountFor(row) + } + : {}) }; } diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 607680008..27ba017cf 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1009,6 +1009,17 @@ + + + + + @@ -1333,7 +1344,7 @@
Instruments & Members
- diff --git a/src/app/current-accounts/current-account-stepper/current-account-currency-step/current-account-currency-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-currency-step/current-account-currency-step.component.html index 1a0f71f1c..1f088d4d0 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-currency-step/current-account-currency-step.component.html +++ b/src/app/current-accounts/current-account-stepper/current-account-currency-step/current-account-currency-step.component.html @@ -26,29 +26,31 @@
-
-
-

Balance Swap Option

-

Allows balance to be swapped or transferred between linked accounts as per product configuration.

+ @if (!nominationOnly) { +
+
+

Balance Swap Option

+

Allows balance to be swapped or transferred between linked accounts as per product configuration.

+
+
- -
-
-
-

MSME

-

Account is categorized under the Micro, Small and Medium Enterprises segment.

+
+
+

MSME

+

Account is categorized under the Micro, Small and Medium Enterprises segment.

+
+
- -
-
-
-

Auto Debit Facility

-

Enable automatic debit instructions for recurring payments linked to this account.

+
+
+

Auto Debit Facility

+

Enable automatic debit instructions for recurring payments linked to this account.

+
+
- -
+ }
diff --git a/src/app/current-accounts/current-account-stepper/current-account-currency-step/current-account-currency-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-currency-step/current-account-currency-step.component.ts index 9e8e1acd6..89bdb9e11 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-currency-step/current-account-currency-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-currency-step/current-account-currency-step.component.ts @@ -30,6 +30,7 @@ export class CurrentAccountCurrencyStepComponent implements OnChanges { @Input() primaryClientId: number | null = null; @Input() isNonIndividualClient = false; @Input() existingAccount: any = null; + @Input() nominationOnly = false; @ViewChild(NomineeFormComponent) nomineeFormComp: NomineeFormComponent; nominees: any[] = []; diff --git a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html index c040b6031..1cf4ac389 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html +++ b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html @@ -104,7 +104,7 @@
Account Type - + @for (opt of accountTypeOptions; track opt) { {{ opt }} } @@ -113,7 +113,11 @@ Customer Segment - + @for (opt of customerSegmentOptions; track opt) { {{ opt }} } @@ -157,7 +161,7 @@ Share Linking Enabled - + Yes No @@ -172,6 +176,7 @@ introducerId ? { clientId: introducerId, displayName: introducerName, cifNumber: introducerCif } : null " (introducerChange)="onIntroducerChange($event)" + [disabled]="readOnly" > } } @@ -263,7 +268,7 @@ color="primary" type="button" class="soa-add-btn" - [disabled]="totalJointSharePercentage >= 100" + [disabled]="totalJointSharePercentage >= 100 || readOnly" (click)="openAddJointHolderDialog()" > person_add Add Holder @@ -309,6 +314,7 @@ max="100" [(ngModel)]="primaryHolderShare" [ngModelOptions]="{ standalone: true }" + [disabled]="readOnly" placeholder="0" (input)="onPrimaryShareInput($event)" (blur)="onPrimaryShareBlur($event)" @@ -358,7 +364,8 @@ class="soa-sign-toggle" [class.soa-sign-toggle--mandatory]="h.isMandatory" [class.soa-sign-toggle--optional]="!h.isMandatory" - (click)="toggleJointHolderMandatory(h)" + [disabled]="readOnly" + (click)="!readOnly && toggleJointHolderMandatory(h)" > {{ h.isMandatory ? 'lock' : 'lock_open' }} {{ h.isMandatory ? 'Mandatory' : 'Optional' }} @@ -366,7 +373,13 @@ Co-Holder - @@ -448,7 +461,8 @@ type="button" class="soa-mode-card" [class.soa-mode-card--selected]="modeOfOperation === opt.value" - (click)="modeOfOperation = opt.value" + [disabled]="readOnly" + (click)="!readOnly && (modeOfOperation = opt.value)" > {{ opt.icon }}
{{ opt.label }}
@@ -503,8 +517,9 @@ [max]="maxDate" [(ngModel)]="minorDob" [ngModelOptions]="{ standalone: true }" + [disabled]="readOnly" /> - + @if (clientDob) { Auto-filled from client profile @@ -525,7 +540,11 @@ Guardian Relationship - + @for (rel of guardianRelationshipOptions; track rel) { {{ rel }} } @@ -541,6 +560,7 @@ @if (guardianName) { @@ -667,7 +687,8 @@ class="soa-cat-tile" [class.soa-cat-tile--selected]="selectedSpecialCategory === opt.value" [class.soa-cat-tile--male]="true" - (click)="selectedSpecialCategory = opt.value" + [disabled]="readOnly" + (click)="!readOnly && (selectedSpecialCategory = opt.value)" > {{ opt.icon }} {{ opt.label }} @@ -717,7 +738,8 @@ class="soa-cat-tile" [class.soa-cat-tile--selected]="selectedWeakerSection === opt.value" [class.soa-cat-tile--female]="true" - (click)="selectedWeakerSection = opt.value" + [disabled]="readOnly" + (click)="!readOnly && (selectedWeakerSection = opt.value)" > {{ opt.icon }} {{ opt.label }} @@ -775,7 +797,7 @@ color="primary" type="button" class="ni-add-btn" - [disabled]="totalSharePercentage >= 100" + [disabled]="totalSharePercentage >= 100 || readOnly" (click)="openAddRelatedClientDialog()" > person_add Add Partner @@ -826,7 +848,8 @@ class="ni-sign-toggle" [class.ni-sign-toggle--mandatory]="c.isMandatory" [class.ni-sign-toggle--optional]="!c.isMandatory" - (click)="toggleMandatory(c)" + [disabled]="readOnly" + (click)="!readOnly && toggleMandatory(c)" [title]="c.isMandatory ? 'Click to set Optional' : 'Click to set Mandatory'" > {{ c.isMandatory ? 'lock' : 'lock_open' }} @@ -838,6 +861,7 @@ mat-icon-button color="warn" type="button" + [disabled]="readOnly" (click)="removeRelatedClient(i)" class="ni-del-btn" > @@ -915,7 +939,8 @@ type="button" class="ni-sig-card" [class.ni-sig-card--selected]="signatoryRights === opt.value" - (click)="signatoryRights = opt.value" + [disabled]="readOnly" + (click)="!readOnly && (signatoryRights = opt.value)" > {{ opt.icon }}
{{ opt.label }}
diff --git a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts index 51ab90f3f..8c5592d1a 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts @@ -53,6 +53,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit { @Input() currentAccountTemplate: any; @Input() hideButtons = false; + @Input() readOnly = false; minDate = new Date(2000, 0, 1); maxDate = new Date(); @@ -236,6 +237,9 @@ export class CurrentAccountDetailsStepComponent implements OnInit { }); } } + if (this.readOnly) { + this.currentAccountDetailsForm.disable(); + } } createCurrentAccountDetailsForm() { @@ -453,6 +457,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit { } isTileDisabled(value: string): boolean { + if (this.readOnly) return true; if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; return false; diff --git a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html index 5b0970996..ae448a85a 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html +++ b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.html @@ -210,5 +210,29 @@
+ +
+
+
+ Internet Banking + {{ facilityConfig.internetBanking ? 'Enabled' : 'Disabled' }} +
+ +
+ +
+
+
+ Mobile Banking + {{ facilityConfig.mobileBanking ? 'Enabled' : 'Disabled' }} +
+ +
diff --git a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts index 49597f57a..6e5cc09d5 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-digital-step/current-account-digital-step.component.ts @@ -26,7 +26,9 @@ const DEFAULT_FACILITY_CONFIG = { interBankTotalCapFacility: false, withdrawalFacility: false, depositFacilityKyc: false, - virtualDebitCard: false + virtualDebitCard: false, + internetBanking: false, + mobileBanking: false }; @Component({ diff --git a/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts index d5d718959..dbddadaa1 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-preview-step/current-account-preview-step.component.ts @@ -78,7 +78,9 @@ export class CurrentAccountPreviewStepComponent implements OnChanges { { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' }, { key: 'withdrawalFacility', label: 'Withdrawal Facility' }, { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' }, - { key: 'virtualDebitCard', label: 'Virtual Debit Card' } + { key: 'virtualDebitCard', label: 'Virtual Debit Card' }, + { key: 'internetBanking', label: 'Internet Banking' }, + { key: 'mobileBanking', label: 'Mobile Banking' } ]; private translateService = inject(TranslateService); diff --git a/src/app/current-accounts/current-account-view/current-account-view.component.ts b/src/app/current-accounts/current-account-view/current-account-view.component.ts index 3129db777..1412dd044 100644 --- a/src/app/current-accounts/current-account-view/current-account-view.component.ts +++ b/src/app/current-accounts/current-account-view/current-account-view.component.ts @@ -124,6 +124,7 @@ export class CurrentAccountViewComponent implements OnInit { 'Add Charge': 'admin', 'Apply Annual Fees': 'admin', 'Modify Application': 'admin', + 'Edit Nominee & Features': 'admin', 'Assign Staff': 'admin', 'Unassign Staff': 'admin', Approve: 'admin', @@ -156,6 +157,7 @@ export class CurrentAccountViewComponent implements OnInit { 'Add Charge': 'plus-circle', 'Apply Annual Fees': 'receipt', 'Modify Application': 'edit', + 'Edit Nominee & Features': 'edit', 'Assign Staff': 'user-plus', 'Unassign Staff': 'user-slash', Approve: 'check-circle', @@ -314,6 +316,9 @@ export class CurrentAccountViewComponent implements OnInit { case 'Modify Application': this.router.navigate(['edit'], { relativeTo: this.route }); break; + case 'Edit Nominee & Features': + this.router.navigate(['edit-nominee-features'], { relativeTo: this.route }); + break; case 'Delete': this.deleteCurrentAccount(); break; diff --git a/src/app/current-accounts/current-account-view/current-accounts-buttons.config.ts b/src/app/current-accounts/current-account-view/current-accounts-buttons.config.ts index e5fb4df88..bbc0e3872 100644 --- a/src/app/current-accounts/current-account-view/current-accounts-buttons.config.ts +++ b/src/app/current-accounts/current-account-view/current-accounts-buttons.config.ts @@ -162,6 +162,10 @@ export class CurrentAccountButtonsConfiguration { switch (status) { case 'Active': this.optionArray = [ + { + name: 'Edit Nominee & Features', + taskPermissionName: 'SUBMITEDIT_CURRENTACCOUNT' + }, { name: 'Post Interest', taskPermissionName: 'POSTINTEREST_CURRENTACCOUNT' diff --git a/src/app/current-accounts/current-accounts-routing.module.ts b/src/app/current-accounts/current-accounts-routing.module.ts index 65539fe02..48be2cb27 100644 --- a/src/app/current-accounts/current-accounts-routing.module.ts +++ b/src/app/current-accounts/current-accounts-routing.module.ts @@ -20,6 +20,7 @@ import { StandingInstructionsTabComponent } from './current-account-view/standin import { DatatableTabsComponent } from './current-account-view/datatable-tabs/datatable-tabs.component'; import { CreateCurrentAccountComponent } from './create-current-account/create-current-account.component'; import { EditCurrentAccountComponent } from './edit-current-account/edit-current-account.component'; +import { EditCurrentAccountFeaturesComponent } from './edit-current-account-features/edit-current-account-features.component'; import { ViewTransactionComponent } from './current-account-view/transactions/view-transaction/view-transaction.component'; import { ViewChargeComponent } from './current-account-view/view-charge/view-charge.component'; import { ViewRecieptComponent } from './current-account-view/transactions/view-reciept/view-reciept.component'; @@ -170,6 +171,18 @@ const routes: Routes = [ currentAccountAndTemplate: CurrentAccountAndTemplateResolver } }, + { + path: ':currentAccountId/edit-nominee-features', + data: { + title: 'Edit Nominee & Features', + breadcrumb: 'Edit Nominee & Features', + routeParamBreadcrumb: false + }, + component: EditCurrentAccountFeaturesComponent, + resolve: { + currentAccountAndTemplate: CurrentAccountAndTemplateResolver + } + }, { path: ':currentAccountId/transactions/:id', data: { title: 'Current Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false }, diff --git a/src/app/current-accounts/current-accounts.module.ts b/src/app/current-accounts/current-accounts.module.ts index 5c7d9a284..bc321f7fb 100644 --- a/src/app/current-accounts/current-accounts.module.ts +++ b/src/app/current-accounts/current-accounts.module.ts @@ -61,6 +61,7 @@ import { CurrentAccountTransactionDatatableTabComponent } from './current-accoun import { GeneralTabComponent } from './current-account-view/general-tab/general-tab.component'; import { CurrentAccountActiveClientMembersComponent } from './current-account-stepper/current-accounts-active-client-members/current-accounts-active-client-members.component'; import { PassbookTabComponent } from './current-account-view/passbook-tab/passbook-tab.component'; +import { EditCurrentAccountFeaturesComponent } from './edit-current-account-features/edit-current-account-features.component'; /** * Current Accounts Module @@ -117,7 +118,8 @@ import { PassbookTabComponent } from './current-account-view/passbook-tab/passbo CurrentAccountTransactionDatatableTabComponent, GeneralTabComponent, CurrentAccountActiveClientMembersComponent, - PassbookTabComponent + PassbookTabComponent, + EditCurrentAccountFeaturesComponent ], providers: [] }) diff --git a/src/app/current-accounts/current-accounts.service.ts b/src/app/current-accounts/current-accounts.service.ts index 18e19593f..f6152a7df 100644 --- a/src/app/current-accounts/current-accounts.service.ts +++ b/src/app/current-accounts/current-accounts.service.ts @@ -370,4 +370,49 @@ export class CurrentAccountsService { loadCurrentAccountDocument(currentAccountId: any, data: any): Observable { return this.http.post(`/currentaccounts/${currentAccountId}/documents`, data); } + + /** + * Submits a maker-checker edit request (Nominee and/or Digital & Account Features change) for a + * current account. The edit is held for checker approval before it is actually applied. + * @param {string} accountId Current Account Id + * @param {any} data Edit payload — `accountFacilityConfig` and/or `nominee`, plus locale/dateFormat + * @returns {Observable} + */ + submitCurrentAccountEdit(accountId: string, data: any): Observable { + return this.http.post(`/currentaccounts/${accountId}/edit`, data); + } + + /** + * @param {string} accountId Current Account Id + * @returns {Observable} the pending edit for this account, if any. + */ + getPendingCurrentAccountEdit(accountId: string): Observable { + return this.http.get(`/currentaccounts/${accountId}/edit/pending`); + } + + /** + * @returns {Observable} all current account edits currently awaiting checker approval. + */ + getAllPendingCurrentAccountEdits(): Observable { + return this.http.get(`/currentaccounts/edit/pending`); + } + + /** + * Approves a pending current account edit, releasing any hold and applying the proposed changes. + * @param {string} accountId Current Account Id + * @returns {Observable} + */ + approveCurrentAccountEdit(accountId: string): Observable { + return this.http.post(`/currentaccounts/${accountId}/edit/approve`, {}); + } + + /** + * Rejects a pending current account edit, releasing any hold without applying the changes. + * @param {string} accountId Current Account Id + * @param {string} rejectReason Optional reason for rejection + * @returns {Observable} + */ + rejectCurrentAccountEdit(accountId: string, rejectReason?: string): Observable { + return this.http.post(`/currentaccounts/${accountId}/edit/reject`, { rejectReason: rejectReason || '' }); + } } diff --git a/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.html b/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.html new file mode 100644 index 000000000..1d53a2459 --- /dev/null +++ b/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.html @@ -0,0 +1,176 @@ + +
+ +
+ +
+

Modify Current Account Features

+
Review account, manage nominees and digital features before submit.
+
+
+ + @if (pendingEditLoading) { +
+ + Checking for pending edits... +
+ } + + @if (!pendingEditLoading && hasPendingEdit) { +
+ hourglass_top +
+ An edit is already pending approval for this account. + + Submitted by {{ pendingEdit.makerUsername || 'a maker' }} on {{ pendingEdit.createdOn | dateFormat }}. Status: + {{ pendingEdit.status }} + @if (pendingEdit.holdAmount) { + · Hold placed: {{ pendingEdit.holdAmount }} + } + + You cannot submit another edit until this one is approved or rejected. +
+
+ } + + @if (!pendingEditLoading) { + + + + + + + Product + +
+ + + +
+ + +
+
+
+ + + + + + Currency + +
+ @if (currentAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + + + + + Digital + +
+ @if (currentAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + + + + + {{ 'labels.inputs.PREVIEW' | translate }} + +
+ @if (currentAccountProductTemplate) { + + + } + +
+ + + + +
+
+
+
+ } +
diff --git a/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.scss b/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.scss new file mode 100644 index 000000000..bc2c55495 --- /dev/null +++ b/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.scss @@ -0,0 +1,206 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$border: #e2e8f0; +$muted: #64748b; +$amber: #92400e; +$amber-bg: #fffbeb; +$amber-border: #fde68a; + +.esf-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 20px 0; + justify-content: center; + font-size: 13px; + color: $muted; + + &--sm { + padding: 10px 0; + justify-content: flex-start; + } +} + +.esf-pending-banner { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 16px; + background: $amber-bg; + border: 1px solid $amber-border; + border-radius: 8px; + margin-bottom: 16px; + color: $amber; + + mat-icon { + flex: 0 0 auto; + } + + &__text { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12.5px; + + strong { + font-size: 13px; + } + } + + &__status { + font-weight: 700; + text-transform: uppercase; + } +} + +/* ─── CSA Stepper Styles ─── */ +$navy: #0d1b2a; +$blue: #1e88e5; +$blue-d: #1e40af; +$indigo: #4f46e5; + +.csa-container { + max-width: 1180px; + margin: 0 auto; + padding: 20px 24px; +} + +.csa-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + + &__icon { + width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: linear-gradient(135deg, $blue 0%, $indigo 100%); + color: #fff; + font-size: 18px; + box-shadow: 0 2px 8px rgb(29 78 216 / 25%); + flex-shrink: 0; + } + + &__title { + margin: 0; + font-size: 20px; + font-weight: 800; + color: $navy; + line-height: 1.1; + letter-spacing: 0.02em; + } + + &__sub { + margin-top: 2px; + font-size: 12.5px; + color: #64748b; + } +} + +.csa-tabs { + background: #fff; + border: 1px solid $border; + border-radius: 14px; + box-shadow: 0 1px 3px rgb(15 23 42 / 4%); + overflow: hidden; + + ::ng-deep { + .mat-mdc-tab-header { + background: #f8fafc; + border-bottom: 1px solid $border; + } + + .mat-mdc-tab { + min-width: 140px; + height: 50px; + padding: 0 22px; + font-size: 13px !important; + font-weight: 600 !important; + letter-spacing: 0.01em; + color: #475569 !important; + opacity: 1 !important; + } + + .mat-mdc-tab:hover:not(.mdc-tab--active, .mat-mdc-tab-disabled) { + background: rgb(30 64 175 / 5%); + color: $navy !important; + } + + .mat-mdc-tab.mat-mdc-tab-active { + .mdc-tab__text-label { + color: $blue-d !important; + font-weight: 700 !important; + } + } + + .mdc-tab-indicator__content--underline { + border-color: $blue-d !important; + border-top-width: 3px !important; + } + + .mat-mdc-tab.mat-mdc-tab-disabled { + opacity: 0.45 !important; + cursor: not-allowed; + } + + .mat-mdc-tab-body-wrapper { + min-height: 320px; + } + + .mat-mdc-tab-body, + .mat-mdc-tab-body-content { + overflow: visible !important; + } + } +} + +.csa-tab__icon { + margin-right: 8px; + font-size: 13px; +} + +.csa-tab-body { + padding: 24px; + + @media (width <= 768px) { + padding: 16px; + } +} + +.csa-actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid #f1f5f9; + + &__spacer { + flex: 1; + } + + button { + height: 40px !important; + border-radius: 10px !important; + font-weight: 600 !important; + padding: 0 18px !important; + } +} + +.m-l-10 { + margin-left: 8px; +} + +.m-r-10 { + margin-right: 8px; +} diff --git a/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.ts b/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.ts new file mode 100644 index 000000000..bca2ab003 --- /dev/null +++ b/src/app/current-accounts/edit-current-account-features/edit-current-account-features.component.ts @@ -0,0 +1,417 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, inject, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule, FormGroup } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatTabsModule } from '@angular/material/tabs'; +import { CurrentAccountDetailsStepComponent } from '../current-account-stepper/current-account-details-step/current-account-details-step.component'; +import { CurrentAccountCurrencyStepComponent } from '../current-account-stepper/current-account-currency-step/current-account-currency-step.component'; +import { CurrentAccountDigitalStepComponent } from '../current-account-stepper/current-account-digital-step/current-account-digital-step.component'; +import { CurrentAccountPreviewStepComponent } from '../current-account-stepper/current-account-preview-step/current-account-preview-step.component'; + +/** Custom Services */ +import { CurrentAccountsService } from '../current-accounts.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Default state for every Digital & Account Feature toggle, mirrors + * current-account-digital-step's DEFAULT_FACILITY_CONFIG. */ +const DEFAULT_FACILITY_CONFIG: Record = { + atmFacility: false, + atmWithdrawalFacility: false, + upiFacility: false, + posTransactionsFacility: false, + chequeBook: false, + impsFacility: false, + smsAlerts: false, + bbpsFacility: false, + insuranceCover: false, + bepgFacility: false, + neftFacility: false, + rtgsFacility: false, + interBankTotalCapFacility: false, + withdrawalFacility: false, + depositFacilityKyc: false, + virtualDebitCard: false, + internetBanking: false, + mobileBanking: false +}; + +/** Toggle metadata used to render the feature grid — key must match a + * property of DEFAULT_FACILITY_CONFIG / the backend's accountFacilityConfig map. */ +const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [ + { key: 'atmFacility', label: 'ATM Facility', icon: 'fa-credit-card' }, + { key: 'atmWithdrawalFacility', label: 'ATM Withdrawal', icon: 'fa-wallet' }, + { key: 'upiFacility', label: 'UPI Facility', icon: 'fa-mobile-alt' }, + { key: 'posTransactionsFacility', label: 'POS Transactions', icon: 'fa-exchange-alt' }, + { key: 'chequeBook', label: 'Cheque Book', icon: 'fa-book' }, + { key: 'impsFacility', label: 'IMPS Facility', icon: 'fa-bolt' }, + { key: 'smsAlerts', label: 'SMS Alerts', icon: 'fa-sms' }, + { key: 'bbpsFacility', label: 'BBPS Facility', icon: 'fa-bell' }, + { key: 'insuranceCover', label: 'Insurance Cover', icon: 'fa-shield-alt' }, + { key: 'bepgFacility', label: 'BEPG Facility', icon: 'fa-plug' }, + { key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' }, + { key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' }, + { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' }, + { key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' }, + { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' }, + { key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' }, + { key: 'internetBanking', label: 'Internet Banking', icon: 'fa-globe' }, + { key: 'mobileBanking', label: 'Mobile Banking', icon: 'fa-mobile-screen-button' } +]; + +/** + * Edit Nominee & Features — maker-checker submission screen. + * + * A maker proposes changes to a current account's Digital & Account Features toggles and/or its + * nominee here; nothing is applied immediately. The request is staged and a different user (checker) + * must approve it from the "Pending Current Account Edits" screen before it takes effect. + */ +@Component({ + selector: 'mifosx-edit-current-account-features', + templateUrl: './edit-current-account-features.component.html', + styleUrls: ['./edit-current-account-features.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent, + MatIcon, + MatProgressSpinner, + MatTabsModule, + CurrentAccountDetailsStepComponent, + CurrentAccountCurrencyStepComponent, + CurrentAccountDigitalStepComponent, + CurrentAccountPreviewStepComponent + ] +}) +export class EditCurrentAccountFeaturesComponent { + private route = inject(ActivatedRoute); + private router = inject(Router); + private currentAccountsService = inject(CurrentAccountsService); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + private dialog = inject(MatDialog); + + @ViewChild(CurrentAccountDetailsStepComponent) currentAccountDetailsStep: CurrentAccountDetailsStepComponent; + @ViewChild(CurrentAccountCurrencyStepComponent) currentAccountCurrencyStep: CurrentAccountCurrencyStepComponent; + @ViewChild(CurrentAccountDigitalStepComponent) currentAccountDigitalStep: CurrentAccountDigitalStepComponent; + + originalNominees: any[] = []; + + get currentAccountTermForm(): FormGroup { + return new FormGroup({}); + } + + get currentAccountSettingsForm(): FormGroup { + return new FormGroup({}); + } + + get currentAccountTermsForm(): FormGroup { + return new FormGroup({}); + } + + get currentAccount() { + return this.currentAccountDetailsStep ? { ...this.currentAccountDetailsStep.currentAccountDetails } : null; + } + + get currentAccountDigital() { + return this.currentAccountDigitalStep?.currentAccountDigital ?? null; + } + + get currentAccountCurrencyFlags() { + return { + balanceSwapOption: this.currentAccountCurrencyStep?.balanceSwapOption ?? false, + msme: this.currentAccountCurrencyStep?.msme ?? false, + autoDebitFacility: this.currentAccountCurrencyStep?.autoDebitFacility ?? false + }; + } + + readonly toggles = FACILITY_TOGGLES; + + accountId: string; + currentAccountAndTemplate: any; + clientId = ''; + accountNo = ''; + + currentAccountProductTemplate: any = null; + selectedTabIndex = 0; + readonly totalTabs = 4; + + originalFacilityConfig: Record = { ...DEFAULT_FACILITY_CONFIG }; + + /** Current nominees on the account, fetched read-only for reference. */ + nominees: any[] = []; + nomineesLoading = false; + relationshipOptions: any[] = []; + + /** Pending edit already submitted for this account, if any. */ + pendingEdit: any = null; + pendingEditLoading = true; + + submitting = false; + + constructor() { + this.accountId = this.route.snapshot.params['currentAccountId']; + this.loadPendingEdit(); + this.route.data.subscribe((data: { currentAccountAndTemplate: any }) => { + const account = data.currentAccountAndTemplate; + this.currentAccountAndTemplate = account; + this.clientId = account?.clientId != null ? String(account.clientId) : ''; + this.accountNo = account?.accountNo || ''; + this.originalFacilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...(account?.accountFacilityConfig || {}) }; + if (this.clientId) { + this.loadNominees(); + this.loadRelationshipTemplate(); + } + }); + } + + private todayIso(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + } + + private loadPendingEdit(): void { + this.pendingEditLoading = true; + this.currentAccountsService.getPendingCurrentAccountEdit(this.accountId).subscribe({ + next: (edit: any) => { + this.pendingEdit = edit && edit.id ? edit : null; + this.pendingEditLoading = false; + }, + error: () => { + this.pendingEdit = null; + this.pendingEditLoading = false; + } + }); + } + + private loadNominees(): void { + this.nomineesLoading = true; + this.clientsService.getClientNominees(this.clientId, this.accountNo, 'CB').subscribe({ + next: (data: any) => { + this.nominees = Array.isArray(data) ? data : []; + this.originalNominees = JSON.parse(JSON.stringify(this.nominees)); + this.nomineesLoading = false; + }, + error: () => { + this.nominees = []; + this.originalNominees = []; + this.nomineesLoading = false; + } + }); + } + + private loadRelationshipTemplate(): void { + this.clientsService.getClientNomineeTemplate(this.clientId).subscribe({ + next: (template: any) => { + this.relationshipOptions = template?.relationshipOptions || []; + }, + error: () => { + this.relationshipOptions = []; + } + }); + } + + get hasPendingEdit(): boolean { + return !!this.pendingEdit && this.pendingEdit.status === 'PENDING'; + } + + get facilityConfig(): Record { + return this.currentAccountDigitalStep?.facilityConfig ?? this.originalFacilityConfig; + } + + get facilityChanged(): boolean { + const config = this.facilityConfig; + return this.toggles.some((t) => config[t.key] !== this.originalFacilityConfig[t.key]); + } + + get changedToggles(): any[] { + const list: any[] = []; + const config = this.facilityConfig; + this.toggles.forEach((t) => { + if (config[t.key] !== this.originalFacilityConfig[t.key]) { + list.push({ + label: t.label, + from: this.originalFacilityConfig[t.key], + to: config[t.key] + }); + } + }); + return list; + } + + get nomineeChanged(): boolean { + return this.buildNomineePayload() !== null; + } + + get canSubmit(): boolean { + return ( + !this.hasPendingEdit && + !this.pendingEditLoading && + !this.submitting && + (this.facilityChanged || this.nomineeChanged) + ); + } + + setTemplate($event: any): void { + this.currentAccountProductTemplate = $event; + } + + next(): void { + if (this.selectedTabIndex < this.totalTabs - 1) { + this.selectedTabIndex += 1; + } + } + + prev(): void { + if (this.selectedTabIndex > 0) { + this.selectedTabIndex -= 1; + } + } + + getRelationshipName(relationshipId: any): string { + const opt = this.relationshipOptions.find((r: any) => r.id === relationshipId); + return opt?.name || ''; + } + + private buildFacilityDiff(): Record | null { + const diff: Record = {}; + let changed = false; + const config = this.facilityConfig; + this.toggles.forEach((t) => { + if (config[t.key] !== this.originalFacilityConfig[t.key]) { + diff[t.key] = config[t.key]; + changed = true; + } + }); + return changed ? diff : null; + } + + private buildNomineePayload(): any | null { + const original = this.originalNominees || []; + const current = this.currentAccountCurrencyStep?.nominees ?? []; + + // Find if a nominee has been added + const added = current.find( + (curr: any) => !original.some((orig: any) => orig.id === curr.id || orig.nomineeClientId === curr.nomineeClientId) + ); + if (added) { + return { + operation: 'ADD', + nomineeId: null, + nomineeClientId: added.nomineeClientId, + relationshipId: added.relationshipId, + percentageShare: added.percentageShare, + isActive: added.isActive !== undefined ? !!added.isActive : true, + nomineeIsMinor: !!added.nomineeIsMinor, + guardianClientId: added.nomineeIsMinor ? added.guardianClientId : null, + guardianRelationshipId: added.nomineeIsMinor ? added.guardianRelationshipId : null, + effectiveDate: this.todayIso(), + remarks: added.remarks || null + }; + } + + // Find if a nominee has been deleted + const deleted = original.find( + (orig: any) => !current.some((curr: any) => curr.id === orig.id || curr.nomineeClientId === orig.nomineeClientId) + ); + if (deleted) { + return { + operation: 'DELETE', + nomineeId: deleted.id + }; + } + + // Find if any nominee has been updated + for (const curr of current) { + const orig = original.find((o: any) => o.id === curr.id || o.nomineeClientId === curr.nomineeClientId); + if (orig) { + const relChanged = orig.relationshipId !== curr.relationshipId; + const shareChanged = +orig.percentageShare !== +curr.percentageShare; + const minorChanged = !!orig.nomineeIsMinor !== !!curr.nomineeIsMinor; + const guardianChanged = + orig.guardianClientId !== curr.guardianClientId || + orig.guardianRelationshipId !== curr.guardianRelationshipId; + const activeChanged = !!orig.isActive !== (curr.isActive !== undefined ? !!curr.isActive : true); + const remarksChanged = (orig.remarks || '') !== (curr.remarks || ''); + + if (relChanged || shareChanged || minorChanged || guardianChanged || activeChanged || remarksChanged) { + return { + operation: 'UPDATE', + nomineeId: orig.id, + relationshipId: curr.relationshipId, + percentageShare: curr.percentageShare, + isActive: curr.isActive !== undefined ? !!curr.isActive : true, + nomineeIsMinor: !!curr.nomineeIsMinor, + guardianClientId: curr.nomineeIsMinor ? curr.guardianClientId : null, + guardianRelationshipId: curr.nomineeIsMinor ? curr.guardianRelationshipId : null, + effectiveDate: this.todayIso(), + remarks: curr.remarks || null + }; + } + } + } + + return null; + } + + submit(): void { + if (!this.canSubmit) return; + + const locale = this.settingsService.language.code; + const payload: any = { locale, dateFormat: 'yyyy-MM-dd' }; + + const facilityDiff = this.buildFacilityDiff(); + if (facilityDiff) { + payload.accountFacilityConfig = facilityDiff; + } + + const nomineePayload = this.buildNomineePayload(); + if (nomineePayload) { + payload.nominee = nomineePayload; + } + + this.submitting = true; + this.currentAccountsService.submitCurrentAccountEdit(this.accountId, payload).subscribe({ + next: () => { + this.submitting = false; + this.showSuccess(); + }, + error: () => { + this.submitting = false; + } + }); + } + + private showSuccess(): void { + const ref = this.dialog.open(SuccessDialogComponent, { + width: '420px', + maxWidth: '92vw', + autoFocus: false, + data: { + title: 'Submitted for Approval', + message: 'Your changes have been submitted and are awaiting checker approval.', + buttonLabel: 'Done' + } + }); + ref.afterClosed().subscribe(() => this.cancel()); + } + + cancel(): void { + this.router.navigate(['../'], { relativeTo: this.route }); + } +} 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 @@
+ + + + + +
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/savings/edit-savings-account-features/edit-savings-account-features.component.html b/src/app/savings/edit-savings-account-features/edit-savings-account-features.component.html new file mode 100644 index 000000000..a1cebfc3f --- /dev/null +++ b/src/app/savings/edit-savings-account-features/edit-savings-account-features.component.html @@ -0,0 +1,171 @@ + +
+ +
+ +
+

Modify Savings Account Features

+
Review account, manage nominees and digital features before submit.
+
+
+ + @if (pendingEditLoading) { +
+ + Checking for pending edits... +
+ } + + @if (!pendingEditLoading && hasPendingEdit) { +
+ hourglass_top +
+ An edit is already pending approval for this account. + + Submitted by {{ pendingEdit.makerUsername || 'a maker' }} on {{ pendingEdit.createdOn | dateFormat }}. Status: + {{ pendingEdit.status }} + @if (pendingEdit.holdAmount) { + · Hold placed: {{ pendingEdit.holdAmount }} + } + + You cannot submit another edit until this one is approved or rejected. +
+
+ } + + @if (!pendingEditLoading) { + + + + + + + Product + +
+ + + +
+ + +
+
+
+ + + + + + Currency + +
+ @if (savingsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + + + + + Digital + +
+ @if (savingsAccountProductTemplate) { + + } + +
+ + + +
+
+
+ + + + + + {{ 'labels.inputs.PREVIEW' | translate }} + +
+ @if (savingsAccountProductTemplate) { + + + } + +
+ + + + +
+
+
+
+ } +
diff --git a/src/app/savings/edit-savings-account-features/edit-savings-account-features.component.scss b/src/app/savings/edit-savings-account-features/edit-savings-account-features.component.scss new file mode 100644 index 000000000..bc2c55495 --- /dev/null +++ b/src/app/savings/edit-savings-account-features/edit-savings-account-features.component.scss @@ -0,0 +1,206 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$border: #e2e8f0; +$muted: #64748b; +$amber: #92400e; +$amber-bg: #fffbeb; +$amber-border: #fde68a; + +.esf-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 20px 0; + justify-content: center; + font-size: 13px; + color: $muted; + + &--sm { + padding: 10px 0; + justify-content: flex-start; + } +} + +.esf-pending-banner { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 16px; + background: $amber-bg; + border: 1px solid $amber-border; + border-radius: 8px; + margin-bottom: 16px; + color: $amber; + + mat-icon { + flex: 0 0 auto; + } + + &__text { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12.5px; + + strong { + font-size: 13px; + } + } + + &__status { + font-weight: 700; + text-transform: uppercase; + } +} + +/* ─── CSA Stepper Styles ─── */ +$navy: #0d1b2a; +$blue: #1e88e5; +$blue-d: #1e40af; +$indigo: #4f46e5; + +.csa-container { + max-width: 1180px; + margin: 0 auto; + padding: 20px 24px; +} + +.csa-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + + &__icon { + width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: linear-gradient(135deg, $blue 0%, $indigo 100%); + color: #fff; + font-size: 18px; + box-shadow: 0 2px 8px rgb(29 78 216 / 25%); + flex-shrink: 0; + } + + &__title { + margin: 0; + font-size: 20px; + font-weight: 800; + color: $navy; + line-height: 1.1; + letter-spacing: 0.02em; + } + + &__sub { + margin-top: 2px; + font-size: 12.5px; + color: #64748b; + } +} + +.csa-tabs { + background: #fff; + border: 1px solid $border; + border-radius: 14px; + box-shadow: 0 1px 3px rgb(15 23 42 / 4%); + overflow: hidden; + + ::ng-deep { + .mat-mdc-tab-header { + background: #f8fafc; + border-bottom: 1px solid $border; + } + + .mat-mdc-tab { + min-width: 140px; + height: 50px; + padding: 0 22px; + font-size: 13px !important; + font-weight: 600 !important; + letter-spacing: 0.01em; + color: #475569 !important; + opacity: 1 !important; + } + + .mat-mdc-tab:hover:not(.mdc-tab--active, .mat-mdc-tab-disabled) { + background: rgb(30 64 175 / 5%); + color: $navy !important; + } + + .mat-mdc-tab.mat-mdc-tab-active { + .mdc-tab__text-label { + color: $blue-d !important; + font-weight: 700 !important; + } + } + + .mdc-tab-indicator__content--underline { + border-color: $blue-d !important; + border-top-width: 3px !important; + } + + .mat-mdc-tab.mat-mdc-tab-disabled { + opacity: 0.45 !important; + cursor: not-allowed; + } + + .mat-mdc-tab-body-wrapper { + min-height: 320px; + } + + .mat-mdc-tab-body, + .mat-mdc-tab-body-content { + overflow: visible !important; + } + } +} + +.csa-tab__icon { + margin-right: 8px; + font-size: 13px; +} + +.csa-tab-body { + padding: 24px; + + @media (width <= 768px) { + padding: 16px; + } +} + +.csa-actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid #f1f5f9; + + &__spacer { + flex: 1; + } + + button { + height: 40px !important; + border-radius: 10px !important; + font-weight: 600 !important; + padding: 0 18px !important; + } +} + +.m-l-10 { + margin-left: 8px; +} + +.m-r-10 { + margin-right: 8px; +} diff --git a/src/app/savings/edit-savings-account-features/edit-savings-account-features.component.ts b/src/app/savings/edit-savings-account-features/edit-savings-account-features.component.ts new file mode 100644 index 000000000..00e9e1c85 --- /dev/null +++ b/src/app/savings/edit-savings-account-features/edit-savings-account-features.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/. + */ + +/** Angular Imports */ +import { Component, inject, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule, FormGroup } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatTabsModule } from '@angular/material/tabs'; +import { SavingsAccountDetailsStepComponent } from '../savings-account-stepper/savings-account-details-step/savings-account-details-step.component'; +import { SavingsAccountCurrencyStepComponent } from '../savings-account-stepper/savings-account-currency-step/savings-account-currency-step.component'; +import { SavingsAccountDigitalStepComponent } from '../savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component'; +import { SavingsAccountPreviewStepComponent } from '../savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component'; + +/** Custom Services */ +import { SavingsService } from '../savings.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Default state for every Digital & Account Feature toggle, mirrors + * savings-account-digital-step's DEFAULT_FACILITY_CONFIG. */ +const DEFAULT_FACILITY_CONFIG: Record = { + atmFacility: false, + atmWithdrawalFacility: false, + upiFacility: false, + posTransactionsFacility: false, + chequeBook: false, + impsFacility: false, + smsAlerts: false, + bbpsFacility: false, + insuranceCover: false, + bepgFacility: false, + neftFacility: false, + rtgsFacility: false, + interBankTotalCapFacility: false, + withdrawalFacility: false, + depositFacilityKyc: false, + virtualDebitCard: false +}; + +/** Toggle metadata used to render the feature grid — key must match a + * property of DEFAULT_FACILITY_CONFIG / the backend's accountFacilityConfig map. */ +const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [ + { key: 'atmFacility', label: 'ATM Facility', icon: 'fa-credit-card' }, + { key: 'atmWithdrawalFacility', label: 'ATM Withdrawal', icon: 'fa-wallet' }, + { key: 'upiFacility', label: 'UPI Facility', icon: 'fa-mobile-alt' }, + { key: 'posTransactionsFacility', label: 'POS Transactions', icon: 'fa-exchange-alt' }, + { key: 'chequeBook', label: 'Cheque Book', icon: 'fa-book' }, + { key: 'impsFacility', label: 'IMPS Facility', icon: 'fa-bolt' }, + { key: 'smsAlerts', label: 'SMS Alerts', icon: 'fa-sms' }, + { key: 'bbpsFacility', label: 'BBPS Facility', icon: 'fa-bell' }, + { key: 'insuranceCover', label: 'Insurance Cover', icon: 'fa-shield-alt' }, + { key: 'bepgFacility', label: 'BEPG Facility', icon: 'fa-plug' }, + { key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' }, + { key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' }, + { key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' }, + { key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' }, + { key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' }, + { key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' } +]; + +/** + * Edit Nominee & Features — maker-checker submission screen. + * + * A maker proposes changes to a savings account's Digital & Account Features toggles and/or its + * nominee here; nothing is applied immediately. The request is staged and a different user (checker) + * must approve it from the "Pending Savings Account Edits" screen before it takes effect. + */ +@Component({ + selector: 'mifosx-edit-savings-account-features', + templateUrl: './edit-savings-account-features.component.html', + styleUrls: ['./edit-savings-account-features.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent, + MatIcon, + MatProgressSpinner, + MatTabsModule, + SavingsAccountDetailsStepComponent, + SavingsAccountCurrencyStepComponent, + SavingsAccountDigitalStepComponent, + SavingsAccountPreviewStepComponent + ] +}) +export class EditSavingsAccountFeaturesComponent { + private route = inject(ActivatedRoute); + private router = inject(Router); + private savingsService = inject(SavingsService); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + private dialog = inject(MatDialog); + + @ViewChild(SavingsAccountDetailsStepComponent) savingsAccountDetailsStep: SavingsAccountDetailsStepComponent; + @ViewChild(SavingsAccountCurrencyStepComponent) savingsAccountCurrencyStep: SavingsAccountCurrencyStepComponent; + @ViewChild(SavingsAccountDigitalStepComponent) savingsAccountDigitalStep: SavingsAccountDigitalStepComponent; + + originalNominees: any[] = []; + + get savingsAccountTermsForm(): FormGroup { + return new FormGroup({}); + } + + get savingsAccount() { + return this.savingsAccountDetailsStep ? { ...this.savingsAccountDetailsStep.savingsAccountDetails } : null; + } + + get savingsAccountDigital() { + return this.savingsAccountDigitalStep?.savingsAccountDigital ?? null; + } + + readonly toggles = FACILITY_TOGGLES; + + accountId: string; + savingsAccountAndTemplate: any; + clientId = ''; + accountNo = ''; + + savingsAccountProductTemplate: any = null; + selectedTabIndex = 0; + readonly totalTabs = 4; + + originalFacilityConfig: Record = { ...DEFAULT_FACILITY_CONFIG }; + + /** Current nominees on the account, fetched read-only for reference. */ + nominees: any[] = []; + nomineesLoading = false; + relationshipOptions: any[] = []; + + /** Pending edit already submitted for this account, if any. */ + pendingEdit: any = null; + pendingEditLoading = true; + + submitting = false; + + constructor() { + this.accountId = this.route.snapshot.params['savingAccountId']; + this.loadPendingEdit(); + this.route.data.subscribe((data: { savingsAccountAndTemplate: any }) => { + const account = data.savingsAccountAndTemplate; + this.savingsAccountAndTemplate = account; + this.clientId = account?.clientId != null ? String(account.clientId) : ''; + this.accountNo = account?.accountNo || ''; + this.originalFacilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...(account?.accountFacilityConfig || {}) }; + if (this.clientId) { + this.loadNominees(); + this.loadRelationshipTemplate(); + } + }); + } + + private todayIso(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + } + + private loadPendingEdit(): void { + this.pendingEditLoading = true; + this.savingsService.getPendingSavingsAccountEdit(this.accountId).subscribe({ + next: (edit: any) => { + this.pendingEdit = edit && edit.id ? edit : null; + this.pendingEditLoading = false; + }, + error: () => { + this.pendingEdit = null; + this.pendingEditLoading = false; + } + }); + } + + private loadNominees(): void { + this.nomineesLoading = true; + this.clientsService.getClientNominees(this.clientId, this.accountNo, 'SB').subscribe({ + next: (data: any) => { + this.nominees = Array.isArray(data) ? data : []; + this.originalNominees = JSON.parse(JSON.stringify(this.nominees)); + this.nomineesLoading = false; + }, + error: () => { + this.nominees = []; + this.originalNominees = []; + this.nomineesLoading = false; + } + }); + } + + private loadRelationshipTemplate(): void { + this.clientsService.getClientNomineeTemplate(this.clientId).subscribe({ + next: (template: any) => { + this.relationshipOptions = template?.relationshipOptions || []; + }, + error: () => { + this.relationshipOptions = []; + } + }); + } + + /** Max share allowed for the mode currently being edited — excludes the nominee's own + * existing share from the running total when updating that same nominee. */ + get hasPendingEdit(): boolean { + return !!this.pendingEdit && this.pendingEdit.status === 'PENDING'; + } + + get facilityConfig(): Record { + return this.savingsAccountDigitalStep?.facilityConfig ?? this.originalFacilityConfig; + } + + get facilityChanged(): boolean { + const config = this.facilityConfig; + return this.toggles.some((t) => config[t.key] !== this.originalFacilityConfig[t.key]); + } + + get changedToggles(): any[] { + const list: any[] = []; + const config = this.facilityConfig; + this.toggles.forEach((t) => { + if (config[t.key] !== this.originalFacilityConfig[t.key]) { + list.push({ + label: t.label, + from: this.originalFacilityConfig[t.key], + to: config[t.key] + }); + } + }); + return list; + } + + get nomineeChanged(): boolean { + return this.buildNomineePayload() !== null; + } + + get canSubmit(): boolean { + return ( + !this.hasPendingEdit && + !this.pendingEditLoading && + !this.submitting && + (this.facilityChanged || this.nomineeChanged) + ); + } + + setTemplate($event: any): void { + this.savingsAccountProductTemplate = $event; + } + + next(): void { + if (this.selectedTabIndex < this.totalTabs - 1) { + this.selectedTabIndex += 1; + } + } + + prev(): void { + if (this.selectedTabIndex > 0) { + this.selectedTabIndex -= 1; + } + } + + getRelationshipName(relationshipId: any): string { + const opt = this.relationshipOptions.find((r: any) => r.id === relationshipId); + return opt?.name || ''; + } + + private buildFacilityDiff(): Record | null { + const diff: Record = {}; + let changed = false; + const config = this.facilityConfig; + this.toggles.forEach((t) => { + if (config[t.key] !== this.originalFacilityConfig[t.key]) { + diff[t.key] = config[t.key]; + changed = true; + } + }); + return changed ? diff : null; + } + + private buildNomineePayload(): any | null { + const original = this.originalNominees || []; + const current = this.savingsAccountCurrencyStep?.nominees ?? []; + + // Find if a nominee has been added + const added = current.find( + (curr: any) => !original.some((orig: any) => orig.id === curr.id || orig.nomineeClientId === curr.nomineeClientId) + ); + if (added) { + return { + operation: 'ADD', + nomineeId: null, + nomineeClientId: added.nomineeClientId, + relationshipId: added.relationshipId, + percentageShare: added.percentageShare, + isActive: added.isActive !== undefined ? !!added.isActive : true, + nomineeIsMinor: !!added.nomineeIsMinor, + guardianClientId: added.nomineeIsMinor ? added.guardianClientId : null, + guardianRelationshipId: added.nomineeIsMinor ? added.guardianRelationshipId : null, + effectiveDate: this.todayIso(), + remarks: added.remarks || null + }; + } + + // Find if a nominee has been deleted + const deleted = original.find( + (orig: any) => !current.some((curr: any) => curr.id === orig.id || curr.nomineeClientId === orig.nomineeClientId) + ); + if (deleted) { + return { + operation: 'DELETE', + nomineeId: deleted.id + }; + } + + // Find if any nominee has been updated + for (const curr of current) { + const orig = original.find((o: any) => o.id === curr.id || o.nomineeClientId === curr.nomineeClientId); + if (orig) { + const relChanged = orig.relationshipId !== curr.relationshipId; + const shareChanged = +orig.percentageShare !== +curr.percentageShare; + const minorChanged = !!orig.nomineeIsMinor !== !!curr.nomineeIsMinor; + const guardianChanged = + orig.guardianClientId !== curr.guardianClientId || + orig.guardianRelationshipId !== curr.guardianRelationshipId; + const activeChanged = !!orig.isActive !== (curr.isActive !== undefined ? !!curr.isActive : true); + const remarksChanged = (orig.remarks || '') !== (curr.remarks || ''); + + if (relChanged || shareChanged || minorChanged || guardianChanged || activeChanged || remarksChanged) { + return { + operation: 'UPDATE', + nomineeId: orig.id, + relationshipId: curr.relationshipId, + percentageShare: curr.percentageShare, + isActive: curr.isActive !== undefined ? !!curr.isActive : true, + nomineeIsMinor: !!curr.nomineeIsMinor, + guardianClientId: curr.nomineeIsMinor ? curr.guardianClientId : null, + guardianRelationshipId: curr.nomineeIsMinor ? curr.guardianRelationshipId : null, + effectiveDate: this.todayIso(), + remarks: curr.remarks || null + }; + } + } + } + + return null; + } + + submit(): void { + if (!this.canSubmit) return; + + const locale = this.settingsService.language.code; + const payload: any = { locale, dateFormat: 'yyyy-MM-dd' }; + + const facilityDiff = this.buildFacilityDiff(); + if (facilityDiff) { + payload.accountFacilityConfig = facilityDiff; + } + + const nomineePayload = this.buildNomineePayload(); + if (nomineePayload) { + payload.nominee = nomineePayload; + } + + this.submitting = true; + this.savingsService.submitSavingsAccountEdit(this.accountId, payload).subscribe({ + next: () => { + this.submitting = false; + this.showSuccess(); + }, + error: () => { + this.submitting = false; + } + }); + } + + private showSuccess(): void { + const ref = this.dialog.open(SuccessDialogComponent, { + width: '420px', + maxWidth: '92vw', + autoFocus: false, + data: { + title: 'Submitted for Approval', + message: 'Your changes have been submitted and are awaiting checker approval.', + buttonLabel: 'Done' + } + }); + ref.afterClosed().subscribe(() => this.cancel()); + } + + cancel(): void { + this.router.navigate(['../'], { relativeTo: this.route }); + } +} diff --git a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html index ea68c5170..1f8ce8870 100644 --- a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html +++ b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html @@ -71,7 +71,7 @@
Account Type - + @for (opt of accountTypeOptions; track opt) { {{ opt }} } @@ -80,7 +80,11 @@ Customer Segment - + @for (opt of customerSegmentOptions; track opt) { {{ opt }} } @@ -117,7 +121,7 @@ Share Linking Enabled - + Yes No @@ -132,6 +136,7 @@ introducerId ? { clientId: introducerId, displayName: introducerName, cifNumber: introducerCif } : null " (introducerChange)="onIntroducerChange($event)" + [disabled]="readOnly" > } } @@ -223,7 +228,7 @@ color="primary" type="button" class="soa-add-btn" - [disabled]="totalJointSharePercentage >= 100" + [disabled]="totalJointSharePercentage >= 100 || readOnly" (click)="openAddJointHolderDialog()" > person_add Add Holder @@ -269,6 +274,7 @@ max="100" [(ngModel)]="primaryHolderShare" [ngModelOptions]="{ standalone: true }" + [disabled]="readOnly" placeholder="0" (input)="onPrimaryShareInput($event)" (blur)="onPrimaryShareBlur($event)" @@ -320,7 +326,8 @@ class="soa-sign-toggle" [class.soa-sign-toggle--mandatory]="h.isMandatory" [class.soa-sign-toggle--optional]="!h.isMandatory" - (click)="toggleJointHolderMandatory(h)" + [disabled]="readOnly" + (click)="!readOnly && toggleJointHolderMandatory(h)" > {{ h.isMandatory ? 'lock' : 'lock_open' }} {{ h.isMandatory ? 'Mandatory' : 'Optional' }} @@ -328,7 +335,13 @@ Co-Holder - @@ -410,7 +423,8 @@ type="button" class="soa-mode-card" [class.soa-mode-card--selected]="modeOfOperation === opt.value" - (click)="modeOfOperation = opt.value" + [disabled]="readOnly" + (click)="!readOnly && (modeOfOperation = opt.value)" > {{ opt.icon }}
{{ opt.label }}
@@ -465,8 +479,9 @@ [max]="maxDate" [(ngModel)]="minorDob" [ngModelOptions]="{ standalone: true }" + [disabled]="readOnly" /> - + @if (clientDob) { Auto-filled from client profile @@ -487,7 +502,11 @@ Guardian Relationship - + @for (rel of guardianRelationshipOptions; track rel) { {{ rel }} } @@ -503,6 +522,7 @@ @if (guardianName) { @@ -630,7 +650,8 @@ class="soa-cat-tile" [class.soa-cat-tile--selected]="selectedSpecialCategory === opt.value" [class.soa-cat-tile--male]="true" - (click)="selectedSpecialCategory = opt.value" + [disabled]="readOnly" + (click)="!readOnly && (selectedSpecialCategory = opt.value)" > {{ opt.icon }} {{ opt.label }} @@ -680,7 +701,8 @@ class="soa-cat-tile" [class.soa-cat-tile--selected]="selectedWeakerSection === opt.value" [class.soa-cat-tile--female]="true" - (click)="selectedWeakerSection = opt.value" + [disabled]="readOnly" + (click)="!readOnly && (selectedWeakerSection = opt.value)" > {{ opt.icon }} {{ opt.label }} diff --git a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts index 1d6f93357..cb93fd19e 100644 --- a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts @@ -53,6 +53,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit { /** Savings Account Template */ @Input() savingsAccountTemplate: any; + @Input() readOnly = false; minDate = new Date(2000, 0, 1); maxDate = new Date(); @@ -219,6 +220,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit { this.cdr.detectChanges(); }); } + if (this.readOnly) { + this.savingsAccountDetailsForm.disable(); + } } /** @@ -277,6 +281,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit { } isTileDisabled(value: string): boolean { + if (this.readOnly) return true; if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; return false; diff --git a/src/app/savings/savings-account-view/savings-account-view.component.ts b/src/app/savings/savings-account-view/savings-account-view.component.ts index ab5de3bf3..59501a2bd 100644 --- a/src/app/savings/savings-account-view/savings-account-view.component.ts +++ b/src/app/savings/savings-account-view/savings-account-view.component.ts @@ -122,6 +122,7 @@ export class SavingsAccountViewComponent implements OnInit { 'Add Charge': 'admin', 'Apply Annual Fees': 'admin', 'Modify Application': 'admin', + 'Edit Nominee & Features': 'admin', 'Assign Staff': 'admin', 'Unassign Staff': 'admin', Approve: 'admin', @@ -156,6 +157,7 @@ export class SavingsAccountViewComponent implements OnInit { 'Add Charge': 'plus-circle', 'Apply Annual Fees': 'receipt', 'Modify Application': 'edit', + 'Edit Nominee & Features': 'edit', 'Assign Staff': 'user-plus', 'Unassign Staff': 'user-slash', Approve: 'check-circle', @@ -311,6 +313,9 @@ export class SavingsAccountViewComponent implements OnInit { case 'Modify Application': this.router.navigate(['edit'], { relativeTo: this.route }); break; + case 'Edit Nominee & Features': + this.router.navigate(['edit-nominee-features'], { relativeTo: this.route }); + break; case 'Delete': this.deleteSavingsAccount(); break; diff --git a/src/app/savings/savings-account-view/savings-buttons.config.ts b/src/app/savings/savings-account-view/savings-buttons.config.ts index 47198897f..930167fb5 100644 --- a/src/app/savings/savings-account-view/savings-buttons.config.ts +++ b/src/app/savings/savings-account-view/savings-buttons.config.ts @@ -161,6 +161,10 @@ export class SavingsButtonsConfiguration { switch (status) { case 'Active': this.optionArray = [ + { + name: 'Edit Nominee & Features', + taskPermissionName: 'SUBMITEDIT_SAVINGSACCOUNT' + }, { name: 'Post Interest', taskPermissionName: 'POSTINTEREST_SAVINGSACCOUNT' diff --git a/src/app/savings/savings-routing.module.ts b/src/app/savings/savings-routing.module.ts index a5e3a3856..289f14d1f 100644 --- a/src/app/savings/savings-routing.module.ts +++ b/src/app/savings/savings-routing.module.ts @@ -27,6 +27,7 @@ import { ExportTransactionsComponent } from './savings-account-view/transactions import { EditTransactionComponent } from './savings-account-view/transactions/edit-transaction/edit-transaction.component'; import { CreateGsimAccountComponent } from './gsim-account/create-gsim-account/create-gsim-account.component'; import { GsimAccountComponent } from './gsim-account/gsim-account.component'; +import { EditSavingsAccountFeaturesComponent } from './edit-savings-account-features/edit-savings-account-features.component'; /** Custom Resolvers */ import { SavingsAccountViewResolver } from './common-resolvers/savings-account-view.resolver'; @@ -171,6 +172,18 @@ const routes: Routes = [ savingsAccountAndTemplate: SavingsAccountAndTemplateResolver } }, + { + path: ':savingAccountId/edit-nominee-features', + data: { + title: 'Edit Nominee & Features', + breadcrumb: 'Edit Nominee & Features', + routeParamBreadcrumb: false + }, + component: EditSavingsAccountFeaturesComponent, + resolve: { + savingsAccountAndTemplate: SavingsAccountAndTemplateResolver + } + }, { path: ':savingAccountId/transactions/:id', data: { title: 'Savings Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false }, diff --git a/src/app/savings/savings.module.ts b/src/app/savings/savings.module.ts index fd9480340..fbd78b468 100644 --- a/src/app/savings/savings.module.ts +++ b/src/app/savings/savings.module.ts @@ -63,6 +63,7 @@ import { SavingsTransactionDatatableTabComponent } from './savings-account-view/ import { GeneralTabComponent } from './savings-account-view/general-tab/general-tab.component'; import { SavingsActiveClientMembersComponent } from './savings-account-stepper/savings-active-client-members/savings-active-client-members.component'; import { PassbookTabComponent } from './savings-account-view/passbook-tab/passbook-tab.component'; +import { EditSavingsAccountFeaturesComponent } from './edit-savings-account-features/edit-savings-account-features.component'; /** * Savings Module @@ -121,7 +122,8 @@ import { PassbookTabComponent } from './savings-account-view/passbook-tab/passbo SavingsTransactionDatatableTabComponent, GeneralTabComponent, SavingsActiveClientMembersComponent, - PassbookTabComponent + PassbookTabComponent, + EditSavingsAccountFeaturesComponent ], providers: [] }) diff --git a/src/app/savings/savings.service.ts b/src/app/savings/savings.service.ts index 52480fc4c..26388a148 100644 --- a/src/app/savings/savings.service.ts +++ b/src/app/savings/savings.service.ts @@ -381,4 +381,49 @@ export class SavingsService { loadSavingsDocument(savingAccountId: any, data: any): Observable { return this.http.post(`/savings/${savingAccountId}/documents`, data); } + + /** + * Submits a maker-checker edit request (Nominee and/or Digital & Account Features change) for a + * savings account. The edit is held for checker approval before it is actually applied. + * @param {string} accountId Savings Account Id + * @param {any} data Edit payload — `accountFacilityConfig` and/or `nominee`, plus locale/dateFormat + * @returns {Observable} + */ + submitSavingsAccountEdit(accountId: string, data: any): Observable { + return this.http.post(`/savingsaccounts/${accountId}/edit`, data); + } + + /** + * @param {string} accountId Savings Account Id + * @returns {Observable} the pending edit for this account, if any. + */ + getPendingSavingsAccountEdit(accountId: string): Observable { + return this.http.get(`/savingsaccounts/${accountId}/edit/pending`); + } + + /** + * @returns {Observable} all savings account edits currently awaiting checker approval. + */ + getAllPendingSavingsAccountEdits(): Observable { + return this.http.get(`/savingsaccounts/edit/pending`); + } + + /** + * Approves a pending savings account edit, releasing any hold and applying the proposed changes. + * @param {string} accountId Savings Account Id + * @returns {Observable} + */ + approveSavingsAccountEdit(accountId: string): Observable { + return this.http.post(`/savingsaccounts/${accountId}/edit/approve`, {}); + } + + /** + * Rejects a pending savings account edit, releasing any hold without applying the changes. + * @param {string} accountId Savings Account Id + * @param {string} rejectReason Optional reason for rejection + * @returns {Observable} + */ + rejectSavingsAccountEdit(accountId: string, rejectReason?: string): Observable { + return this.http.post(`/savingsaccounts/${accountId}/edit/reject`, { rejectReason: rejectReason || '' }); + } } diff --git a/src/app/shared/account-nominees-tab/account-nominees-tab.component.html b/src/app/shared/account-nominees-tab/account-nominees-tab.component.html index a36df6205..779a9c8e3 100644 --- a/src/app/shared/account-nominees-tab/account-nominees-tab.component.html +++ b/src/app/shared/account-nominees-tab/account-nominees-tab.component.html @@ -14,13 +14,37 @@

Manage nominees for this account. Nominees are account-specific.

- @if (!loading && !nomineesLoading && totalShare < 100 && !showAddForm) { + @if (!loading && !nomineesLoading && totalShare < 100 && !showAddForm && !hasPendingEdit) { }
+ @if (pendingEditLoading) { +
+ + Checking for pending edits... +
+ } + + @if (!pendingEditLoading && hasPendingEdit) { +
+ hourglass_top +
+ An edit is already pending approval for this account. + + Submitted by {{ pendingEdit.makerUsername || 'a maker' }} on {{ pendingEdit.createdOn | dateFormat }}. Status: + {{ pendingEdit.status }} + @if (pendingEdit.holdAmount) { + · Hold placed: {{ pendingEdit.holdAmount }} + } + + You cannot submit another edit until this one is approved or rejected. +
+
+ } + @if (loading) {
@@ -98,18 +122,20 @@ - - + @if (!hasPendingEdit) { + + + }
@if (nominee.nomineeIsMinor && (nominee.guardianDisplayName || nominee.guardianName)) { diff --git a/src/app/shared/account-nominees-tab/account-nominees-tab.component.scss b/src/app/shared/account-nominees-tab/account-nominees-tab.component.scss index c25ab0e21..8f3f29a3d 100644 --- a/src/app/shared/account-nominees-tab/account-nominees-tab.component.scss +++ b/src/app/shared/account-nominees-tab/account-nominees-tab.component.scss @@ -645,3 +645,56 @@ $primary-light: #e8f0fe; overflow: hidden; } } + +$border: #e2e8f0; +$muted: #64748b; +$amber: #92400e; +$amber-bg: #fffbeb; +$amber-border: #fde68a; + +.esf-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 20px 0; + justify-content: center; + font-size: 13px; + color: $muted; + + &--sm { + padding: 10px 0; + justify-content: flex-start; + } +} + +.esf-pending-banner { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 16px; + background: $amber-bg; + border: 1px solid $amber-border; + border-radius: 8px; + margin-bottom: 16px; + color: $amber; + + mat-icon { + flex: 0 0 auto; + } + + &__text { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12.5px; + + strong { + font-size: 13px; + } + } + + &__status { + font-weight: 700; + text-transform: uppercase; + } +} diff --git a/src/app/shared/account-nominees-tab/account-nominees-tab.component.ts b/src/app/shared/account-nominees-tab/account-nominees-tab.component.ts index afbfccc59..21e84b4b4 100644 --- a/src/app/shared/account-nominees-tab/account-nominees-tab.component.ts +++ b/src/app/shared/account-nominees-tab/account-nominees-tab.component.ts @@ -20,6 +20,9 @@ import { MatSlideToggle } from '@angular/material/slide-toggle'; import { DateFormatPipe } from '../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FormsModule } from '@angular/forms'; +import { SavingsService } from 'app/savings/savings.service'; +import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component'; +import { CurrentAccountsService } from 'app/current-accounts/current-accounts.service'; @Component({ selector: 'mifosx-account-nominees-tab', @@ -41,11 +44,14 @@ export class AccountNomineesTabComponent implements OnInit { private clientsService = inject(ClientsService); private dialog = inject(MatDialog); private sanitizer = inject(DomSanitizer); + private savingsService = inject(SavingsService); + private currentAccountsService = inject(CurrentAccountsService); /** Set by route data */ accountDataKey: string = ''; accountType: string = ''; + accountId: string = ''; clientId: string = ''; accountNo: string = ''; loading = true; @@ -53,6 +59,9 @@ export class AccountNomineesTabComponent implements OnInit { nominees: any[] = []; nomineesLoading = false; + pendingEdit: any = null; + pendingEditLoading = true; + showAddForm = false; searchText = ''; searchResults: any[] = []; @@ -110,15 +119,45 @@ export class AccountNomineesTabComponent implements OnInit { this.route.parent.data.subscribe((data: any) => { const accountData = data[this.accountDataKey]; if (accountData) { + this.accountId = accountData.id?.toString() || this.route.parent.snapshot.params['savingAccountId'] || ''; this.clientId = accountData.clientId?.toString() || ''; this.accountNo = accountData.accountNo || ''; this.loading = false; + this.loadPendingEdit(); this.loadNominees(); this.loadTemplate(); } }); } + private loadPendingEdit(): void { + this.pendingEditLoading = true; + const fetch$ = + this.accountType === 'CB' + ? this.currentAccountsService.getPendingCurrentAccountEdit(this.accountId) + : this.savingsService.getPendingSavingsAccountEdit(this.accountId); + fetch$.subscribe({ + next: (edit: any) => { + this.pendingEdit = edit && edit.id ? edit : null; + this.pendingEditLoading = false; + }, + error: () => { + this.pendingEdit = null; + this.pendingEditLoading = false; + } + }); + } + + get hasPendingEdit(): boolean { + return !!this.pendingEdit && this.pendingEdit.status === 'PENDING'; + } + + private todayIso(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + } + private loadTemplate(): void { this.clientsService.getClientNomineeTemplate(this.clientId).subscribe({ next: (template: any) => { @@ -356,27 +395,34 @@ export class AccountNomineesTabComponent implements OnInit { if (this.guardianSameAsNominee) return; this.nomineeSaving = true; - const payload: any = { + const nomineePayload: any = { + operation: 'ADD', + nomineeId: null, nomineeClientId: this.selectedClient.id, - accountNo: this.accountNo, - accountType: this.accountType, - nominationType: this.nomineeNomType, relationshipId: this.nomineeRelationshipId, percentageShare: this.nomineeShare, + isActive: true, nomineeIsMinor: this.nomineeIsMinor, - remarks: this.nomineeRemarks || null, - locale: 'en' + guardianClientId: this.nomineeIsMinor && this.selectedGuardian ? this.selectedGuardian.id : null, + guardianRelationshipId: null, + effectiveDate: this.todayIso(), + remarks: this.nomineeRemarks || null }; - if (this.nomineeIsMinor && this.selectedGuardian) { - payload.guardianClientId = this.selectedGuardian.id; - } + const payload = { + locale: 'en', + dateFormat: 'yyyy-MM-dd', + nominee: nomineePayload + }; - this.clientsService.addNominee(this.clientId, payload).subscribe({ + const submit$ = + this.accountType === 'CB' + ? this.currentAccountsService.submitCurrentAccountEdit(this.accountId, payload) + : this.savingsService.submitSavingsAccountEdit(this.accountId, payload); + submit$.subscribe({ next: () => { this.nomineeSaving = false; - this.loadNominees(); - this.resetAddForm(); + this.showSuccess(); }, error: () => { this.nomineeSaving = false; @@ -384,6 +430,24 @@ export class AccountNomineesTabComponent implements OnInit { }); } + private showSuccess(): void { + const ref = this.dialog.open(SuccessDialogComponent, { + width: '420px', + maxWidth: '92vw', + autoFocus: false, + data: { + title: 'Submitted for Approval', + message: 'Your changes have been submitted and are awaiting checker approval.', + buttonLabel: 'Done' + } + }); + ref.afterClosed().subscribe(() => { + this.loadPendingEdit(); + this.loadNominees(); + this.resetAddForm(); + }); + } + viewNominee(nominee: any): void { const clientId = nominee.nomineeClientId || nominee.linkedClientId || nominee.clientId; const displayName = nominee.nomineeDisplayName || nominee.firstName || ''; @@ -432,17 +496,33 @@ export class AccountNomineesTabComponent implements OnInit { if (!this.editRelId || !this.editShare || this.editShare < 1 || this.editShare > this.maxShareForEdit(index)) return; - const payload: any = { + const nomineePayload: any = { + operation: 'UPDATE', + nomineeId: nominee.id, relationshipId: this.editRelId, percentageShare: this.editShare, - nominationType: this.editNomType, - locale: 'en' + isActive: nominee.isActive !== undefined ? !!nominee.isActive : true, + nomineeIsMinor: !!nominee.nomineeIsMinor, + guardianClientId: nominee.nomineeIsMinor ? nominee.guardianClientId : null, + guardianRelationshipId: nominee.nomineeIsMinor ? nominee.guardianRelationshipId : null, + effectiveDate: this.todayIso(), + remarks: nominee.remarks || null }; - this.clientsService.updateNominee(this.clientId, nominee.id.toString(), payload).subscribe({ + const payload = { + locale: 'en', + dateFormat: 'yyyy-MM-dd', + nominee: nomineePayload + }; + + const submit$ = + this.accountType === 'CB' + ? this.currentAccountsService.submitCurrentAccountEdit(this.accountId, payload) + : this.savingsService.submitSavingsAccountEdit(this.accountId, payload); + submit$.subscribe({ next: () => { - this.loadNominees(); this.editingIndex = null; + this.showSuccess(); }, error: () => {} }); @@ -454,9 +534,26 @@ export class AccountNomineesTabComponent implements OnInit { }); ref.afterClosed().subscribe((response: any) => { if (response?.delete) { - this.clientsService.deleteNominee(this.clientId, nominee.id.toString()).subscribe({ - next: () => this.nominees.splice(index, 1), - error: () => this.nominees.splice(index, 1) + const nomineePayload: any = { + operation: 'DELETE', + nomineeId: nominee.id + }; + + const payload = { + locale: 'en', + dateFormat: 'yyyy-MM-dd', + nominee: nomineePayload + }; + + const submit$ = + this.accountType === 'CB' + ? this.currentAccountsService.submitCurrentAccountEdit(this.accountId, payload) + : this.savingsService.submitSavingsAccountEdit(this.accountId, payload); + submit$.subscribe({ + next: () => { + this.showSuccess(); + }, + error: () => {} }); } }); 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/shared/introducer-search/introducer-search.component.html b/src/app/shared/introducer-search/introducer-search.component.html index e93a3a5ae..46a4e815a 100644 --- a/src/app/shared/introducer-search/introducer-search.component.html +++ b/src/app/shared/introducer-search/introducer-search.component.html @@ -19,7 +19,13 @@
Search by Name or Client ID - + search @@ -86,9 +92,11 @@ }
- + @if (!disabled) { + + }
@if (fetchingLinkedData) { diff --git a/src/app/shared/introducer-search/introducer-search.component.ts b/src/app/shared/introducer-search/introducer-search.component.ts index 467502a06..1d4287d16 100644 --- a/src/app/shared/introducer-search/introducer-search.component.ts +++ b/src/app/shared/introducer-search/introducer-search.component.ts @@ -33,6 +33,7 @@ export class IntroducerSearchComponent implements OnChanges { @Input() excludedClientIds: number[] = []; @Input() preselectedIntroducer: { clientId: number; displayName: string; cifNumber?: string } | null = null; + @Input() disabled = false; @Output() introducerChange = new EventEmitter<{ clientId: number; displayName: string; cifNumber?: string } | null>(); entryMode: 'linked' = 'linked'; 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/checker-inbox/checker-inbox.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts index 54da1de31..8f7ec6d2c 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts @@ -104,18 +104,22 @@ export class CheckerInboxComponent implements OnInit { ]; /** - * Entities whose pending edits are gated by our own m_savings_product_temp lock rather than the - * native command-source pipeline. They're excluded from Checker Inbox entirely - approving/rejecting - * them here would call the native /makercheckers/{id}?command=approve endpoint, which replays the - * original command handler directly and never touches our temp table, leaving its lock stuck forever. - * Action them from Approvals -> Pending Product Edits instead, where that's handled correctly. + * Entities whose pending edits are gated by our own custom staging tables (m_savings_product_temp + * for products, the savings account edit-request table for Nominee/Digital-Feature edits) rather + * than the native command-source pipeline. They're excluded from Checker Inbox entirely - + * approving/rejecting them here would call the native /makercheckers/{id}?command=approve endpoint, + * which replays the original command handler directly and never touches our staging table, leaving + * its lock/hold stuck forever. Action them from Approvals -> Pending Product Edits / Pending Account + * Edits instead, where that's handled correctly. */ private readonly productEditEntities = [ 'SAVINGSPRODUCT', 'CURRENTPRODUCT', 'SHAREPRODUCT', 'FIXEDDEPOSITPRODUCT', - 'RECURRINGDEPOSITPRODUCT' + 'RECURRINGDEPOSITPRODUCT', + 'SAVINGSACCOUNT', + 'CURRENTACCOUNT' ]; private excludeProductEdits(items: any[]): any[] { 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/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.html new file mode 100644 index 000000000..d9aaafb86 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.html @@ -0,0 +1,157 @@ + + +
+ @if (showData) { +
+ + search + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account No.{{ edit.accountNo }}Account Type + + {{ edit.accountType }} + + Submitted By{{ edit.makerUsername }}Submitted On{{ formatDate(edit.createdOn) }}Hold Amount + @if (edit.holdAmount) { + {{ edit.holdAmount }} + } @else { + — + } + Status + {{ edit.status }} + Actions + + + +
+
+ + @if (selectedEdit) { +
+
+

Proposed Changes — Account "{{ selectedEdit.accountNo }}"

+ +
+ + @if (selectedEdit.facilityConfigSubmitted) { +
+

Digital & Account Features

+ + + + + + + + + @for (row of entries(selectedEdit.proposedFacilityConfig); track row.key) { + + + + + } + +
FeatureProposed Value
{{ row.key }}{{ row.value }}
+
+ } + + @if (selectedEdit.nomineeSubmitted) { +
+

Nominee Change

+ + + + + + + + + @for (row of entries(selectedEdit.proposedNominee); track row.key) { + + + + + } + +
FieldProposed Value
{{ row.key }}{{ row.value }}
+
+ } + + @if (!selectedEdit.facilityConfigSubmitted && !selectedEdit.nomineeSubmitted) { +

No proposed changes recorded on this edit.

+ } + + @if (selectedEdit.rejectReason) { +

Reject Reason: {{ selectedEdit.rejectReason }}

+ } +
+ } + } + + @if (!showData) { +
+ +

No pending account edits

+

There are no Nominee / Digital-Feature edits awaiting approval at this time.

+
+ } +
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.scss new file mode 100644 index 000000000..81f4fff23 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.scss @@ -0,0 +1,215 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$border: #e2e8f0; +$text-muted: #64748b; + +.psae-tab-content { + padding: 16px 0; +} + +.psae-action-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.psae-search-field { + flex: 1; + min-width: 200px; + max-width: 320px; +} + +.psae-btn { + font-size: 11.5px; + font-weight: 600; + border-radius: 8px !important; + height: 32px; + padding: 0 10px; + margin-right: 6px; + + fa-icon { + margin-right: 4px; + font-size: 11px; + } +} + +.psae-btn-view { + border-color: #2563eb; + color: #2563eb; + + &:hover { + background: #eff6ff; + } +} + +.psae-btn-approve { + border-color: #16a34a; + color: #16a34a; + + &:hover { + background: #f0fdf4; + } +} + +.psae-btn-reject { + border-color: #dc2626; + color: #dc2626; + + &:hover { + background: #fef2f2; + } +} + +.status-badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + + &--pending { + background: #fef9c3; + color: #854d0e; + } + + &--hold { + background: #ffedd5; + color: #9a3412; + } + + &--savings { + background: #e0f2fe; + color: #0369a1; + } + + &--current { + background: #f3e8ff; + color: #6b21a8; + } + + &--fd { + background: #fef2f2; + color: #991b1b; + } + + &--rd { + background: #ecfdf5; + color: #065f46; + } +} + +.psae-diff-panel { + margin-top: 20px; + border: 1px solid $border; + border-radius: 12px; + background: #fff; + padding: 16px; +} + +.psae-diff-header { + display: flex; + align-items: center; + justify-content: space-between; + + h3 { + margin: 0; + font-size: 14px; + font-weight: 700; + } +} + +.psae-diff-block { + margin-top: 16px; + + h4 { + margin: 0 0 6px; + font-size: 12.5px; + font-weight: 700; + color: #0d1b2a; + text-transform: uppercase; + letter-spacing: 0.03em; + } +} + +.psae-diff-loading { + color: $text-muted; + font-size: 13px; +} + +.psae-diff-table { + width: 100%; + border-collapse: collapse; + margin-top: 4px; + font-size: 12.5px; + + th, + td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid $border; + } + + th { + color: $text-muted; + font-weight: 600; + } +} + +.psae-diff-label { + font-weight: 600; + color: #0d1b2a; +} + +.psae-diff-proposed { + color: #15803d; + font-weight: 600; +} + +.psae-reject-reason { + margin-top: 14px; + font-size: 12.5px; + color: #b91c1c; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 24px; + background: #fff; + border: 1px dashed $border; + border-radius: 14px; + text-align: center; + margin: 16px 0; + + .empty-icon { + font-size: 42px; + color: #cbd5e1; + margin-bottom: 14px; + } + + .empty-title { + margin: 0 0 4px; + font-size: 15px; + font-weight: 700; + color: #0d1b2a; + } + + .empty-text { + margin: 0; + font-size: 12.5px; + color: $text-muted; + max-width: 380px; + line-height: 1.5; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.ts new file mode 100644 index 000000000..78bd3c1fa --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component.ts @@ -0,0 +1,198 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + MatTableDataSource, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { MatIconButton } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +import { SavingsService } from 'app/savings/savings.service'; +import { CurrentAccountsService } from 'app/current-accounts/current-accounts.service'; +import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; +import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; +import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; +import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Checker-facing list of savings account Nominee / Digital-Feature edits awaiting approval. + * Mirrors PendingSavingsProductsComponent's list + compare + approve/reject pattern. + */ +@Component({ + selector: 'mifosx-pending-savings-account-edits', + templateUrl: './pending-savings-account-edits.component.html', + styleUrls: ['./pending-savings-account-edits.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatIcon, + MatIconButton + ] +}) +export class PendingSavingsAccountEditsComponent { + private route = inject(ActivatedRoute); + private router = inject(Router); + private dialog = inject(MatDialog); + private savingsService = inject(SavingsService); + private currentAccountsService = inject(CurrentAccountsService); + + dataSource: MatTableDataSource; + showData = false; + + displayedColumns: string[] = [ + 'accountNo', + 'accountType', + 'makerUsername', + 'createdOn', + 'holdAmount', + 'status', + 'actions' + ]; + + selectedEdit: any = null; + + constructor() { + this.route.data.subscribe((data: { pendingSavingsAccountEditsData: any; pendingCurrentAccountEditsData: any }) => { + const savingsItems = (data.pendingSavingsAccountEditsData || []).map((item: any) => { + let type = 'Savings'; + if (item.depositAccountType === 'FIXED_DEPOSIT') { + type = 'Fixed Deposit'; + } else if (item.depositAccountType === 'RECURRING_DEPOSIT') { + type = 'Recurring Deposit'; + } + return { + ...item, + accountType: type + }; + }); + const currentItems = (data.pendingCurrentAccountEditsData || []).map((item: any) => ({ + ...item, + accountType: 'Current' + })); + const items = [ + ...savingsItems, + ...currentItems + ]; + this.showData = items.length > 0; + this.dataSource = new MatTableDataSource(items); + }); + } + + applyFilter(filterValue: string = '') { + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + formatDate(date: any): string { + if (!date) return ''; + const d = Array.isArray(date) + ? new Date(date[0], date[1] - 1, date[2], date[3] || 0, date[4] || 0) + : new Date(date); + return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN'); + } + + /** Readable list of "key: value" pairs for the proposed facility/nominee changes. */ + entries(obj: any): { key: string; value: any }[] { + if (!obj) return []; + return Object.keys(obj).map((key) => ({ key, value: obj[key] })); + } + + viewCompare(edit: any): void { + this.selectedEdit = this.selectedEdit === edit ? null : edit; + } + + closeCompare(): void { + this.selectedEdit = null; + } + + approve(edit: any): void { + const isCurrent = edit.accountType === 'Current'; + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + heading: isCurrent ? 'Approve Current Account Edit' : 'Approve Savings Account Edit', + dialogContext: `Apply the pending Nominee/Digital-Feature edit for account "${edit.accountNo}"? This cannot be undone.`, + type: 'basic' + } + }); + dialogRef.afterClosed().subscribe((response: any) => { + if (response?.confirm) { + if (isCurrent) { + this.currentAccountsService.approveCurrentAccountEdit(edit.currentAccountId).subscribe(() => this.reload()); + } else { + this.savingsService.approveSavingsAccountEdit(edit.savingsAccountId).subscribe(() => this.reload()); + } + } + }); + } + + reject(edit: any): void { + const isCurrent = edit.accountType === 'Current'; + const formfields: FormfieldBase[] = [ + new InputBase({ + controlName: 'rejectReason', + label: 'Reason for rejection', + value: '', + type: 'text', + required: false + }) + ]; + const dialogRef = this.dialog.open(FormDialogComponent, { + data: { + title: `Reject edit for account "${edit.accountNo}"`, + layout: { addButtonText: 'Reject' }, + formfields + } + }); + dialogRef.afterClosed().subscribe((response: any) => { + if (response?.data) { + const rejectReason = response.data.value.rejectReason || ''; + if (isCurrent) { + this.currentAccountsService + .rejectCurrentAccountEdit(edit.currentAccountId, rejectReason) + .subscribe(() => this.reload()); + } else { + this.savingsService + .rejectSavingsAccountEdit(edit.savingsAccountId, rejectReason) + .subscribe(() => this.reload()); + } + } + }); + } + + reload(): void { + this.closeCompare(); + const url = this.router.url; + this.router + .navigateByUrl('/checker-inbox-and-tasks', { skipLocationChange: true }) + .then(() => this.router.navigate([url])); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 7a073ade3..f5c176f5b 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -106,6 +106,18 @@ Pending Product Edits + + + Pending Account Edits + { + private currentAccountsService = inject(CurrentAccountsService); + + resolve(): Observable { + return this.currentAccountsService.getAllPendingCurrentAccountEdits(); + } +} diff --git a/src/app/tasks/common-resolvers/getPendingSavingsAccountEdits.resolver.ts b/src/app/tasks/common-resolvers/getPendingSavingsAccountEdits.resolver.ts new file mode 100644 index 000000000..3bdf0e6b6 --- /dev/null +++ b/src/app/tasks/common-resolvers/getPendingSavingsAccountEdits.resolver.ts @@ -0,0 +1,25 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { Resolve } from '@angular/router'; +import { Observable } from 'rxjs'; +import { SavingsService } from 'app/savings/savings.service'; + +/** + * Resolves all savings account Nominee/Digital-Feature edits currently awaiting checker approval, + * for the Pending Savings Account Edits screen. + */ +@Injectable() +export class GetPendingSavingsAccountEdits implements Resolve { + private savingsService = inject(SavingsService); + + resolve(): Observable { + return this.savingsService.getAllPendingSavingsAccountEdits(); + } +} diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index b4050cc5f..2ea67ff26 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -28,6 +28,7 @@ import { RejectedAccountComponent } from './checker-inbox-and-tasks-tabs/rejecte import { TerminatedAccountComponent } from './checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component'; import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component'; import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component'; +import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component'; import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component'; /** Custom Resolvers */ @@ -43,6 +44,8 @@ import { GetPendingAccounts } from './common-resolvers/getPendingAccounts.resolv import { GetRejectedAccounts } from './common-resolvers/getRejectedAccounts.resolver'; import { GetTerminatedAccounts } from './common-resolvers/getTerminatedAccounts.resolver'; import { GetPendingSavingsProducts } from './common-resolvers/getPendingSavingsProducts.resolver'; +import { GetPendingSavingsAccountEdits } from './common-resolvers/getPendingSavingsAccountEdits.resolver'; +import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurrentAccountEdits.resolver'; /** Tasks Routes */ const routes: Routes = [ @@ -126,6 +129,19 @@ const routes: Routes = [ pendingSavingsProductsData: GetPendingSavingsProducts } }, + { + path: 'pending-savings-account-edits', + component: PendingSavingsAccountEditsComponent, + data: { title: 'Pending Account Edits' }, + resolve: { + pendingSavingsAccountEditsData: GetPendingSavingsAccountEdits, + pendingCurrentAccountEditsData: GetPendingCurrentAccountEdits + } + }, + { + path: 'pending-current-account-edits', + redirectTo: 'pending-savings-account-edits' + }, { path: 'withdrawal-requests', component: WithdrawalRequestsComponent, @@ -203,7 +219,9 @@ const routes: Routes = [ GetPendingAccounts, GetRejectedAccounts, GetTerminatedAccounts, - GetPendingSavingsProducts + GetPendingSavingsProducts, + GetPendingSavingsAccountEdits, + GetPendingCurrentAccountEdits ] }) export class TasksRoutingModule {}