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:
co-authored by
Claude Sonnet 4.6
parent
f1b829a100
commit
960e4e181b
@@ -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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 })));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user