- 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>
505 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|