# Award UX Fixes & Bulk Invite — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Fix 5 issues: upload auto-retry for flaky connections, bulk invite for award jurors, jury group selector UX, decision mode setting on award edit page, and award juror role filter. **Architecture:** These are 5 independent fixes touching different files. No schema changes needed — all are UI/UX and router-level changes. The bulk invite is the largest task, requiring a new reusable component, a new tRPC procedure, and integration into the award juror tab. **Tech Stack:** Next.js 15 App Router, tRPC 11, Prisma 6, shadcn/ui, Tailwind CSS 4 --- ## File Map ### New Files | File | Purpose | |------|---------| | `src/components/shared/bulk-invite-form.tsx` | Reusable multi-row name+email invite form component | ### Modified Files | File | Change | |------|--------| | `src/components/shared/requirement-upload-slot.tsx` | Add XHR retry logic (3 attempts, exponential backoff) | | `src/app/(admin)/admin/awards/[id]/page.tsx` | Integrate bulk invite form, widen role filter, add chair toggle | | `src/app/(admin)/admin/rounds/[roundId]/page.tsx` | Move jury group selector above empty-state message | | `src/app/(admin)/admin/awards/[id]/edit/page.tsx` | Add Decision Mode dropdown | | `src/server/routers/specialAward.ts` | Add `bulkInviteJurors` procedure | --- ## Task 1: Upload Auto-Retry **Files:** - Modify: `src/components/shared/requirement-upload-slot.tsx:169-188` - [ ] **Step 1: Replace the single-attempt XHR upload with a retry wrapper** In `src/components/shared/requirement-upload-slot.tsx`, find the XHR upload block (lines 169-188). Replace it with a retry loop. The key change is wrapping the XHR Promise in a function and calling it up to 3 times with exponential backoff. Find this code: ```tsx // Upload file with progress tracking await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { setProgress(Math.round((event.loaded / event.total) * 100)) } }) xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { resolve() } else { reject(new Error(`Upload failed with status ${xhr.status}`)) } }) xhr.addEventListener('error', () => reject(new Error('Upload failed'))) xhr.open('PUT', url) xhr.setRequestHeader('Content-Type', file.type) xhr.send(file) }) ``` Replace with: ```tsx // Upload file with progress tracking and auto-retry const maxRetries = 3 for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { setProgress(Math.round((event.loaded / event.total) * 100)) } }) xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { resolve() } else { reject(new Error(`Upload failed with status ${xhr.status}`)) } }) xhr.addEventListener('error', () => reject(new Error('Network error during upload')) ) xhr.addEventListener('abort', () => reject(new Error('Upload was aborted')) ) xhr.open('PUT', url) xhr.setRequestHeader('Content-Type', file.type) xhr.send(file) }) break // Success — exit retry loop } catch (uploadErr) { if (attempt < maxRetries) { const delay = attempt * 2000 // 2s, 4s toast.info(`Upload interrupted, retrying... (${attempt}/${maxRetries})`) setProgress(0) await new Promise((r) => setTimeout(r, delay)) } else { throw uploadErr // Final attempt failed — propagate to outer catch } } } ``` - [ ] **Step 2: Verify it compiles** ```bash powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'requirement-upload'" ``` Expected: No errors for this file. - [ ] **Step 3: Commit** ```bash git add src/components/shared/requirement-upload-slot.tsx git commit -m "feat: add auto-retry (3 attempts) for file uploads on flaky connections Co-Authored-By: Claude Opus 4.6 (1M context) " ``` --- ## Task 2: Bulk Invite Form Component **Files:** - Create: `src/components/shared/bulk-invite-form.tsx` - [ ] **Step 1: Create the reusable bulk invite component** Create `src/components/shared/bulk-invite-form.tsx`. This is a multi-row form where each row has Name + Email fields, with an "Add Row" button and a "Send Invites" button. ```tsx 'use client' import { useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { toast } from 'sonner' import { Plus, Trash2, Send, Loader2 } from 'lucide-react' interface InviteRow { name: string email: string } interface BulkInviteFormProps { onSubmit: (rows: InviteRow[]) => Promise isPending?: boolean /** Label for the submit button */ submitLabel?: string } const emptyRow = (): InviteRow => ({ name: '', email: '' }) export function BulkInviteForm({ onSubmit, isPending = false, submitLabel = 'Send Invites', }: BulkInviteFormProps) { const [rows, setRows] = useState([emptyRow()]) const updateRow = (index: number, field: keyof InviteRow, value: string) => { setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r))) } const addRow = () => setRows((prev) => [...prev, emptyRow()]) const removeRow = (index: number) => { if (rows.length <= 1) return setRows((prev) => prev.filter((_, i) => i !== index)) } const validRows = rows.filter( (r) => r.email.trim() && r.email.includes('@') ) const handleSubmit = async () => { if (validRows.length === 0) { toast.error('Please enter at least one valid email address') return } // Check for duplicate emails const emails = validRows.map((r) => r.email.trim().toLowerCase()) const dupes = emails.filter((e, i) => emails.indexOf(e) !== i) if (dupes.length > 0) { toast.error(`Duplicate email: ${dupes[0]}`) return } await onSubmit(validRows.map((r) => ({ name: r.name.trim(), email: r.email.trim() }))) setRows([emptyRow()]) } return (
{rows.map((row, index) => (
{index === 0 && ( )} updateRow(index, 'name', e.target.value)} disabled={isPending} />
{index === 0 && ( )} updateRow(index, 'email', e.target.value)} disabled={isPending} />
))}
) } ``` - [ ] **Step 2: Commit** ```bash git add src/components/shared/bulk-invite-form.tsx git commit -m "feat: add reusable BulkInviteForm component for multi-row name+email invites Co-Authored-By: Claude Opus 4.6 (1M context) " ``` --- ## Task 3: Backend — Bulk Invite Jurors Procedure **Files:** - Modify: `src/server/routers/specialAward.ts` — add `bulkInviteJurors` procedure This procedure accepts an array of `{ name, email }`, creates user accounts (or finds existing ones), assigns them the specified role, adds them as `AwardJuror`, and sends invite emails. - [ ] **Step 1: Add the `bulkInviteJurors` procedure** In `src/server/routers/specialAward.ts`, add the following imports near the top (after existing imports): ```typescript import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite' import { sendJuryInvitationEmail } from '@/lib/email' ``` Check if these are already imported — `generateInviteToken` and `getInviteExpiryMs` are already imported (line 9). `sendJuryInvitationEmail` may not be — add it if missing. Then add the procedure after the existing `bulkAddJurors` procedure (after ~line 576): ```typescript /** * Bulk invite new users as award jurors — creates accounts, assigns role, sends invite emails */ bulkInviteJurors: adminProcedure .input( z.object({ awardId: z.string(), role: z.enum(['JURY_MEMBER', 'AWARD_MASTER']).default('AWARD_MASTER'), invitees: z.array( z.object({ name: z.string().optional(), email: z.string().email(), }) ).min(1).max(50), }) ) .mutation(async ({ ctx, input }) => { const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, select: { id: true, name: true }, }) const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = [] for (const invitee of input.invitees) { try { // Check if user already exists let user = await ctx.prisma.user.findUnique({ where: { email: invitee.email }, select: { id: true, status: true, role: true }, }) if (!user) { // Create new user with invite token const inviteToken = generateInviteToken() const expiryMs = await getInviteExpiryMs(ctx.prisma) user = await ctx.prisma.user.create({ data: { email: invitee.email, name: invitee.name || null, role: input.role, status: 'INVITED', inviteToken, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), }, select: { id: true, status: true, role: true }, }) // Send invite email const inviteUrl = `${process.env.NEXTAUTH_URL}/accept-invite?token=${inviteToken}` try { await sendJuryInvitationEmail( invitee.email, invitee.name || null, inviteUrl, award.name ) } catch { // Email failure shouldn't block the invite } results.push({ email: invitee.email, status: 'created' }) } else { results.push({ email: invitee.email, status: 'existing' }) } // Add as award juror (skip if already added) await ctx.prisma.awardJuror.upsert({ where: { awardId_userId: { awardId: input.awardId, userId: user.id }, }, update: {}, create: { awardId: input.awardId, userId: user.id }, }) } catch (err) { results.push({ email: invitee.email, status: 'error', error: err instanceof Error ? err.message : 'Unknown error', }) } } await logAudit({ userId: ctx.user.id, action: 'CREATE', entityType: 'AwardJuror', entityId: input.awardId, detailsJson: { action: 'BULK_INVITE', awardName: award.name, role: input.role, count: input.invitees.length, results, }, }) return { created: results.filter((r) => r.status === 'created').length, existing: results.filter((r) => r.status === 'existing').length, errors: results.filter((r) => r.status === 'error').length, results, } }), ``` - [ ] **Step 2: Check that `sendJuryInvitationEmail` is importable** Verify the import exists in `src/lib/email.ts`: ```bash grep -n "export.*sendJuryInvitationEmail" src/lib/email.ts ``` Expected: Shows the exported function. If it doesn't exist, check for the actual name and adjust the import. - [ ] **Step 3: Verify it compiles** ```bash powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'specialAward'" ``` - [ ] **Step 4: Commit** ```bash git add src/server/routers/specialAward.ts git commit -m "feat: add bulkInviteJurors procedure — creates accounts, assigns role, sends invites Co-Authored-By: Claude Opus 4.6 (1M context) " ``` --- ## Task 4: Integrate Bulk Invite into Award Juror Tab + Widen Role Filter **Files:** - Modify: `src/app/(admin)/admin/awards/[id]/page.tsx` Three changes in this file: 1. Widen the `allUsers` query role filter to include `AWARD_MASTER` 2. Add a collapsible "Invite New Jurors" section with the `BulkInviteForm` 3. Wire up the `bulkInviteJurors` mutation - [ ] **Step 1: Widen the role filter** At line 405-408, change: ```tsx const { data: allUsers } = trpc.user.list.useQuery( { role: 'JURY_MEMBER', page: 1, perPage: 100 }, { enabled: activeTab === 'jurors' } ) ``` to: ```tsx const { data: allUsers } = trpc.user.list.useQuery( { page: 1, perPage: 200 }, { enabled: activeTab === 'jurors' } ) ``` This removes the role filter entirely — `AwardJuror` is role-agnostic, so any user should be selectable. Increase `perPage` to 200 to cover more users. - [ ] **Step 2: Add the bulk invite mutation** Near the other mutations (after `removeJuror` at ~line 479), add: ```tsx const bulkInvite = trpc.specialAward.bulkInviteJurors.useMutation({ onSuccess: (data) => { utils.specialAward.listJurors.invalidate({ awardId }) toast.success(`${data.created} invited, ${data.existing} already existed${data.errors > 0 ? `, ${data.errors} failed` : ''}`) }, onError: (err) => toast.error(err.message), }) ``` - [ ] **Step 3: Add the BulkInviteForm to the juror tab** Add the import at the top of the file: ```tsx import { BulkInviteForm } from '@/components/shared/bulk-invite-form' ``` In the jurors tab (after the existing "Add Juror" button section, around line 1329), add a separator and the bulk invite form: ```tsx

Invite New Jurors by Email

Invite new users who don't have accounts yet. They'll receive an invitation email and be added as jurors for this award.

{ await bulkInvite.mutateAsync({ awardId, role: 'AWARD_MASTER', invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })), }) }} isPending={bulkInvite.isPending} submitLabel="Invite & Add as Jurors" />
``` Make sure `Separator` is imported from `@/components/ui/separator` (check existing imports). - [ ] **Step 4: Verify it compiles** ```bash powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'awards/\[id\]'" ``` - [ ] **Step 5: Commit** ```bash git add src/app/(admin)/admin/awards/[id]/page.tsx git commit -m "feat: add bulk invite form to award juror tab, widen role filter to all users Co-Authored-By: Claude Opus 4.6 (1M context) " ``` --- ## Task 5: Move Jury Group Selector to Top of Assignments Tab **Files:** - Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx:1787-1793, 1987-2060` - [ ] **Step 1: Replace the empty-state placeholder with the jury group selector** In `src/app/(admin)/admin/rounds/[roundId]/page.tsx`, find the "Select a jury group below" placeholder (lines 1787-1793): ```tsx {!round?.juryGroupId ? (

Select a jury group below to get started.

) : ( ``` Replace the Card block inside the `!round?.juryGroupId` branch with the actual jury group selector (same UI that's at the bottom). Change it to: ```tsx {!round?.juryGroupId ? (
Jury Group Select or create a jury group to get started
{juryGroups && juryGroups.length > 0 ? ( ) : (

No jury groups exist yet. Create one to get started.

)}
) : ( ``` This replaces the unhelpful "Select a jury group below" message with the actual selector, right at the top where the user expects it. - [ ] **Step 2: Verify it compiles** ```bash powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'rounds/\[roundId\]'" ``` - [ ] **Step 3: Commit** ```bash git add "src/app/(admin)/admin/rounds/[roundId]/page.tsx" git commit -m "fix: show jury group selector at top of assignments tab when none assigned Co-Authored-By: Claude Opus 4.6 (1M context) " ``` --- ## Task 6: Add Decision Mode Dropdown to Award Edit Page **Files:** - Modify: `src/app/(admin)/admin/awards/[id]/edit/page.tsx` - Modify: `src/server/routers/specialAward.ts` (update mutation input to accept `decisionMode`) - [ ] **Step 1: Verify the `update` procedure accepts `decisionMode`** Check if the `specialAward.update` procedure already accepts `decisionMode` in its input schema. Search for `decisionMode` in the update input: ```bash grep -A5 "decisionMode" src/server/routers/specialAward.ts | head -20 ``` If it's NOT in the update input, add it. In the `update` procedure's `.input()` schema (around line 200-220), add: ```typescript decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).nullable().optional(), ``` And in the mutation data (the `ctx.prisma.specialAward.update` call), include: ```typescript decisionMode: input.decisionMode, ``` - [ ] **Step 2: Add state and initialization for decisionMode on the edit page** In `src/app/(admin)/admin/awards/[id]/edit/page.tsx`, near the other state declarations (around line 54), add: ```tsx const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'>('JURY_VOTE') ``` In the `useEffect` or initialization block where award data populates the form (around line 76), add: ```tsx setDecisionMode((award.decisionMode as typeof decisionMode) || 'JURY_VOTE') ``` In the submit handler (around line 95), include `decisionMode` in the mutation call. - [ ] **Step 3: Add the Decision Mode dropdown to the form** In the grid that contains the Scoring Mode dropdown (around line 199, the `sm:grid-cols-2` div), add a new cell after the Scoring Mode ``: ```tsx
``` - [ ] **Step 4: Verify it compiles** ```bash powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'edit/page'" ``` - [ ] **Step 5: Commit** ```bash git add "src/app/(admin)/admin/awards/[id]/edit/page.tsx" src/server/routers/specialAward.ts git commit -m "feat: add Decision Mode dropdown to award edit page Co-Authored-By: Claude Opus 4.6 (1M context) " ``` --- ## Task 7: Verification - [ ] **Step 1: Run typecheck** ```bash powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit" ``` - [ ] **Step 2: Run tests** ```bash npx vitest run ``` - [ ] **Step 3: Run build** ```bash npm run build ``` - [ ] **Step 4: Manual smoke test checklist** 1. **Upload retry:** Find a large file upload, verify progress shows. Simulate by disconnecting network briefly during upload — should see "retrying" toast, then succeed. 2. **Bulk invite:** Go to Awards → [award] → Jurors tab. See the "Invite New Jurors by Email" section. Add 2 rows with name+email, click "Invite & Add as Jurors". Verify users created and added as jurors. 3. **Jury group selector:** Go to Rounds → any evaluation round with no jury assigned. The jury group dropdown should appear at the top, not buried at the bottom. 4. **Decision Mode:** Go to Awards → [award] → Edit. See the Decision Mode dropdown. Select "Award Master" and save. Verify it persists on reload. 5. **Role filter:** On the award juror tab, the "Select a juror" dropdown should show all users, not just JURY_MEMBER. --- ## Key Design Decisions | Decision | Rationale | |----------|-----------| | Retry with exponential backoff (2s, 4s) | Gives time for transient network issues to resolve without overwhelming the server | | `BulkInviteForm` as shared component | Reusable for jury group invites later, not award-specific | | `bulkInviteJurors` creates accounts + adds jurors in one call | Avoids requiring admin to do two separate steps (create user, then add juror) | | `upsert` for AwardJuror in bulk invite | Safe for re-inviting — won't error if user is already a juror | | Remove role filter entirely from juror dropdown | `AwardJuror` is role-agnostic; any user should be selectable. The old JURY_MEMBER filter was too restrictive. | | Inline jury group selector when empty | Users couldn't find it at the bottom; showing it where the empty state message is matches user expectation |