feat(election): add guarantor status section to nomination-apply page

Shows whether the selected nominee is a guarantor on any loan, with loan health indicator, borrower details, savings account, guaranteed/released/remaining amounts, and loan timeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-24 13:12:08 +05:30
co-authored by Claude Sonnet 4.6
parent 3d9b0480c6
commit 71552d6875
14 changed files with 2532 additions and 749 deletions
@@ -8,8 +8,6 @@
.candidate-board {
padding: 24px;
max-width: 1300px;
margin: 0 auto;
}
.page-header {
@@ -30,8 +30,7 @@ $shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
}
.config-inner {
max-width: 860px;
margin: 0 auto;
// layout wrapper
}
// ── Page header ────────────────────────────────────────────────────────────
@@ -8,8 +8,6 @@
.election-dashboard {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
@@ -8,8 +8,6 @@
.election-results {
padding: 24px;
max-width: 1000px;
margin: 0 auto;
}
.page-header {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -71,7 +71,43 @@ export class NominationApplyComponent implements OnInit {
kycData: any = null;
familyMemberKycMap: Record<string, any> = {};
familyKycLoading = false;
nomineeAccounts: any = null;
familyMemberAccountsMap: Record<string, any> = {};
familyAccountsLoading = false;
expandedFamilyCards = new Set<number>();
familyExpanded = true;
kycExpanded = true;
eligExpanded = true;
guarantorsExpanded = true;
// ── Resizable split panel ─────────────────────────────────────────────────
leftPanelWidth = 44;
isDragging = false;
private _dragLayout: HTMLElement | null = null;
private _onMouseMove = (e: MouseEvent) => {
if (!this._dragLayout) return;
const rect = this._dragLayout.getBoundingClientRect();
const raw = ((e.clientX - rect.left) / rect.width) * 100;
this.leftPanelWidth = Math.min(70, Math.max(25, raw));
};
private _onMouseUp = () => {
this.isDragging = false;
document.removeEventListener('mousemove', this._onMouseMove);
document.removeEventListener('mouseup', this._onMouseUp);
};
startDrag(event: MouseEvent, layoutEl: HTMLElement): void {
event.preventDefault();
this.isDragging = true;
this._dragLayout = layoutEl;
document.addEventListener('mousemove', this._onMouseMove);
document.addEventListener('mouseup', this._onMouseUp);
}
familyMemberOptions: any = null;
showAddAsClientPanel = false;
selectedNewRelationship: any = null;
addAsClientLoading = false;
profileForm!: FormGroup;
postForm!: FormGroup;
@@ -121,6 +157,59 @@ export class NominationApplyComponent implements OnInit {
return !!member.linkedClientId;
}
private get takenRelationshipIds(): Set<number> {
return new Set(
this.familyMembers
.map((m) => (typeof m.relationshipId === 'object' ? m.relationshipId?.id : m.relationshipId))
.filter((id) => !!id)
);
}
get availableRelationships(): any[] {
const taken = this.takenRelationshipIds;
return (this.familyMemberOptions?.relationshipIdOptions || []).filter((r: any) => !taken.has(r.id));
}
get takenRelationships(): any[] {
const taken = this.takenRelationshipIds;
return (this.familyMemberOptions?.relationshipIdOptions || []).filter((r: any) => taken.has(r.id));
}
toggleAddAsClientPanel(): void {
this.showAddAsClientPanel = !this.showAddAsClientPanel;
this.selectedNewRelationship = null;
if (this.showAddAsClientPanel && !this.familyMemberOptions) {
this.addAsClientLoading = true;
this.clientsService.getClientTemplate().subscribe({
next: (template: any) => {
this.familyMemberOptions = template.familyMemberOptions;
this.addAsClientLoading = false;
},
error: () => (this.addAsClientLoading = false)
});
}
}
selectNewRelationship(rel: any): void {
this.selectedNewRelationship = this.selectedNewRelationship?.id === rel.id ? null : rel;
}
createClientForRelationship(): void {
if (!this.selectedNewRelationship) return;
const prefill = {
relationshipId: this.selectedNewRelationship.id,
relationship: this.selectedNewRelationship.name,
parentClientId: this.selectedMemberId
};
this.router.navigate(
[
'/clients',
'create'
],
{ state: { prefill } }
);
}
toggleFamilyCard(memberId: number): void {
if (this.expandedFamilyCards.has(memberId)) {
this.expandedFamilyCards.delete(memberId);
@@ -200,6 +289,55 @@ export class NominationApplyComponent implements OnInit {
return risk === 1 ? 'low' : risk === 2 ? 'medium' : risk === 3 ? 'high' : '';
}
isLoanOverdue(loan: any): boolean {
const isClosed = loan.status?.id === 600 || (loan.status?.value ?? '').toString().toLowerCase().includes('closed');
if (isClosed) return false;
return (loan.summary?.totalOverdue ?? 0) > 0;
}
loanStatusLabel(loan: any): string {
const statusVal = (loan.status?.value ?? '').toString().toLowerCase();
if (statusVal.includes('closed')) return 'Closed';
if ((loan.summary?.totalOverdue ?? 0) > 0) return 'Overdue';
if (statusVal.includes('active')) return 'Active';
return loan.status?.value ?? '—';
}
loanStatusClass(loan: any): string {
const statusVal = (loan.status?.value ?? '').toString().toLowerCase();
if (statusVal.includes('closed')) return 'closed';
if ((loan.summary?.totalOverdue ?? 0) > 0) return 'overdue';
return 'active';
}
familyMemberAccounts(linkedClientId: number): any {
return this.familyMemberAccountsMap['c' + linkedClientId] ?? null;
}
get nomineeLoanAccounts(): any[] {
return this.nomineeAccounts?.loanAccounts ?? [];
}
get nomineeSavingsAccounts(): any[] {
return this.nomineeAccounts?.savingsAccounts ?? [];
}
get nomineeGuarantorAccounts(): any[] {
return this.nomineeAccounts?.guarantorAccounts ?? [];
}
get isEligible(): boolean {
if (!this.nomineeAccounts) return true;
const nomineeOverdue = (this.nomineeAccounts?.loanAccounts ?? []).some((l: any) => this.isLoanOverdue(l));
if (nomineeOverdue) return false;
for (const key of Object.keys(this.familyMemberAccountsMap)) {
const fmAccounts = this.familyMemberAccountsMap[key];
const overdue = (fmAccounts?.loanAccounts ?? []).some((l: any) => this.isLoanOverdue(l));
if (overdue) return false;
}
return true;
}
kycExpiryInfo(expiryDate: any): { date: Date | null; isExpired: boolean; isExpiringSoon: boolean; daysLeft: number } {
const date = this.parseDob(expiryDate);
if (!date) return { date: null, isExpired: false, isExpiringSoon: false, daysLeft: 0 };
@@ -208,6 +346,22 @@ export class NominationApplyComponent implements OnInit {
return { date, isExpired: daysLeft < 0, isExpiringSoon: daysLeft >= 0 && daysLeft <= 180, daysLeft };
}
clearMember(): void {
this.selectedMemberId = null;
this.memberSearchCtrl.setValue('', { emitEvent: false });
this.filteredClients = [];
this.selectedShareAccountNo = null;
this.selectedShareCount = null;
this.familyMembers = [];
this.kycData = null;
this.familyMemberKycMap = {};
this.nomineeAccounts = null;
this.familyMemberAccountsMap = {};
this.familyAccountsLoading = false;
this.familyLoading = false;
this.profileForm.reset();
}
onMemberSelect(client: any): void {
this.selectedMemberId = client.id;
this.memberSearchCtrl.setValue(client.displayName, { emitEvent: false });
@@ -218,6 +372,14 @@ export class NominationApplyComponent implements OnInit {
this.familyMemberKycMap = {};
this.familyKycLoading = false;
this.expandedFamilyCards = new Set<number>();
this.showAddAsClientPanel = false;
this.selectedNewRelationship = null;
this.familyExpanded = true;
this.kycExpanded = true;
this.eligExpanded = true;
this.nomineeAccounts = null;
this.familyMemberAccountsMap = {};
this.familyAccountsLoading = false;
forkJoin({
detail: this.clientsService.getClientData(client.id.toString()),
@@ -281,6 +443,7 @@ export class NominationApplyComponent implements OnInit {
this.familyLoading = false;
this.familyMembers = Array.isArray(family) ? family : [];
this.kycData = kyc;
this.nomineeAccounts = accounts;
const linkedMembers = this.familyMembers.filter((m) => !!m.linkedClientId);
if (linkedMembers.length > 0) {
@@ -296,6 +459,20 @@ export class NominationApplyComponent implements OnInit {
this.familyMemberKycMap = kycMap;
});
}
if (linkedMembers.length > 0) {
this.familyAccountsLoading = true;
const accountCalls: { [k: string]: any } = {};
linkedMembers.forEach((m) => {
accountCalls['c' + m.linkedClientId] = this.clientsService
.getClientAccountData(m.linkedClientId.toString())
.pipe(catchError(() => of(null)));
});
forkJoin(accountCalls).subscribe((accountsMap: any) => {
this.familyAccountsLoading = false;
this.familyMemberAccountsMap = accountsMap;
});
}
});
}
@@ -371,6 +548,10 @@ export class NominationApplyComponent implements OnInit {
});
}
get manualEntryMembers(): any[] {
return this.familyMembers.filter((m) => !m.linkedClientId);
}
submit(): void {
if (!this.selectedMemberId) {
this.snackBar.open('Please search and select a member first', 'Close', { duration: 3000 });
@@ -382,6 +563,33 @@ export class NominationApplyComponent implements OnInit {
this.declarationForm.markAllAsTouched();
return;
}
const manualMembers = this.manualEntryMembers;
if (manualMembers.length > 0) {
const names = manualMembers
.map((m) => {
const name = this.familyMemberDisplayName(m);
const rel = m.relationship || m.relationshipId?.name || '';
return rel ? `${name} (${rel})` : name;
})
.join(', ');
this.snackBar.open(
`Cannot submit: ${names} ${manualMembers.length === 1 ? 'is' : 'are'} not registered as CBS client${manualMembers.length === 1 ? '' : 's'}. Please use "Create as Client" to register them first.`,
'Close',
{ duration: 8000, panelClass: 'snack-error' }
);
return;
}
if (!this.isEligible) {
this.snackBar.open(
'Cannot submit: Nominee or a family member has overdue loans. No Due Certificate is not eligible.',
'Close',
{ duration: 8000, panelClass: 'snack-error' }
);
return;
}
this.submitting = true;
const bv = this.profileForm.value;
@@ -8,8 +8,6 @@
.nomination-detail {
padding: 24px;
max-width: 1100px;
margin: 0 auto;
}
.page-header {
@@ -8,8 +8,6 @@
.nomination-list {
padding: 24px;
max-width: 1300px;
margin: 0 auto;
}
.page-header {
@@ -8,8 +8,6 @@
.voting-interface {
padding: 24px;
max-width: 900px;
margin: 0 auto;
}
.page-header {
@@ -104,116 +104,90 @@
</div>
</div>
</div>
<table
class="flex-fill"
mat-table
[dataSource]="guarantorDetails | accountsFilter: 'guarantor' : showDeletedGuarantorsAccounts"
>
<table mat-table [dataSource]="guarantorDetails | accountsFilter: 'guarantor' : showDeletedGuarantorsAccounts">
<ng-container matColumnDef="fullname">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.name' | translate }}</th>
<td mat-cell *matCellDef="let guarantor">
@if (!guarantor.guarantorFundingDetails) {
<span> {{ guarantor.firstname }} &nbsp; {{ guarantor.lastname }} </span>
}
<span> {{ guarantor.firstname }} &nbsp; {{ guarantor.lastname }} </span>
</td>
</ng-container>
<ng-container matColumnDef="relationship">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Relationship' | translate }}</th>
<td mat-cell *matCellDef="let guarantor">
@if (!guarantor.guarantorFundingDetails) {
<span>
{{ guarantor.clientRelationshipType?.name }}
</span>
}
<span>{{ guarantor.clientRelationshipType?.name }}</span>
</td>
</ng-container>
<ng-container matColumnDef="guarantortype">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Guarantor Type' | translate }}</th>
<td mat-cell *matCellDef="let guarantor">
@if (!guarantor.guarantorFundingDetails) {
<span>
{{ guarantor.guarantorType.value }}
</span>
}
<span>{{ guarantor.guarantorType.value }}</span>
</td>
</ng-container>
<ng-container matColumnDef="depositAccount">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Deposit Account' | translate }}</th>
<td mat-cell *matCellDef="let guarantor">
@if (!guarantor.guarantorFundingDetails) {
<span> </span>
@if (guarantor.guarantorFundingDetails?.length) {
<span>{{ guarantor.guarantorFundingDetails[0].savingsAccount?.accountNo }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Amount' | translate }}</th>
<td mat-cell *matCellDef="let guarantor">
@if (!guarantor.guarantorFundingDetails) {
<span> </span>
@if (guarantor.guarantorFundingDetails?.length) {
<span>{{ guarantor.guarantorFundingDetails[0].amount | formatNumber }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="remainingAmount">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Remaining Amount' | translate }}</th>
<td mat-cell *matCellDef="let guarantor">
@if (!guarantor.guarantorFundingDetails) {
<span> </span>
@if (guarantor.guarantorFundingDetails?.length) {
<span>{{ guarantor.guarantorFundingDetails[0].amountRemaining | formatNumber }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let guarantor">
@if (!guarantor.guarantorFundingDetails) {
<span>
@if (guarantor.status) {
<span>
{{ 'labels.inputs.Active' | translate }}
</span>
}
@if (!guarantor.status) {
<span>
{{ 'labels.inputs.Deleted' | translate }}
</span>
}
</span>
@if (guarantor.status) {
<span>{{ 'labels.inputs.Active' | translate }}</span>
}
@if (!guarantor.status) {
<span>{{ 'labels.inputs.Deleted' | translate }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Action' | translate }}</th>
<td mat-cell *matCellDef="let guarantor; let id = index">
@if (!guarantor.guarantorFundingDetails) {
<span>
<span>
<button
mat-raised-button
class="account-action-button"
color="primary"
*mifosxHasPermission="'READ_SAVINGSACCOUNT'"
(click)="viewGuarantorDetails(guarantor)"
>
<fa-icon icon="eye" class="m-r-10"></fa-icon>
</button>
@if (guarantor.status && guarantor.guarantorType.id === 3 && guarantor.id) {
<button mat-raised-button class="account-action-button" color="primary">
<fa-icon icon="edit" class="m-r-10"></fa-icon>
</button>
}
@if (guarantor.status) {
<button
mat-raised-button
class="account-action-button"
color="primary"
*mifosxHasPermission="'READ_SAVINGSACCOUNT'"
(click)="viewGuarantorDetails(guarantor)"
color="warn"
*mifosxHasPermission="'DELETE_GUARANTOR'"
(click)="deleteGuarantor(guarantor.id)"
>
<fa-icon icon="eye" class="m-r-10"></fa-icon>
<fa-icon icon="trash" class="m-r-10"></fa-icon>
</button>
@if (guarantor.status && guarantor.guarantorType.id === 3 && guarantor.id) {
<button mat-raised-button class="account-action-button" color="primary">
<fa-icon icon="edit" class="m-r-10"></fa-icon>
</button>
}
@if (guarantor.status) {
<span>
<button
mat-raised-button
class="account-action-button"
color="warn"
*mifosxHasPermission="'DELETE_GUARANTOR'"
(click)="deleteGuarantor(guarantor.id)"
>
<fa-icon icon="trash" class="m-r-10"></fa-icon>
</button>
</span>
}
</span>
}
}
</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="guarantorsDisplayedColumns"></tr>
+4
View File
@@ -800,4 +800,8 @@ export class LoansService {
const httpParams = new HttpParams().set('offset', offset.toString()).set('limit', limit.toString());
return this.http.get('/entityDatatableChecks', { params: httpParams });
}
getLoanGuarantors(loanId: string): Observable<any> {
return this.http.get(`/loans/${loanId}/guarantors`);
}
}
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260619',
'hash': '822fa4c6'
'version': '260623',
'hash': '3d9b0480'
},
'allow_switching_backend_instance': true
};
+13
View File
@@ -33,6 +33,19 @@
z-index: 9999;
}
.snack-error .mdc-snackbar__surface {
background-color: #dc2626 !important;
color: #fff !important;
}
.snack-error .mat-mdc-snack-bar-label {
color: #fff !important;
}
.snack-error .mat-mdc-button {
color: #fecaca !important;
}
// ===== Sub-menu Mega Panels (rendered in CDK overlay) =====
.deposits-mega-panel,
.hrm-mega-panel {