# 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<void>((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<void>((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) <noreply@anthropic.com>"
```

---

## 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<void>
  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<InviteRow[]>([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 (
    <div className="space-y-3">
      <div className="space-y-2">
        {rows.map((row, index) => (
          <div key={index} className="flex items-end gap-2">
            <div className="flex-1 space-y-1">
              {index === 0 && (
                <Label className="text-xs text-muted-foreground">Name</Label>
              )}
              <Input
                placeholder="Full name"
                value={row.name}
                onChange={(e) => updateRow(index, 'name', e.target.value)}
                disabled={isPending}
              />
            </div>
            <div className="flex-1 space-y-1">
              {index === 0 && (
                <Label className="text-xs text-muted-foreground">
                  Email <span className="text-destructive">*</span>
                </Label>
              )}
              <Input
                type="email"
                placeholder="email@example.com"
                value={row.email}
                onChange={(e) => updateRow(index, 'email', e.target.value)}
                disabled={isPending}
              />
            </div>
            <Button
              variant="ghost"
              size="icon"
              onClick={() => removeRow(index)}
              disabled={rows.length <= 1 || isPending}
              className="shrink-0"
            >
              <Trash2 className="h-4 w-4" />
            </Button>
          </div>
        ))}
      </div>

      <div className="flex items-center justify-between">
        <Button
          variant="outline"
          size="sm"
          onClick={addRow}
          disabled={isPending}
        >
          <Plus className="mr-1.5 h-4 w-4" />
          Add Row
        </Button>

        <Button
          onClick={handleSubmit}
          disabled={validRows.length === 0 || isPending}
          size="sm"
        >
          {isPending ? (
            <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
          ) : (
            <Send className="mr-1.5 h-4 w-4" />
          )}
          {submitLabel} ({validRows.length})
        </Button>
      </div>
    </div>
  )
}
```

- [ ] **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) <noreply@anthropic.com>"
```

---

## 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) <noreply@anthropic.com>"
```

---

## 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
              <Separator className="my-4" />
              <div className="space-y-2">
                <h3 className="text-sm font-medium">Invite New Jurors by Email</h3>
                <p className="text-xs text-muted-foreground">
                  Invite new users who don&apos;t have accounts yet. They&apos;ll receive an invitation email and be added as jurors for this award.
                </p>
                <BulkInviteForm
                  onSubmit={async (rows) => {
                    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"
                />
              </div>
```

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) <noreply@anthropic.com>"
```

---

## 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 ? (
              <Card>
                <CardContent className="py-8 text-center">
                  <p className="text-sm text-muted-foreground">Select a jury group below to get started.</p>
                </CardContent>
              </Card>
            ) : (
```

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 ? (
              <Card>
                <CardHeader>
                  <div className="flex items-center justify-between">
                    <div>
                      <CardTitle className="text-base">Jury Group</CardTitle>
                      <CardDescription>
                        Select or create a jury group to get started
                      </CardDescription>
                    </div>
                    <Button size="sm" variant="outline" onClick={() => setCreateJuryOpen(true)}>
                      <Plus className="h-4 w-4 mr-1.5" />
                      New Jury
                    </Button>
                  </div>
                </CardHeader>
                <CardContent>
                  {juryGroups && juryGroups.length > 0 ? (
                    <Select
                      value="__none__"
                      onValueChange={(value) => {
                        if (value !== '__none__') {
                          assignJuryMutation.mutate({ id: roundId, juryGroupId: value })
                        }
                      }}
                      disabled={assignJuryMutation.isPending}
                    >
                      <SelectTrigger className="w-full sm:w-80">
                        <SelectValue placeholder="Select jury group..." />
                      </SelectTrigger>
                      <SelectContent>
                        <SelectItem value="__none__">No jury assigned</SelectItem>
                        {juryGroups.map((jg: any) => (
                          <SelectItem key={jg.id} value={jg.id}>
                            {jg.name} ({jg._count?.members ?? 0} members)
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  ) : (
                    <p className="text-sm text-muted-foreground">
                      No jury groups exist yet. Create one to get started.
                    </p>
                  )}
                </CardContent>
              </Card>
            ) : (
```

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) <noreply@anthropic.com>"
```

---

## 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 `</div>`:

```tsx
            <div className="space-y-2">
              <Label htmlFor="decisionMode">Decision Mode</Label>
              <Select
                value={decisionMode}
                onValueChange={(v) => setDecisionMode(v as typeof decisionMode)}
              >
                <SelectTrigger id="decisionMode">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="JURY_VOTE">Jury Vote — tallied from all jurors</SelectItem>
                  <SelectItem value="AWARD_MASTER_DECISION">Award Master — sponsor picks winner</SelectItem>
                  <SelectItem value="ADMIN_DECISION">Admin Decision — admin selects winner</SelectItem>
                </SelectContent>
              </Select>
            </div>
```

- [ ] **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) <noreply@anthropic.com>"
```

---

## 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 |
