feat(election): structured audit log details UI

Replaced raw JSON pre dump with readable key-value rows.
PASS/FAIL/WON values show as green badge, FAIL/REJECT/CANCELLED as red.
Manual vote counts shown as table with Candidate/Post/Votes/Previous columns.
Winner results shown as separate table with Post/Winner/Votes.
Added DETAIL_LABELS for 50+ field keys covering all audit action types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-01 15:01:48 +05:30
co-authored by Claude Sonnet 4.6
parent a915259ba1
commit 4666a476fd
3 changed files with 409 additions and 11 deletions
@@ -104,9 +104,76 @@
}
</div>
@if (expandedId === log.id && parsedDetails(log.details)) {
<div class="details-block">
<pre>{{ parsedDetails(log.details) }}</pre>
@if (expandedId === log.id && log.details) {
<div class="details-block" (click)="$event.stopPropagation()">
<span class="details-label">Details</span>
@if (isArrayDetails(log.details)) {
@if (isWinnerArray(log.details)) {
<!-- Winner results array -->
<table class="vote-table">
<thead>
<tr>
<th>Post</th>
<th>Winner</th>
<th>Votes</th>
</tr>
</thead>
<tbody>
@for (row of voteEntries(log.details); track $index) {
<tr>
<td>
<span class="post-chip">{{ row.post }}</span>
</td>
<td class="winner-name">{{ row.winner }}</td>
<td class="vote-count">{{ row.votes }}</td>
</tr>
}
</tbody>
</table>
} @else {
<!-- Manual vote counts array -->
<table class="vote-table">
<thead>
<tr>
<th>Candidate</th>
<th>Post</th>
<th>Votes</th>
@if (hasVoteEdits(log.details)) {
<th>Previous</th>
}
</tr>
</thead>
<tbody>
@for (row of voteEntries(log.details); track $index) {
<tr>
<td>{{ row.candidate }}</td>
<td>
<span class="post-chip">{{ row.post }}</span>
</td>
<td class="vote-count">{{ row.votes }}</td>
@if (hasVoteEdits(log.details)) {
<td class="vote-prev">{{ row.previousVotes ?? '—' }}</td>
}
</tr>
}
</tbody>
</table>
}
} @else if (detailsEntries(log.details).length > 0) {
<!-- Key-value structured display -->
<div class="detail-rows">
@for (entry of detailsEntries(log.details); track entry.key) {
<div class="detail-row">
<span class="detail-key">{{ entry.label }}</span>
<span class="detail-val" [class]="valueBadge(entry.value)">{{ formatValue(entry) }}</span>
</div>
}
</div>
} @else {
<!-- Fallback raw text -->
<pre class="detail-raw">{{ parsedDetails(log.details) }}</pre>
}
</div>
}
</div>
@@ -309,9 +309,136 @@
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
padding: 12px 14px;
pre {
.details-label {
display: block;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: #94a3b8;
margin-bottom: 10px;
}
.detail-rows {
display: flex;
flex-direction: column;
}
.detail-row {
display: flex;
align-items: baseline;
gap: 12px;
padding: 5px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
}
.detail-key {
min-width: 160px;
max-width: 160px;
font-size: 12px;
color: #64748b;
font-weight: 500;
flex-shrink: 0;
}
.detail-val {
font-size: 13px;
color: #1e293b;
word-break: break-word;
&.badge-pass {
display: inline-block;
color: #16a34a;
font-weight: 600;
background: #f0fdf4;
padding: 1px 10px;
border-radius: 20px;
border: 1px solid #bbf7d0;
font-size: 12px;
}
&.badge-fail {
display: inline-block;
color: #dc2626;
font-weight: 600;
background: #fef2f2;
padding: 1px 10px;
border-radius: 20px;
border: 1px solid #fecaca;
font-size: 12px;
}
&.badge-warn {
display: inline-block;
color: #92400e;
font-weight: 600;
background: #fffbeb;
padding: 1px 10px;
border-radius: 20px;
border: 1px solid #fde68a;
font-size: 12px;
}
}
.vote-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
text-align: left;
padding: 6px 10px;
border-bottom: 2px solid #e2e8f0;
color: #64748b;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 7px 10px;
border-bottom: 1px solid #f1f5f9;
color: #1e293b;
}
tr:last-child td {
border-bottom: none;
}
.vote-count {
font-weight: 700;
color: #2563eb;
font-size: 15px;
}
.vote-prev {
color: #94a3b8;
text-decoration: line-through;
}
.winner-name {
font-weight: 600;
color: #16a34a;
}
.post-chip {
background: #eff6ff;
color: #1d4ed8;
border-radius: 4px;
padding: 1px 7px;
font-size: 11px;
font-weight: 600;
}
}
.detail-raw {
margin: 0;
font-size: 12px;
color: #2d3748;
@@ -15,7 +15,76 @@ 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'
};
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' },
@@ -25,18 +94,41 @@ const ACTION_META: Record<string, { label: string; icon: string; color: string }
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' },
DECLARE_RESULTS: { label: 'Results Declared', icon: 'gavel', color: '#7c3aed' }
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' }
};
@Component({
@@ -96,7 +188,12 @@ export class ElectionAuditLogComponent implements OnInit, OnDestroy {
'CLOSE_VOTING',
'CANCEL_ELECTION',
'CREATE_POST',
'UPDATE_POST'
'UPDATE_POST',
'ACTIVATE_ELECTION_FAILED',
'CLOSE_NOMINATIONS_FAILED',
'OPEN_VOTING_FAILED',
'CLOSE_VOTING_FAILED',
'CANCEL_ELECTION_FAILED'
],
NOMINATION: [
'SUBMIT_NOMINATION',
@@ -104,16 +201,31 @@ export class ElectionAuditLogComponent implements OnInit, OnDestroy {
'RESUBMIT_NOMINATION',
'ELIGIBILITY_CHECK',
'CEO_PRECHECK',
'APPROVAL_ACTION'
'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'
'UPDATE_CANDIDATE',
'APPROVE_CANDIDATE_FAILED',
'REMOVE_CANDIDATE_FAILED'
],
VOTE: ['CAST_VOTE'],
RESULT: ['DECLARE_RESULTS']
VOTE: [
'CAST_VOTE',
'MANUAL_VOTE_ENTRY',
'MANUAL_VOTE_ENTRY_FAILED'
],
RESULT: [
'DECLARE_RESULTS',
'DECLARE_RESULTS_FAILED'
]
};
get availableActions(): string[] {
@@ -226,6 +338,98 @@ export class ElectionAuditLogComponent implements OnInit, OnDestroy {
}
}
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;
}