- 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>
185 lines
7.2 KiB
HTML
185 lines
7.2 KiB
HTML
<!--
|
||
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="election-dashboard">
|
||
<div class="page-header">
|
||
<h2 class="page-title">Election Management</h2>
|
||
<a routerLink="/election/config/new" class="btn-primary"> <mat-icon>add</mat-icon> Create Election </a>
|
||
</div>
|
||
|
||
@if (loading) {
|
||
<div class="loading-state">
|
||
<div class="spinner"></div>
|
||
<p>Loading elections...</p>
|
||
</div>
|
||
} @else if (elections.length === 0) {
|
||
<div class="empty-state">
|
||
<mat-icon class="empty-icon">how_to_vote</mat-icon>
|
||
<h3>No elections found</h3>
|
||
<p>Create a new election to get started.</p>
|
||
<a routerLink="/election/config/new" class="btn-primary">Create Election</a>
|
||
</div>
|
||
} @else {
|
||
<div class="stats-row">
|
||
<div class="stat-card">
|
||
<span class="stat-value">{{ totalElements }}</span>
|
||
<span class="stat-label">Total</span>
|
||
</div>
|
||
<div class="stat-card">
|
||
<span class="stat-value">{{ countByStatus('ACTIVE') }}</span>
|
||
<span class="stat-label">Active</span>
|
||
</div>
|
||
<div class="stat-card">
|
||
<span class="stat-value">{{ countByStatus('VOTING_OPEN') }}</span>
|
||
<span class="stat-label">Voting Open</span>
|
||
</div>
|
||
<div class="stat-card">
|
||
<span class="stat-value">{{ countByStatus('RESULT_DECLARED') }}</span>
|
||
<span class="stat-label">Completed</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="elections-grid">
|
||
@for (election of paginatedElections; track election.id) {
|
||
<div class="election-card">
|
||
<div class="card-header">
|
||
<div>
|
||
<h3 class="election-title">{{ election.title }}</h3>
|
||
<span class="election-type">{{ election.electionType }}</span>
|
||
</div>
|
||
<span class="status-chip" [ngClass]="statusClass(election.status)">
|
||
{{ statusLabel(election.status) }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="card-body">
|
||
<div class="date-row">
|
||
<div class="date-item">
|
||
<mat-icon>event_note</mat-icon>
|
||
<div>
|
||
<span class="date-label">Nominations</span>
|
||
<span class="date-value">
|
||
{{ election.nominationStartDate | date: 'dd MMM' }} –
|
||
{{ election.nominationEndDate | date: 'dd MMM yyyy' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="date-item">
|
||
<mat-icon>how_to_vote</mat-icon>
|
||
<div>
|
||
<span class="date-label">Election Date</span>
|
||
<span class="date-value">{{ election.electionDate | date: 'dd MMM yyyy' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
@if (election.description) {
|
||
<p class="description">{{ election.description }}</p>
|
||
}
|
||
|
||
@if (voteSummaryMap[election.id]) {
|
||
<div class="voter-count-row">
|
||
<span class="voter-count-chip">
|
||
<mat-icon>how_to_vote</mat-icon>
|
||
{{ voteSummaryMap[election.id].totalVoters }} members voted
|
||
</span>
|
||
<span class="vote-total-chip"> {{ voteSummaryMap[election.id].totalVotes }} total votes </span>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<div class="card-actions">
|
||
<a [routerLink]="['/election', election.id, 'nominations']" class="btn-outline">
|
||
<mat-icon>people</mat-icon> Nominations
|
||
</a>
|
||
<a [routerLink]="['/election', election.id, 'candidates']" class="btn-outline">
|
||
<mat-icon>person_pin</mat-icon> Candidates
|
||
</a>
|
||
|
||
@if (election.status === 'DRAFT') {
|
||
<button class="btn-activate" [disabled]="actionLoading[election.id]" (click)="activate(election)">
|
||
<mat-icon>play_circle</mat-icon>
|
||
{{ actionLoading[election.id] ? 'Activating...' : 'Activate' }}
|
||
</button>
|
||
}
|
||
@if (canNominate(election)) {
|
||
<a [routerLink]="['/election', election.id, 'nomination', 'apply']" class="btn-primary">
|
||
<mat-icon>person_add</mat-icon> Nominate Member
|
||
</a>
|
||
}
|
||
@if (election.status === 'ACTIVE') {
|
||
<button class="btn-warn" [disabled]="actionLoading[election.id]" (click)="closeNominations(election)">
|
||
<mat-icon>lock</mat-icon> Close Nominations
|
||
</button>
|
||
}
|
||
@if (election.status === 'NOMINATION_CLOSED') {
|
||
<button class="btn-activate" [disabled]="actionLoading[election.id]" (click)="openVoting(election)">
|
||
<mat-icon>how_to_vote</mat-icon> Open Voting
|
||
</button>
|
||
}
|
||
@if (canVote(election)) {
|
||
<a [routerLink]="['/election', election.id, 'vote']" class="btn-vote">
|
||
<mat-icon>ballot</mat-icon> Vote Now
|
||
</a>
|
||
}
|
||
@if (canViewResults(election)) {
|
||
<a [routerLink]="['/election', election.id, 'results']" class="btn-results">
|
||
<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>
|
||
}
|
||
</div>
|
||
|
||
@if (totalPages > 1 || totalElements > pageSizeOptions[0]) {
|
||
<div class="pagination-bar">
|
||
<div class="pg-info">Showing {{ startItem }}–{{ endItem }} of {{ totalElements }}</div>
|
||
<div class="pg-controls">
|
||
<button class="pg-btn" [disabled]="currentPage === 1" (click)="goToPage(1)" title="First">
|
||
<mat-icon>first_page</mat-icon>
|
||
</button>
|
||
<button class="pg-btn" [disabled]="currentPage === 1" (click)="goToPage(currentPage - 1)" title="Previous">
|
||
<mat-icon>chevron_left</mat-icon>
|
||
</button>
|
||
@for (p of pageNumbers; track $index) {
|
||
@if (isNumber(p)) {
|
||
<button class="pg-btn pg-num" [class.pg-num--active]="p === currentPage" (click)="goToPage(p)">
|
||
{{ p }}
|
||
</button>
|
||
} @else {
|
||
<span class="pg-ellipsis">…</span>
|
||
}
|
||
}
|
||
<button
|
||
class="pg-btn"
|
||
[disabled]="currentPage === totalPages"
|
||
(click)="goToPage(currentPage + 1)"
|
||
title="Next"
|
||
>
|
||
<mat-icon>chevron_right</mat-icon>
|
||
</button>
|
||
<button class="pg-btn" [disabled]="currentPage === totalPages" (click)="goToPage(totalPages)" 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>
|