- Replace TYPE dropdown options with Withdrawal Slip, Cheque, Disbursement, Enter Bank Transfer, DD - Move TXN TP. (payment type) into Classification row between BRANCH and AC. TP. - Add GL and LA account types; fix SHR endpoint to /accounts/share - Auto-search A/C NO. on 4+ digits with 400ms debounce - Auto-fill ECS A/C NO. from savingsAccountNumber after account load - Accept suffix/partial accountNo match from list API - Disable broken global /search fallback across all account types - Increase field widths: TYPE 160px, TXN TP. 160px, CHEQUE NO. 165px, TOK. NO. 120px Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
283 lines
8.7 KiB
TypeScript
283 lines
8.7 KiB
TypeScript
/**
|
|
* Copyright since 2025 Mifos Initiative
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
*/
|
|
|
|
import { Component, OnInit, inject } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { RouterModule } from '@angular/router';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatSelectModule } from '@angular/material/select';
|
|
import { MatInputModule } from '@angular/material/input';
|
|
import { catchError } from 'rxjs/operators';
|
|
import { of } from 'rxjs';
|
|
import { CashierService } from '../cashier.service';
|
|
|
|
type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success';
|
|
|
|
interface Denomination {
|
|
id: number;
|
|
value: number;
|
|
kind: 'note' | 'coin';
|
|
label: string;
|
|
}
|
|
|
|
interface ShareCharge {
|
|
id: number;
|
|
name: string;
|
|
amount: number;
|
|
currencySymbol: string;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'mifosx-share-collection',
|
|
templateUrl: './share-collection.component.html',
|
|
styleUrls: [
|
|
'../cashier-shared.scss',
|
|
'./share-collection.component.scss'
|
|
],
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
RouterModule,
|
|
MatIconModule,
|
|
MatSnackBarModule,
|
|
MatFormFieldModule,
|
|
MatSelectModule,
|
|
MatInputModule
|
|
]
|
|
})
|
|
export class ShareCollectionComponent implements OnInit {
|
|
private service = inject(CashierService);
|
|
private snackBar = inject(MatSnackBar);
|
|
|
|
pageState: PageState = 'idle';
|
|
accountInput = '';
|
|
transactionDate = '';
|
|
paymentTypeId: number | null = null;
|
|
paymentTypes: any[] = [];
|
|
note = '';
|
|
errorMsg = '';
|
|
saving = false;
|
|
|
|
account: any = null;
|
|
charges: ShareCharge[] = [];
|
|
collectionResult: any = null; // filled after successful POST
|
|
|
|
// Editable fields — cashier can override if API returns 0
|
|
requestedSharesInput = 0;
|
|
pricePerShare = 0;
|
|
|
|
// ── Denomination ────────────────────────────────────────────────────────────
|
|
notes: Denomination[] = [];
|
|
coins: Denomination[] = [];
|
|
denominationCounts: Record<string, number> = {};
|
|
|
|
get shareAmount(): number {
|
|
return this.requestedSharesInput * this.pricePerShare;
|
|
}
|
|
|
|
get totalChargeAmount(): number {
|
|
return this.charges.reduce((s, c) => s + c.amount, 0);
|
|
}
|
|
|
|
get totalAmount(): number {
|
|
return this.shareAmount + this.totalChargeAmount;
|
|
}
|
|
|
|
get currencySymbol(): string {
|
|
return this.account?.currency?.displaySymbol ?? '₹';
|
|
}
|
|
|
|
get isCashPayment(): boolean {
|
|
if (this.paymentTypeId === null) return true;
|
|
const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId);
|
|
return !!pt?.isCashPayment;
|
|
}
|
|
|
|
get allDenominations(): Denomination[] {
|
|
const desc = (a: Denomination, b: Denomination) => b.value - a.value;
|
|
return [
|
|
...[...this.notes].sort(desc),
|
|
...[...this.coins].sort(desc)
|
|
];
|
|
}
|
|
|
|
get denominationTotal(): number {
|
|
return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0);
|
|
}
|
|
|
|
get denomMatchesTotal(): boolean {
|
|
return this.denominationTotal === this.totalAmount;
|
|
}
|
|
|
|
get isValid(): boolean {
|
|
if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false;
|
|
if (this.isCashPayment && this.allDenominations.length > 0) {
|
|
return this.denomMatchesTotal;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
const d = new Date();
|
|
this.transactionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
this.service
|
|
.getPaymentTypes()
|
|
.pipe(catchError(() => of([])))
|
|
.subscribe((types: any[]) => (this.paymentTypes = types || []));
|
|
this.loadDenominations();
|
|
}
|
|
|
|
private loadDenominations(): void {
|
|
this.service
|
|
.getActiveDenominations()
|
|
.pipe(catchError(() => of([])))
|
|
.subscribe((list: any[]) => {
|
|
const rows: Denomination[] = (list || []).map((d: any) => ({
|
|
id: d.id,
|
|
value: d.denomination ?? d.value,
|
|
kind: d.type === 'coin' ? 'coin' : 'note',
|
|
label: '₹' + (d.denomination ?? d.value)
|
|
}));
|
|
const order = (a: Denomination, b: Denomination) => b.value - a.value;
|
|
this.notes = rows.filter((r) => r.kind === 'note').sort(order);
|
|
this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
|
|
});
|
|
}
|
|
|
|
// ── Denomination helpers ──────────────────────────────────────────────────
|
|
|
|
denomKey(d: Denomination): string {
|
|
return `${d.kind}:${d.value}`;
|
|
}
|
|
|
|
countFor(d: Denomination): number {
|
|
return this.denominationCounts[this.denomKey(d)] || 0;
|
|
}
|
|
|
|
subtotalFor(d: Denomination): number {
|
|
return this.countFor(d) * d.value;
|
|
}
|
|
|
|
onDenomChange(d: Denomination, raw: any): void {
|
|
this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
|
|
}
|
|
|
|
bumpDenom(d: Denomination, delta: number): void {
|
|
this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
|
|
}
|
|
|
|
clearDenominations(): void {
|
|
this.denominationCounts = {};
|
|
}
|
|
|
|
searchAccount(): void {
|
|
const query = this.accountInput.trim();
|
|
if (!query) return;
|
|
this.pageState = 'searching';
|
|
this.account = null;
|
|
this.charges = [];
|
|
this.errorMsg = '';
|
|
|
|
this.service
|
|
.getShareAccountForCollection(query)
|
|
.pipe(
|
|
catchError((err) => {
|
|
this.errorMsg =
|
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
err?.error?.developerMessage ||
|
|
'Account not found. Please check the account ID and try again.';
|
|
this.pageState = 'idle';
|
|
return of(null);
|
|
})
|
|
)
|
|
.subscribe((acc: any) => {
|
|
if (!acc) return;
|
|
this.account = acc;
|
|
|
|
// Auto-detect shares from summary or first pending purchasedShares entry
|
|
const pendingTx = (acc.purchasedShares || []).find(
|
|
(t: any) =>
|
|
t.status?.code === 'purchasedSharesStatusType.applied' && t.type?.code === 'purchasedSharesType.purchased'
|
|
);
|
|
this.requestedSharesInput = Number(
|
|
acc.summary?.totalPendingForApprovalShares || pendingTx?.numberOfShares || acc.requestedShares || 0
|
|
);
|
|
this.pricePerShare = Number(pendingTx?.purchasedPrice || acc.currentMarketPrice || acc.unitPrice || 0);
|
|
|
|
// Show ALL charges on the account — no type filtering
|
|
this.charges = (acc.charges || []).map((c: any) => ({
|
|
id: c.chargeId ?? c.id,
|
|
name: c.name,
|
|
amount: Number(c.amount ?? c.amountOutstanding ?? 0),
|
|
currencySymbol: c.currency?.displaySymbol ?? '₹'
|
|
}));
|
|
|
|
this.pageState = 'loaded';
|
|
});
|
|
}
|
|
|
|
recalc(): void {
|
|
// triggered by input changes — nothing extra needed, getters auto-recalculate
|
|
}
|
|
|
|
submit(): void {
|
|
if (!this.isValid || this.saving) return;
|
|
this.saving = true;
|
|
const payload = {
|
|
shareAccountId: this.account.id,
|
|
accountNo: this.account.accountNo,
|
|
clientName: this.account.clientName,
|
|
transactionDate: this.transactionDate,
|
|
requestedShares: this.requestedSharesInput,
|
|
pricePerShare: this.pricePerShare,
|
|
shareAmount: this.shareAmount,
|
|
chargeAmount: this.totalChargeAmount,
|
|
totalAmount: this.totalAmount,
|
|
paymentTypeId: this.paymentTypeId || null,
|
|
note: this.note || null
|
|
};
|
|
|
|
this.service.collectSharePayment(payload).subscribe({
|
|
next: (result: any) => {
|
|
this.collectionResult = result;
|
|
this.snackBar.open('Share collection posted successfully.', 'Close', { duration: 3000 });
|
|
this.pageState = 'success';
|
|
this.saving = false;
|
|
},
|
|
error: (err: any) => {
|
|
const msg =
|
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
err?.error?.developerMessage ||
|
|
err?.error?.defaultUserMessage ||
|
|
'Collection failed. Please try again.';
|
|
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
|
this.saving = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
reset(): void {
|
|
this.pageState = 'idle';
|
|
this.accountInput = '';
|
|
this.account = null;
|
|
this.charges = [];
|
|
this.errorMsg = '';
|
|
this.note = '';
|
|
this.paymentTypeId = null;
|
|
this.requestedSharesInput = 0;
|
|
this.pricePerShare = 0;
|
|
this.collectionResult = null;
|
|
this.saving = false;
|
|
this.clearDenominations();
|
|
}
|
|
}
|