diff --git a/src/app/election/components/election-audit-log/election-audit-log.component.html b/src/app/election/components/election-audit-log/election-audit-log.component.html new file mode 100644 index 000000000..e73d81c8d --- /dev/null +++ b/src/app/election/components/election-audit-log/election-audit-log.component.html @@ -0,0 +1,155 @@ + + +
+ + + +
+
+ search + +
+ + + + + + @if (filterEntity || filterAction || searchText) { + + } + + {{ totalElements }} entries +
+ + @if (loading) { +
+
+

Loading audit log...

+
+ } @else if (logs.length === 0) { +
+ history +

No audit entries found.

+
+ } @else { +
+ @for (log of logs; track log.id) { +
+
+ {{ actionMeta(log.action).icon }} +
+ +
+
+
+ + {{ actionMeta(log.action).label }} + + {{ log.entityType }} + @if (log.entityId) { + #{{ log.entityId }} + } + +
+
+ {{ log.createdAt }} + {{ expandedId === log.id ? 'expand_less' : 'expand_more' }} +
+
+ +

{{ log.description }}

+ +
+ @if (log.performedByName) { + person{{ log.performedByName }} + } + @if (log.ipAddress) { + computer{{ log.ipAddress }} + } + @if (log.officeId) { + businessOffice #{{ log.officeId }} + } +
+ + @if (expandedId === log.id && parsedDetails(log.details)) { +
+
{{ parsedDetails(log.details) }}
+
+ } +
+
+ } +
+ + @if (totalPages > 1) { +
+
+ @if (totalElements === 0) { + No entries + } @else { + Showing {{ startItem }}–{{ endItem }} of {{ totalElements }} + } +
+
+ + + @for (p of pageNumbers; track p) { + + } + + +
+
+ +
+
+ } + } +
diff --git a/src/app/election/components/election-audit-log/election-audit-log.component.scss b/src/app/election/components/election-audit-log/election-audit-log.component.scss new file mode 100644 index 000000000..fdc534e99 --- /dev/null +++ b/src/app/election/components/election-audit-log/election-audit-log.component.scss @@ -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; + } + } +} diff --git a/src/app/election/components/election-audit-log/election-audit-log.component.ts b/src/app/election/components/election-audit-log/election-audit-log.component.ts new file mode 100644 index 000000000..e68f3801a --- /dev/null +++ b/src/app/election/components/election-audit-log/election-audit-log.component.ts @@ -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 = { + 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(); + private filterTrigger$ = new Subject(); + + 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 = { + 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; + } +} diff --git a/src/app/election/components/election-dashboard/election-dashboard.component.html b/src/app/election/components/election-dashboard/election-dashboard.component.html index 74e42e409..37d6c846c 100644 --- a/src/app/election/components/election-dashboard/election-dashboard.component.html +++ b/src/app/election/components/election-dashboard/election-dashboard.component.html @@ -132,6 +132,9 @@ bar_chart Results } + + history Audit Log + } diff --git a/src/app/election/components/election-dashboard/election-dashboard.component.scss b/src/app/election/components/election-dashboard/election-dashboard.component.scss index f0858d664..64156aac8 100644 --- a/src/app/election/components/election-dashboard/election-dashboard.component.scss +++ b/src/app/election/components/election-dashboard/election-dashboard.component.scss @@ -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; diff --git a/src/app/election/election-routing.module.ts b/src/app/election/election-routing.module.ts index c20490267..ae6e65e19 100644 --- a/src/app/election/election-routing.module.ts +++ b/src/app/election/election-routing.module.ts @@ -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' } } ] } diff --git a/src/app/election/election.service.ts b/src/app/election/election.service.ts index 12c75534b..061a69537 100644 --- a/src/app/election/election.service.ts +++ b/src/app/election/election.service.ts @@ -156,4 +156,19 @@ export class ElectionService { getVoteSummary(electionId: number): Observable { return this.http.get(`/election/${electionId}/vote-summary`).pipe(catchError(() => of(null))); } + + getAuditLog( + electionId: number, + params?: { entityType?: string; action?: string; search?: string; page?: number; size?: number } + ): Observable { + const qp: Record = {}; + 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(`/election/config/${electionId}/audit-log`, { params: qp }) + .pipe(catchError(() => of({ content: [], totalElements: 0, totalPages: 0, currentPage: 0, pageSize: 20 }))); + } }