Files
MOPC-Portal/docs/superpowers/plans/2026-04-07-award-ux-fixes.md
2026-04-07 19:36:20 -04:00

25 KiB

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:

        // 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:

        // 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
powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'requirement-upload'"

Expected: No errors for this file.

  • Step 3: Commit
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.

'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
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):

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):

  /**
   * 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:

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
powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'specialAward'"
  • Step 4: Commit
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:

const { data: allUsers } = trpc.user.list.useQuery(
  { role: 'JURY_MEMBER', page: 1, perPage: 100 },
  { enabled: activeTab === 'jurors' }
)

to:

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:

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:

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:

              <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
powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'awards/\[id\]'"
  • Step 5: Commit
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):

            {!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:

            {!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
powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'rounds/\[roundId\]'"
  • Step 3: Commit
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:

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:

decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).nullable().optional(),

And in the mutation data (the ctx.prisma.specialAward.update call), include:

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:

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:

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

            <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
powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit 2>&1 | Select-String 'edit/page'"
  • Step 5: Commit
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
powershell -ExecutionPolicy Bypass -Command "npx tsc --noEmit"
  • Step 2: Run tests
npx vitest run
  • Step 3: Run build
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