Files
cbsnexora/src/app/election/components/election-audit-log/election-audit-log.component.ts
T
Jaanu423andClaude Sonnet 4.6 e952bc546f feat(election): committee meeting UI — schedule/edit drawer, online/offline mode toggle, soft delete
- committee-board: collapsible meetings section per card, Schedule Meeting button, meeting list with status chips and join links
- Meeting form drawer: ONLINE (platform, link, ID, password) vs OFFLINE (venue, address, city) mode toggle
- Actions: edit/complete/cancel for SCHEDULED; delete (soft) for all statuses on active committees
- election.service.ts: getMeetings, scheduleMeeting, updateMeeting, patchMeetingStatus, deleteMeeting
- election-audit-log: meeting action metas (SCHEDULE_MEETING, UPDATE_MEETING, CANCEL_MEETING, COMPLETE_MEETING, DELETE_MEETING + FAILEDs), COMMITTEE entity action list updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 10:18:00 +05:30

505 lines
16 KiB
TypeScript

/**
* 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<string, string> = {
// 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<string, { label: string; icon: string; color: string }> = {
// 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<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',
'DIRECTOR_APPOINTMENT',
'COMMITTEE'
];
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',
'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<string, string> = {
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;
}
}