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:
co-authored by
Claude Sonnet 4.6
parent
3d9b0480c6
commit
71552d6875
@@ -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 {
|
||||
|
||||
+37
-63
@@ -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 }} {{ guarantor.lastname }} </span>
|
||||
}
|
||||
<span> {{ guarantor.firstname }} {{ 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>
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260619',
|
||||
'hash': '822fa4c6'
|
||||
'version': '260623',
|
||||
'hash': '3d9b0480'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user