/** * 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 DETAIL_LABELS: Record = { // Election state transitions electionTitle: 'Election', newStatus: 'New Status', reason: 'Reason', // Election config fields title: 'Title', description: 'Description', electionType: 'Election Type', electionDate: 'Election Date', resultDate: 'Result Date', nominationStartDate: 'Nomination Start', nominationEndDate: 'Nomination End', votingStartDate: 'Voting Start', votingEndDate: 'Voting End', // Nomination submission nominationId: 'Nomination ID', applicantName: 'Applicant Name', memberId: 'Member ID', postId: 'Post ID', gender: 'Gender', caste: 'Caste', // Eligibility check overallResult: 'Overall Result', age: 'Age', shareHolding: 'Share Holding', area: 'Area', ageRemarks: 'Age Note', genderRemarks: 'Gender Note', casteRemarks: 'Caste Note', shareRemarks: 'Share Note', areaRemarks: 'Area Note', // CEO Pre-check directorLoan: 'Director Loan', directorLoanOutstanding: 'Loan Outstanding (₹)', directorLoanRemarks: 'Director Loan Note', relationAccount: 'Relation Account', relationRemarks: 'Relation Account Note', kyc: 'KYC', kycRemarks: 'KYC Note', familyKyc: 'Family KYC', familyKycRemarks: 'Family KYC Note', guarantor: 'Guarantor', guarantorRemarks: 'Guarantor Note', aml: 'AML', amlRemarks: 'AML Note', familyLoan: 'Family Loan', familyLoanRemarks: 'Family Loan Note', // Approval action action: 'Action', stepName: 'Approval Step', stepNumber: 'Step Number', remarks: 'Remarks', rejectionReason: 'Rejection Reason', // Candidate symbolUpdated: 'Symbol Updated', profileUpdated: 'Profile Updated', briefProfile: 'Brief Profile', filename: 'File Name', mimeType: 'MIME Type', // Winner results (array items) post: 'Post', winner: 'Winner', votes: 'Votes', candidate: 'Candidate', previousVotes: 'Previous Votes', // Director appointment designation: 'Designation', tenureStart: 'Tenure Start', tenureEnd: 'Tenure End', resolutionNo: 'Resolution No', appointmentDate: 'Appointment Date', status: 'Status', expiredCount: 'Expired Count', date: 'Date', // Committee name: 'Name', committeeId: 'Committee ID', members: 'Members', addedIds: 'Added IDs', removedAppointmentId: 'Removed Appointment ID', // Committee Meeting meetingId: 'Meeting ID', meetingTitle: 'Meeting Title', meetingDate: 'Meeting Date', mode: 'Mode', platform: 'Platform', venue: 'Venue', city: 'City' }; const ACTION_META: Record = { // Election Config 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' }, // Election Config — Failed ACTIVATE_ELECTION_FAILED: { label: 'Activate Failed', icon: 'error_outline', color: '#dc2626' }, CLOSE_NOMINATIONS_FAILED: { label: 'Close Nominations Failed', icon: 'error_outline', color: '#dc2626' }, OPEN_VOTING_FAILED: { label: 'Open Voting Failed', icon: 'error_outline', color: '#dc2626' }, CLOSE_VOTING_FAILED: { label: 'Close Voting Failed', icon: 'error_outline', color: '#dc2626' }, CANCEL_ELECTION_FAILED: { label: 'Cancel Failed', icon: 'error_outline', color: '#dc2626' }, // Nomination 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' }, GENERATE_NO_DUE_CERTIFICATE: { label: 'No Due Certificate', icon: 'receipt_long', color: '#0891b2' }, // Nomination — Failed SUBMIT_NOMINATION_FAILED: { label: 'Nomination Submit Failed', icon: 'error_outline', color: '#dc2626' }, WITHDRAW_NOMINATION_FAILED: { label: 'Withdraw Failed', icon: 'error_outline', color: '#dc2626' }, ELIGIBILITY_CHECK_FAILED: { label: 'Eligibility Check Failed', icon: 'error_outline', color: '#dc2626' }, CEO_PRECHECK_FAILED: { label: 'CEO Pre-check Failed', icon: 'error_outline', color: '#dc2626' }, APPROVAL_ACTION_FAILED: { label: 'Approval Failed', icon: 'error_outline', color: '#dc2626' }, // Candidate 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' }, // Candidate — Failed APPROVE_CANDIDATE_FAILED: { label: 'Approve Candidate Failed', icon: 'error_outline', color: '#dc2626' }, REMOVE_CANDIDATE_FAILED: { label: 'Remove Candidate Failed', icon: 'error_outline', color: '#dc2626' }, // Voting CAST_VOTE: { label: 'Vote Cast', icon: 'ballot', color: '#16a34a' }, MANUAL_VOTE_ENTRY: { label: 'Manual Vote Entry', icon: 'edit_note', color: '#0891b2' }, MANUAL_VOTE_ENTRY_FAILED: { label: 'Vote Entry Failed', icon: 'error_outline', color: '#dc2626' }, // Results DECLARE_RESULTS: { label: 'Results Declared', icon: 'gavel', color: '#7c3aed' }, DECLARE_RESULTS_FAILED: { label: 'Declare Results Failed', icon: 'error_outline', color: '#dc2626' }, // Director Appointment APPOINT_DIRECTOR: { label: 'Director Appointed', icon: 'person_check', color: '#16a34a' }, UPDATE_DIRECTOR_APPOINTMENT: { label: 'Appointment Updated', icon: 'manage_accounts', color: '#2563eb' }, REMOVE_DIRECTOR_APPOINTMENT: { label: 'Director Removed', icon: 'person_remove', color: '#dc2626' }, APPOINT_DIRECTOR_FAILED: { label: 'Appoint Failed', icon: 'error_outline', color: '#dc2626' }, UPDATE_DIRECTOR_APPOINTMENT_FAILED: { label: 'Update Appointment Failed', icon: 'error_outline', color: '#dc2626' }, REMOVE_DIRECTOR_APPOINTMENT_FAILED: { label: 'Remove Appointment Failed', icon: 'error_outline', color: '#dc2626' }, // Committee CREATE_COMMITTEE: { label: 'Committee Created', icon: 'groups', color: '#16a34a' }, UPDATE_COMMITTEE: { label: 'Committee Updated', icon: 'edit_note', color: '#2563eb' }, DISSOLVE_COMMITTEE: { label: 'Committee Dissolved', icon: 'group_off', color: '#dc2626' }, ADD_COMMITTEE_MEMBER: { label: 'Member Added', icon: 'group_add', color: '#0891b2' }, REMOVE_COMMITTEE_MEMBER: { label: 'Member Removed', icon: 'person_off', color: '#d97706' }, // Committee Meeting SCHEDULE_MEETING: { label: 'Meeting Scheduled', icon: 'event', color: '#16a34a' }, UPDATE_MEETING: { label: 'Meeting Updated', icon: 'edit_calendar', color: '#2563eb' }, CANCEL_MEETING: { label: 'Meeting Cancelled', icon: 'event_busy', color: '#dc2626' }, COMPLETE_MEETING: { label: 'Meeting Completed', icon: 'event_available', color: '#10b981' }, DELETE_MEETING: { label: 'Meeting Deleted', icon: 'delete_outline', color: '#94a3b8' }, SCHEDULE_MEETING_FAILED: { label: 'Schedule Failed', icon: 'error_outline', color: '#dc2626' }, UPDATE_MEETING_FAILED: { label: 'Update Meeting Failed', icon: 'error_outline', color: '#dc2626' } }; @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', 'DIRECTOR_APPOINTMENT', 'COMMITTEE' ]; 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', 'ACTIVATE_ELECTION_FAILED', 'CLOSE_NOMINATIONS_FAILED', 'OPEN_VOTING_FAILED', 'CLOSE_VOTING_FAILED', 'CANCEL_ELECTION_FAILED' ], NOMINATION: [ 'SUBMIT_NOMINATION', 'WITHDRAW_NOMINATION', 'RESUBMIT_NOMINATION', 'ELIGIBILITY_CHECK', 'CEO_PRECHECK', 'APPROVAL_ACTION', 'GENERATE_NO_DUE_CERTIFICATE', 'SUBMIT_NOMINATION_FAILED', 'WITHDRAW_NOMINATION_FAILED', 'ELIGIBILITY_CHECK_FAILED', 'CEO_PRECHECK_FAILED', 'APPROVAL_ACTION_FAILED' ], CANDIDATE: [ 'APPROVE_CANDIDATE', 'REMOVE_CANDIDATE', 'UPLOAD_SYMBOL', 'UPDATE_CANDIDATE', 'APPROVE_CANDIDATE_FAILED', 'REMOVE_CANDIDATE_FAILED' ], VOTE: [ 'CAST_VOTE', 'MANUAL_VOTE_ENTRY', 'MANUAL_VOTE_ENTRY_FAILED' ], RESULT: [ 'DECLARE_RESULTS', 'DECLARE_RESULTS_FAILED' ], DIRECTOR_APPOINTMENT: [ 'APPOINT_DIRECTOR', 'UPDATE_DIRECTOR_APPOINTMENT', 'REMOVE_DIRECTOR_APPOINTMENT', 'APPOINT_DIRECTOR_FAILED', 'UPDATE_DIRECTOR_APPOINTMENT_FAILED', 'REMOVE_DIRECTOR_APPOINTMENT_FAILED' ], COMMITTEE: [ 'CREATE_COMMITTEE', 'UPDATE_COMMITTEE', 'DISSOLVE_COMMITTEE', 'ADD_COMMITTEE_MEMBER', 'REMOVE_COMMITTEE_MEMBER', 'SCHEDULE_MEETING', 'UPDATE_MEETING', 'CANCEL_MEETING', 'COMPLETE_MEETING', 'DELETE_MEETING', 'SCHEDULE_MEETING_FAILED', 'UPDATE_MEETING_FAILED' ] }; 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; } } isArrayDetails(details: string | null): boolean { if (!details) return false; try { return Array.isArray(JSON.parse(details)); } catch { return false; } } voteEntries(details: string | null): any[] { if (!details) return []; try { const arr = JSON.parse(details); return Array.isArray(arr) ? arr : []; } catch { return []; } } hasVoteEdits(details: string | null): boolean { return this.voteEntries(details).some((r) => r.previousVotes !== undefined); } isWinnerArray(details: string | null): boolean { const arr = this.voteEntries(details); return arr.length > 0 && arr[0].winner !== undefined; } detailsEntries(details: string | null): { key: string; label: string; value: string }[] { if (!details) return []; try { const obj = JSON.parse(details); if (Array.isArray(obj)) return []; return Object.entries(obj) .filter( ([ , v ]) => v !== null && v !== undefined && v !== '' ) .map( ([ k, v ]) => ({ key: k, label: DETAIL_LABELS[k] ?? k.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase()), value: typeof v === 'boolean' ? (v ? 'Yes' : 'No') : String(v) }) ); } catch { return []; } } valueBadge(value: string): string { const v = value?.toUpperCase(); if ([ 'PASS', 'YES', 'APPROVE', 'WON', 'ACTIVE', 'VOTING_OPEN' ].includes(v)) return 'badge-pass'; if ([ 'FAIL', 'NO', 'REJECT', 'CANCELLED' ].includes(v)) return 'badge-fail'; if ([ 'NOMINATION_CLOSED', 'VOTING_CLOSED' ].includes(v)) return 'badge-warn'; return ''; } formatValue(entry: { key: string; value: string }): string { const STATUS_LABELS: Record = { ACTIVE: 'Active (Nominations Open)', NOMINATION_CLOSED: 'Nominations Closed', VOTING_OPEN: 'Voting Open', VOTING_CLOSED: 'Voting Closed', CANCELLED: 'Cancelled' }; if (entry.key === 'newStatus' && STATUS_LABELS[entry.value]) { return STATUS_LABELS[entry.value]; } return entry.value; } toggleExpand(id: number): void { this.expandedId = this.expandedId === id ? null : id; } }