diff --git a/src/app/(admin)/admin/audit/page.tsx b/src/app/(admin)/admin/audit/page.tsx
index df1cc8c..a08009e 100644
--- a/src/app/(admin)/admin/audit/page.tsx
+++ b/src/app/(admin)/admin/audit/page.tsx
@@ -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
Details
-
- {JSON.stringify(log.detailsJson, null, 2)}
-
+ {log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
+ } />
+ ) : log.action === 'COI_REASSIGNMENT' ? (
+ } />
+ ) : (
+
+ {JSON.stringify(log.detailsJson, null, 2)}
+
+ )}
)}
{!!(log as Record).previousDataJson && (
@@ -622,9 +638,15 @@ export default function AuditLogPage() {
Details
-
- {JSON.stringify(log.detailsJson, null, 2)}
-
+ {log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
+ } />
+ ) : log.action === 'COI_REASSIGNMENT' ? (
+ } />
+ ) : (
+
+ {JSON.stringify(log.detailsJson, null, 2)}
+
+ )}
)}
@@ -693,6 +715,129 @@ export default function AuditLogPage() {
)
}
+function ReshuffleDetailView({ details }: { details: Record }) {
+ const reassignedTo = (details.reassignedTo ?? {}) as Record
+ 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 (
+
+ {/* Summary */}
+
+
+ Juror Dropout
+ {droppedName}
+
+
+ {String(details.movedCount)} project(s) reassigned, {String(details.failedCount)} failed
+ {details.removedFromGroup ? ' — removed from jury group' : ''}
+
+
+
+ {/* Per-project moves (new format) */}
+ {moves.length > 0 && (
+
+
Project → New Juror
+
+
+
+ | Project |
+ Reassigned To |
+
+
+
+ {moves.map((move, i) => (
+
+ | {move.projectTitle} |
+ {move.newJurorName} |
+
+ ))}
+
+
+
+ )}
+
+ {/* Fallback: count-based view (old format, no per-project detail) */}
+ {moves.length === 0 && jurorIds.length > 0 && (
+
+
Reassignment Summary (project detail not available)
+
+
+
+ | Juror |
+ Projects Received |
+
+
+
+ {jurorIds.map((id) => (
+
+ | {nameMap?.[id] || id} |
+ {reassignedTo[id]} |
+
+ ))}
+
+
+
+ )}
+
+ {/* Failed projects */}
+ {Array.isArray(details.failedProjects) && (details.failedProjects as string[]).length > 0 && (
+
+
Could not reassign:
+
+ {(details.failedProjects as string[]).map((p, i) => (
+ - {p}
+ ))}
+
+
+ )}
+
+ )
+}
+
+function COIReassignmentDetailView({ details }: { details: Record }) {
+ 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 (
+
+
+
+ COI Reassignment
+
+
+
+ Project: {(details.projectId as string)?.slice(0, 12)}...
+ {' | '}Round: {(details.roundId as string)?.slice(0, 12)}...
+
+
+
+ )
+}
+
function DiffViewer({ before, after }: { before: unknown; after: unknown }) {
const beforeObj = typeof before === 'object' && before !== null ? before as Record : {}
const afterObj = typeof after === 'object' && after !== null ? after as Record : {}
diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts
index 51f3189..6ce205d 100644
--- a/src/server/routers/user.ts
+++ b/src/server/routers/user.ts
@@ -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 = {}
+ for (const u of users) {
+ map[u.id] = u.name || u.email
+ }
+ return map
+ }),
+
/**
* Create/invite a new user (admin only)
*/