Add human-readable reshuffle details to audit log page
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s

Audit log now renders JUROR_DROPOUT_RESHUFFLE and COI_REASSIGNMENT
entries as formatted tables with resolved juror names instead of raw
JSON with opaque IDs. Uses new user.resolveNames endpoint to batch-
lookup user IDs. Also adds missing action types to the filter dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-20 14:23:10 +01:00
parent 0d0571ebf2
commit 34fdd0ba8e
2 changed files with 170 additions and 6 deletions

View File

@@ -83,6 +83,11 @@ const ACTION_TYPES = [
'ROLE_CHANGED',
'PASSWORD_SET',
'PASSWORD_CHANGED',
'JUROR_DROPOUT_RESHUFFLE',
'COI_REASSIGNMENT',
'APPLY_AI_SUGGESTIONS',
'APPLY_SUGGESTIONS',
'NOTIFY_JURORS_OF_ASSIGNMENTS',
]
// Entity type options
@@ -118,6 +123,11 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
ROLE_CHANGED: 'secondary',
PASSWORD_SET: 'outline',
PASSWORD_CHANGED: 'outline',
JUROR_DROPOUT_RESHUFFLE: 'destructive',
COI_REASSIGNMENT: 'secondary',
APPLY_AI_SUGGESTIONS: 'default',
APPLY_SUGGESTIONS: 'default',
NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline',
}
export default function AuditLogPage() {
@@ -516,9 +526,15 @@ export default function AuditLogPage() {
<p className="text-xs font-medium text-muted-foreground mb-1">
Details
</p>
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)}
</pre>
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
) : log.action === 'COI_REASSIGNMENT' ? (
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
) : (
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)}
</pre>
)}
</div>
)}
{!!(log as Record<string, unknown>).previousDataJson && (
@@ -622,9 +638,15 @@ export default function AuditLogPage() {
<p className="text-xs font-medium text-muted-foreground mb-1">
Details
</p>
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)}
</pre>
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
) : log.action === 'COI_REASSIGNMENT' ? (
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
) : (
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)}
</pre>
)}
</div>
)}
</div>
@@ -693,6 +715,129 @@ export default function AuditLogPage() {
)
}
function ReshuffleDetailView({ details }: { details: Record<string, unknown> }) {
const reassignedTo = (details.reassignedTo ?? {}) as Record<string, number>
const jurorIds = Object.keys(reassignedTo)
const moves = (details.moves ?? []) as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]
// Resolve juror IDs to names
const { data: nameMap } = trpc.user.resolveNames.useQuery(
{ ids: [...jurorIds, details.droppedJurorId as string].filter(Boolean) },
{ enabled: jurorIds.length > 0 },
)
const droppedName = (details.droppedJurorName as string) || (nameMap && details.droppedJurorId ? nameMap[details.droppedJurorId as string] : null) || (details.droppedJurorId as string)
return (
<div className="rounded-lg border bg-white overflow-hidden text-sm">
{/* Summary */}
<div className="p-3 bg-muted/50 border-b space-y-1">
<div className="flex items-center gap-2">
<Badge variant="destructive">Juror Dropout</Badge>
<span className="font-semibold">{droppedName}</span>
</div>
<p className="text-xs text-muted-foreground">
{String(details.movedCount)} project(s) reassigned, {String(details.failedCount)} failed
{details.removedFromGroup ? ' — removed from jury group' : ''}
</p>
</div>
{/* Per-project moves (new format) */}
{moves.length > 0 && (
<div className="p-3">
<p className="text-xs font-medium text-muted-foreground mb-2">Project New Juror</p>
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left py-1 font-medium">Project</th>
<th className="text-left py-1 font-medium">Reassigned To</th>
</tr>
</thead>
<tbody>
{moves.map((move, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1.5 pr-2">{move.projectTitle}</td>
<td className="py-1.5 font-medium">{move.newJurorName}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Fallback: count-based view (old format, no per-project detail) */}
{moves.length === 0 && jurorIds.length > 0 && (
<div className="p-3">
<p className="text-xs font-medium text-muted-foreground mb-2">Reassignment Summary (project detail not available)</p>
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left py-1 font-medium">Juror</th>
<th className="text-right py-1 font-medium">Projects Received</th>
</tr>
</thead>
<tbody>
{jurorIds.map((id) => (
<tr key={id} className="border-b last:border-0">
<td className="py-1.5">{nameMap?.[id] || id}</td>
<td className="py-1.5 text-right font-medium">{reassignedTo[id]}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Failed projects */}
{Array.isArray(details.failedProjects) && (details.failedProjects as string[]).length > 0 && (
<div className="p-3 border-t bg-red-50/50">
<p className="text-xs font-medium text-red-700 mb-1">Could not reassign:</p>
<ul className="text-xs text-muted-foreground list-disc list-inside">
{(details.failedProjects as string[]).map((p, i) => (
<li key={i}>{p}</li>
))}
</ul>
</div>
)}
</div>
)
}
function COIReassignmentDetailView({ details }: { details: Record<string, unknown> }) {
const ids = [details.oldJurorId, details.newJurorId].filter(Boolean) as string[]
const { data: nameMap } = trpc.user.resolveNames.useQuery(
{ ids },
{ enabled: ids.length > 0 },
)
const oldName = nameMap?.[details.oldJurorId as string] || (details.oldJurorId as string)
const newName = nameMap?.[details.newJurorId as string] || (details.newJurorId as string)
return (
<div className="rounded-lg border bg-white overflow-hidden text-sm">
<div className="p-3 space-y-2">
<div className="flex items-center gap-2">
<Badge variant="secondary">COI Reassignment</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<p className="text-muted-foreground">From</p>
<p className="font-medium">{oldName}</p>
</div>
<div>
<p className="text-muted-foreground">To</p>
<p className="font-medium">{newName}</p>
</div>
</div>
<div className="text-xs text-muted-foreground">
Project: <span className="font-mono">{(details.projectId as string)?.slice(0, 12)}...</span>
{' | '}Round: <span className="font-mono">{(details.roundId as string)?.slice(0, 12)}...</span>
</div>
</div>
</div>
)
}
function DiffViewer({ before, after }: { before: unknown; after: unknown }) {
const beforeObj = typeof before === 'object' && before !== null ? before as Record<string, unknown> : {}
const afterObj = typeof after === 'object' && after !== null ? after as Record<string, unknown> : {}