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:
co-authored by
Claude Sonnet 4.6
parent
a915259ba1
commit
4666a476fd
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user