Add human-readable reshuffle details to audit log page
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s
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:
@@ -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>
|
||||
{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>
|
||||
{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> : {}
|
||||
|
||||
@@ -360,6 +360,25 @@ export const userRouter = router({
|
||||
return user
|
||||
}),
|
||||
|
||||
/**
|
||||
* Resolve a batch of user IDs to names (admin only).
|
||||
* Returns a map of id → name for displaying in audit logs, etc.
|
||||
*/
|
||||
resolveNames: adminProcedure
|
||||
.input(z.object({ ids: z.array(z.string()).max(50) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (input.ids.length === 0) return {}
|
||||
const users = await ctx.prisma.user.findMany({
|
||||
where: { id: { in: input.ids } },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
const map: Record<string, string> = {}
|
||||
for (const u of users) {
|
||||
map[u.id] = u.name || u.email
|
||||
}
|
||||
return map
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create/invite a new user (admin only)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user