feat(election): audit log UI with server-side filter, pagination, entity-aware actions

- New ElectionAuditLogComponent: timeline view, expandable JSON details
- Server-side filtering: entityType, action, full-text search (350ms debounce)
- Pagination bar with page size selector (10/20/50 per page)
- Action dropdown filtered by selected entity (CANDIDATE shows only
  candidate actions, NOMINATION shows nomination actions, etc.)
- Entity change resets action filter automatically
- election.service.ts: getAuditLog() sends query params to backend
- Audit Log button added to every election card in dashboard
- Route: /election/:electionId/audit-log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-29 13:02:45 +05:30
co-authored by Claude Sonnet 4.6
parent f1b829a100
commit 960e4e181b
7 changed files with 836 additions and 1 deletions
@@ -0,0 +1,232 @@
/**
* 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, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
import { ElectionService } from '../../election.service';
const ACTION_META: Record<string, { label: string; icon: string; color: string }> = {
CREATE_ELECTION: { label: 'Election Created', icon: 'add_circle', color: '#16a34a' },
UPDATE_ELECTION: { label: 'Election Updated', icon: 'edit', color: '#2563eb' },
ACTIVATE_ELECTION: { label: 'Activated', icon: 'play_circle', color: '#16a34a' },
CLOSE_NOMINATIONS: { label: 'Nominations Closed', icon: 'lock', color: '#d97706' },
OPEN_VOTING: { label: 'Voting Opened', icon: 'how_to_vote', color: '#16a34a' },
CLOSE_VOTING: { label: 'Voting Closed', icon: 'lock_clock', color: '#d97706' },
CANCEL_ELECTION: { label: 'Election Cancelled', icon: 'cancel', color: '#dc2626' },
CREATE_POST: { label: 'Post Created', icon: 'work', color: '#7c3aed' },
UPDATE_POST: { label: 'Post Updated', icon: 'work_outline', color: '#2563eb' },
SUBMIT_NOMINATION: { label: 'Nomination Submitted', icon: 'person_add', color: '#2563eb' },
WITHDRAW_NOMINATION: { label: 'Nomination Withdrawn', icon: 'person_remove', color: '#dc2626' },
RESUBMIT_NOMINATION: { label: 'Nomination Resubmitted', icon: 'refresh', color: '#2563eb' },
ELIGIBILITY_CHECK: { label: 'Eligibility Checked', icon: 'fact_check', color: '#7c3aed' },
CEO_PRECHECK: { label: 'CEO Pre-check', icon: 'manage_accounts', color: '#7c3aed' },
APPROVAL_ACTION: { label: 'Approval Action', icon: 'approval', color: '#d97706' },
APPROVE_CANDIDATE: { label: 'Candidate Approved', icon: 'verified', color: '#16a34a' },
REMOVE_CANDIDATE: { label: 'Candidate Removed', icon: 'remove_circle', color: '#dc2626' },
UPLOAD_SYMBOL: { label: 'Symbol Uploaded', icon: 'image', color: '#2563eb' },
UPDATE_CANDIDATE: { label: 'Candidate Updated', icon: 'edit_note', color: '#2563eb' },
CAST_VOTE: { label: 'Vote Cast', icon: 'ballot', color: '#16a34a' },
DECLARE_RESULTS: { label: 'Results Declared', icon: 'gavel', color: '#7c3aed' }
};
@Component({
selector: 'mifosx-election-audit-log',
standalone: true,
imports: [
CommonModule,
RouterModule,
FormsModule,
MatIconModule
],
templateUrl: './election-audit-log.component.html',
styleUrls: ['./election-audit-log.component.scss']
})
export class ElectionAuditLogComponent implements OnInit, OnDestroy {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
private destroy$ = new Subject<void>();
private filterTrigger$ = new Subject<void>();
electionId!: number;
election: any = null;
logs: any[] = [];
loading = true;
filterEntity = '';
filterAction = '';
searchText = '';
page = 0;
pageSize = 20;
totalElements = 0;
totalPages = 0;
expandedId: number | null = null;
readonly entityTypes = [
'ELECTION',
'NOMINATION',
'CANDIDATE',
'VOTE',
'RESULT'
];
readonly pageSizeOptions = [
10,
20,
50
];
private readonly ENTITY_ACTIONS: Record<string, string[]> = {
ELECTION: [
'CREATE_ELECTION',
'UPDATE_ELECTION',
'ACTIVATE_ELECTION',
'CLOSE_NOMINATIONS',
'OPEN_VOTING',
'CLOSE_VOTING',
'CANCEL_ELECTION',
'CREATE_POST',
'UPDATE_POST'
],
NOMINATION: [
'SUBMIT_NOMINATION',
'WITHDRAW_NOMINATION',
'RESUBMIT_NOMINATION',
'ELIGIBILITY_CHECK',
'CEO_PRECHECK',
'APPROVAL_ACTION'
],
CANDIDATE: [
'APPROVE_CANDIDATE',
'REMOVE_CANDIDATE',
'UPLOAD_SYMBOL',
'UPDATE_CANDIDATE'
],
VOTE: ['CAST_VOTE'],
RESULT: ['DECLARE_RESULTS']
};
get availableActions(): string[] {
if (!this.filterEntity) return Object.keys(ACTION_META);
return this.ENTITY_ACTIONS[this.filterEntity] ?? Object.keys(ACTION_META);
}
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.service.getElection(this.electionId).subscribe((e) => (this.election = e));
this.filterTrigger$.pipe(debounceTime(350), takeUntil(this.destroy$)).subscribe(() => this.fetchLogs());
this.triggerFetch();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadLogs(): void {
this.page = 0;
this.triggerFetch();
}
onEntityChange(): void {
this.filterAction = '';
this.page = 0;
this.triggerFetch();
}
applyFilter(): void {
this.page = 0;
this.triggerFetch();
}
clearFilters(): void {
this.filterEntity = '';
this.filterAction = '';
this.searchText = '';
this.page = 0;
this.triggerFetch();
}
goToPage(p: number): void {
if (p < 0 || p >= this.totalPages) return;
this.page = p;
this.fetchLogs();
}
onPageSizeChange(): void {
this.page = 0;
this.fetchLogs();
}
private triggerFetch(): void {
this.filterTrigger$.next();
}
private fetchLogs(): void {
this.loading = true;
this.service
.getAuditLog(this.electionId, {
entityType: this.filterEntity || undefined,
action: this.filterAction || undefined,
search: this.searchText || undefined,
page: this.page,
size: this.pageSize
})
.subscribe((data: any) => {
this.logs = data.content ?? [];
this.totalElements = data.totalElements ?? 0;
this.totalPages = data.totalPages ?? 0;
this.loading = false;
});
}
get startItem(): number {
return this.totalElements === 0 ? 0 : this.page * this.pageSize + 1;
}
get endItem(): number {
return Math.min((this.page + 1) * this.pageSize, this.totalElements);
}
get pageNumbers(): number[] {
const total = this.totalPages;
if (total <= 7) return Array.from({ length: total }, (_, i) => i);
const half = 2;
let start = Math.max(0, this.page - half);
let end = Math.min(total - 1, this.page + half);
if (end - start < 4) {
if (start === 0) end = Math.min(4, total - 1);
else start = Math.max(0, end - 4);
}
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
actionMeta(action: string): { label: string; icon: string; color: string } {
return ACTION_META[action] ?? { label: action, icon: 'info', color: '#6b7280' };
}
parsedDetails(details: string | null): string | null {
if (!details) return null;
try {
return JSON.stringify(JSON.parse(details), null, 2);
} catch {
return details;
}
}
toggleExpand(id: number): void {
this.expandedId = this.expandedId === id ? null : id;
}
}