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,155 @@
<!--
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/.
-->
<div class="audit-log-page">
<div class="page-header">
<a [routerLink]="['/election']" class="back-link"><mat-icon>arrow_back</mat-icon></a>
<div>
<h2>Audit Log</h2>
@if (election) {
<p class="sub-title">{{ election.title }}</p>
}
</div>
<button class="btn-refresh" (click)="loadLogs()" title="Refresh">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- Filters -->
<div class="filter-bar">
<div class="search-wrap">
<mat-icon>search</mat-icon>
<input
type="text"
placeholder="Search by action, user, description..."
[(ngModel)]="searchText"
(ngModelChange)="applyFilter()"
/>
</div>
<select [(ngModel)]="filterEntity" (change)="onEntityChange()" class="filter-select">
<option value="">All entities</option>
@for (e of entityTypes; track e) {
<option [value]="e">{{ e }}</option>
}
</select>
<select [(ngModel)]="filterAction" (change)="applyFilter()" class="filter-select">
<option value="">All actions</option>
@for (a of availableActions; track a) {
<option [value]="a">{{ actionMeta(a).label }}</option>
}
</select>
@if (filterEntity || filterAction || searchText) {
<button class="btn-clear" (click)="clearFilters()"><mat-icon>close</mat-icon> Clear</button>
}
<span class="count-badge">{{ totalElements }} entries</span>
</div>
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading audit log...</p>
</div>
} @else if (logs.length === 0) {
<div class="empty-state">
<mat-icon>history</mat-icon>
<p>No audit entries found.</p>
</div>
} @else {
<div class="timeline">
@for (log of logs; track log.id) {
<div class="timeline-item" [class.expanded]="expandedId === log.id">
<div class="timeline-dot" [style.background]="actionMeta(log.action).color">
<mat-icon>{{ actionMeta(log.action).icon }}</mat-icon>
</div>
<div class="timeline-card" (click)="toggleExpand(log.id)">
<div class="card-main">
<div class="card-left">
<span class="action-label" [style.color]="actionMeta(log.action).color">
{{ actionMeta(log.action).label }}
</span>
<span class="entity-chip"
>{{ log.entityType }}
@if (log.entityId) {
#{{ log.entityId }}
}
</span>
</div>
<div class="card-right">
<span class="timestamp">{{ log.createdAt }}</span>
<mat-icon class="expand-icon">{{ expandedId === log.id ? 'expand_less' : 'expand_more' }}</mat-icon>
</div>
</div>
<p class="description">{{ log.description }}</p>
<div class="card-meta">
@if (log.performedByName) {
<span class="meta-chip"><mat-icon>person</mat-icon>{{ log.performedByName }}</span>
}
@if (log.ipAddress) {
<span class="meta-chip"><mat-icon>computer</mat-icon>{{ log.ipAddress }}</span>
}
@if (log.officeId) {
<span class="meta-chip"><mat-icon>business</mat-icon>Office #{{ log.officeId }}</span>
}
</div>
@if (expandedId === log.id && parsedDetails(log.details)) {
<div class="details-block">
<pre>{{ parsedDetails(log.details) }}</pre>
</div>
}
</div>
</div>
}
</div>
@if (totalPages > 1) {
<div class="pagination-bar">
<div class="pg-info">
@if (totalElements === 0) {
No entries
} @else {
Showing {{ startItem }}–{{ endItem }} of {{ totalElements }}
}
</div>
<div class="pg-controls">
<button class="pg-btn" [disabled]="page === 0" (click)="goToPage(0)" title="First">
<mat-icon>first_page</mat-icon>
</button>
<button class="pg-btn" [disabled]="page === 0" (click)="goToPage(page - 1)" title="Previous">
<mat-icon>chevron_left</mat-icon>
</button>
@for (p of pageNumbers; track p) {
<button class="pg-btn" [class.pg-num--active]="p === page" (click)="goToPage(p)">
{{ p + 1 }}
</button>
}
<button class="pg-btn" [disabled]="page === totalPages - 1" (click)="goToPage(page + 1)" title="Next">
<mat-icon>chevron_right</mat-icon>
</button>
<button class="pg-btn" [disabled]="page === totalPages - 1" (click)="goToPage(totalPages - 1)" title="Last">
<mat-icon>last_page</mat-icon>
</button>
</div>
<div class="pg-size">
<select [(ngModel)]="pageSize" (change)="onPageSizeChange()">
@for (n of pageSizeOptions; track n) {
<option [value]="n">{{ n }} / page</option>
}
</select>
</div>
</div>
}
}
</div>
@@ -0,0 +1,410 @@
/**
* 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/.
*/
.audit-log-page {
padding: 24px;
max-width: 960px;
}
.page-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 24px;
h2 {
font-size: 22px;
font-weight: 700;
margin: 0 0 2px;
}
.sub-title {
font-size: 13px;
color: #718096;
margin: 0;
}
.back-link {
display: flex;
align-items: center;
color: #4a5568;
text-decoration: none;
&:hover {
color: #2563eb;
}
}
.btn-refresh {
margin-left: auto;
background: none;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
display: flex;
align-items: center;
color: #4a5568;
&:hover {
background: #f8fafc;
color: #2563eb;
}
}
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 24px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px 16px;
.search-wrap {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 200px;
mat-icon {
color: #a0aec0;
font-size: 18px;
}
input {
border: none;
outline: none;
font-size: 14px;
width: 100%;
background: transparent;
}
}
.filter-select {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 6px 10px;
font-size: 13px;
color: #4a5568;
background: #f8fafc;
outline: none;
cursor: pointer;
}
.btn-clear {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid #fed7d7;
border-radius: 8px;
padding: 5px 10px;
font-size: 13px;
color: #e53e3e;
cursor: pointer;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
&:hover {
background: #fff5f5;
}
}
.count-badge {
margin-left: auto;
background: #eff6ff;
color: #1d4ed8;
border-radius: 20px;
padding: 3px 10px;
font-size: 12px;
font-weight: 600;
}
}
.loading-state,
.empty-state {
text-align: center;
padding: 60px 20px;
color: #718096;
mat-icon {
font-size: 48px;
height: 48px;
width: 48px;
opacity: 0.4;
display: block;
margin: 0 auto 12px;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid #e2e8f0;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.timeline {
display: flex;
flex-direction: column;
gap: 0;
position: relative;
&::before {
content: '';
position: absolute;
left: 19px;
top: 0;
bottom: 0;
width: 2px;
background: #e2e8f0;
}
}
.timeline-item {
display: flex;
gap: 16px;
padding-bottom: 16px;
position: relative;
}
.timeline-dot {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
z-index: 1;
box-shadow: 0 0 0 3px #fff;
mat-icon {
color: #fff;
font-size: 18px;
height: 18px;
width: 18px;
}
}
.timeline-card {
flex: 1;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
cursor: pointer;
transition: box-shadow 0.15s;
&:hover {
box-shadow: 0 2px 10px rgb(0 0 0 / 8%);
}
.timeline-item.expanded & {
border-color: #bfdbfe;
box-shadow: 0 2px 12px rgb(37 99 235 / 10%);
}
}
.card-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
.card-left {
display: flex;
align-items: center;
gap: 8px;
}
.card-right {
display: flex;
align-items: center;
gap: 6px;
}
}
.action-label {
font-size: 14px;
font-weight: 700;
}
.entity-chip {
background: #f1f5f9;
color: #475569;
border-radius: 20px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
}
.timestamp {
font-size: 12px;
color: #94a3b8;
}
.expand-icon {
font-size: 18px;
height: 18px;
width: 18px;
color: #94a3b8;
}
.description {
font-size: 13px;
color: #4a5568;
margin: 0 0 8px;
}
.card-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
.meta-chip {
display: inline-flex;
align-items: center;
gap: 3px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 20px;
padding: 2px 8px;
font-size: 11px;
color: #64748b;
mat-icon {
font-size: 12px;
height: 12px;
width: 12px;
}
}
}
.details-block {
margin-top: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
pre {
margin: 0;
font-size: 12px;
color: #2d3748;
white-space: pre-wrap;
word-break: break-all;
font-family: Consolas, Monaco, monospace;
}
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.pg-info {
font-size: 12px;
color: #718096;
white-space: nowrap;
}
.pg-controls {
display: flex;
align-items: center;
gap: 3px;
}
.pg-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
padding: 0 4px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
color: #4a5568;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover:not(:disabled) {
background: #eff6ff;
border-color: #2563eb;
color: #2563eb;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.pg-num--active {
background: #2563eb;
color: #fff;
border-color: #2563eb;
&:hover {
background: #1d4ed8;
}
}
.pg-size {
select {
padding: 5px 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
color: #4a5568;
outline: none;
cursor: pointer;
background: #fff;
&:focus {
border-color: #2563eb;
}
}
}
@@ -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;
}
}
@@ -132,6 +132,9 @@
<mat-icon>bar_chart</mat-icon> Results
</a>
}
<a [routerLink]="['/election', election.id, 'audit-log']" class="btn-audit">
<mat-icon>history</mat-icon> Audit Log
</a>
</div>
</div>
}
@@ -235,7 +235,8 @@
.btn-primary,
.btn-outline,
.btn-vote,
.btn-results {
.btn-results,
.btn-audit {
display: inline-flex;
align-items: center;
gap: 3px;
@@ -292,6 +293,17 @@
}
}
.btn-audit {
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
&:hover {
background: #e2e8f0;
color: #1e293b;
}
}
.btn-activate {
background: #2b6cb0;
color: #fff;
@@ -73,6 +73,14 @@ const routes: Routes = [
loadComponent: () =>
import('./components/election-results/election-results.component').then((m) => m.ElectionResultsComponent),
data: { title: 'Election Results' }
},
{
path: ':electionId/audit-log',
loadComponent: () =>
import('./components/election-audit-log/election-audit-log.component').then(
(m) => m.ElectionAuditLogComponent
),
data: { title: 'Election Audit Log' }
}
]
}
+15
View File
@@ -156,4 +156,19 @@ export class ElectionService {
getVoteSummary(electionId: number): Observable<any> {
return this.http.get<any>(`/election/${electionId}/vote-summary`).pipe(catchError(() => of(null)));
}
getAuditLog(
electionId: number,
params?: { entityType?: string; action?: string; search?: string; page?: number; size?: number }
): Observable<any> {
const qp: Record<string, string> = {};
if (params?.entityType) qp['entityType'] = params.entityType;
if (params?.action) qp['action'] = params.action;
if (params?.search) qp['search'] = params.search;
if (params?.page !== undefined) qp['page'] = String(params.page);
if (params?.size !== undefined) qp['size'] = String(params.size);
return this.http
.get<any>(`/election/config/${electionId}/audit-log`, { params: qp })
.pipe(catchError(() => of({ content: [], totalElements: 0, totalPages: 0, currentPage: 0, pageSize: 20 })));
}
}