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
|
<mat-icon>bar_chart</mat-icon> Results
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
<a [routerLink]="['/election', election.id, 'audit-log']" class="btn-audit">
|
||||||
|
<mat-icon>history</mat-icon> Audit Log
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,7 +235,8 @@
|
|||||||
.btn-primary,
|
.btn-primary,
|
||||||
.btn-outline,
|
.btn-outline,
|
||||||
.btn-vote,
|
.btn-vote,
|
||||||
.btn-results {
|
.btn-results,
|
||||||
|
.btn-audit {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
@@ -292,6 +293,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-audit {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.btn-activate {
|
.btn-activate {
|
||||||
background: #2b6cb0;
|
background: #2b6cb0;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./components/election-results/election-results.component').then((m) => m.ElectionResultsComponent),
|
import('./components/election-results/election-results.component').then((m) => m.ElectionResultsComponent),
|
||||||
data: { title: 'Election Results' }
|
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> {
|
getVoteSummary(electionId: number): Observable<any> {
|
||||||
return this.http.get<any>(`/election/${electionId}/vote-summary`).pipe(catchError(() => of(null)));
|
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