Compare commits
6 Commits
3854b6ff0c
...
5537946b5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5537946b5a | ||
|
|
29502a2b88 | ||
|
|
b901047418 | ||
|
|
22a08ef957 | ||
|
|
158eba416d | ||
|
|
97d7f9b625 |
747
docs/superpowers/plans/2026-04-07-award-ux-fixes.md
Normal file
747
docs/superpowers/plans/2026-04-07-award-ux-fixes.md
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
# 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't have accounts yet. They'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 |
|
||||||
@@ -58,6 +58,7 @@ export default function EditAwardPage({
|
|||||||
const [votingEndAt, setVotingEndAt] = useState('')
|
const [votingEndAt, setVotingEndAt] = useState('')
|
||||||
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
||||||
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
|
||||||
|
const [decisionMode, setDecisionMode] = useState<'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'>('JURY_VOTE')
|
||||||
|
|
||||||
// Helper to format date for datetime-local input
|
// Helper to format date for datetime-local input
|
||||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||||
@@ -80,6 +81,7 @@ export default function EditAwardPage({
|
|||||||
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
||||||
setEvaluationRoundId(award.evaluationRoundId || '')
|
setEvaluationRoundId(award.evaluationRoundId || '')
|
||||||
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
|
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
|
||||||
|
setDecisionMode((award.decisionMode as typeof decisionMode) || 'JURY_VOTE')
|
||||||
}
|
}
|
||||||
}, [award])
|
}, [award])
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ export default function EditAwardPage({
|
|||||||
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
||||||
evaluationRoundId: evaluationRoundId || undefined,
|
evaluationRoundId: evaluationRoundId || undefined,
|
||||||
eligibilityMode,
|
eligibilityMode,
|
||||||
|
decisionMode,
|
||||||
})
|
})
|
||||||
toast.success('Award updated')
|
toast.success('Award updated')
|
||||||
router.push(`/admin/awards/${awardId}`)
|
router.push(`/admin/awards/${awardId}`)
|
||||||
@@ -222,6 +225,23 @@ export default function EditAwardPage({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{scoringMode === 'RANKED' && (
|
{scoringMode === 'RANKED' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
|
import { BulkInviteForm } from '@/components/shared/bulk-invite-form'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { Pagination } from '@/components/shared/pagination'
|
import { Pagination } from '@/components/shared/pagination'
|
||||||
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
|
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
|
||||||
@@ -403,7 +405,7 @@ export default function AwardDetailPage({
|
|||||||
|
|
||||||
// Deferred queries - only load when needed
|
// Deferred queries - only load when needed
|
||||||
const { data: allUsers } = trpc.user.list.useQuery(
|
const { data: allUsers } = trpc.user.list.useQuery(
|
||||||
{ role: 'JURY_MEMBER', page: 1, perPage: 100 },
|
{ page: 1, perPage: 200 },
|
||||||
{ enabled: activeTab === 'jurors' }
|
{ enabled: activeTab === 'jurors' }
|
||||||
)
|
)
|
||||||
const { data: allProjects } = trpc.project.list.useQuery(
|
const { data: allProjects } = trpc.project.list.useQuery(
|
||||||
@@ -484,6 +486,13 @@ export default function AwardDetailPage({
|
|||||||
},
|
},
|
||||||
onError: () => toast.error('Failed to update chair status'),
|
onError: () => toast.error('Failed to update chair status'),
|
||||||
})
|
})
|
||||||
|
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),
|
||||||
|
})
|
||||||
const setWinner = trpc.specialAward.setWinner.useMutation({
|
const setWinner = trpc.specialAward.setWinner.useMutation({
|
||||||
onSuccess: invalidateAward,
|
onSuccess: invalidateAward,
|
||||||
})
|
})
|
||||||
@@ -1328,6 +1337,25 @@ export default function AwardDetailPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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't have accounts yet. They'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>
|
||||||
|
|
||||||
{jurors && jurors.length > 0 ? (
|
{jurors && jurors.length > 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Table>
|
<Table>
|
||||||
|
|||||||
@@ -1787,8 +1787,48 @@ export default function RoundDetailPage() {
|
|||||||
{/* 1. Jury Members & Progress (merged) */}
|
{/* 1. Jury Members & Progress (merged) */}
|
||||||
{!round?.juryGroupId ? (
|
{!round?.juryGroupId ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-8 text-center">
|
<CardHeader>
|
||||||
<p className="text-sm text-muted-foreground">Select a jury group below to get started.</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
131
src/components/shared/bulk-invite-form.tsx
Normal file
131
src/components/shared/bulk-invite-form.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
'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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -166,26 +166,46 @@ export function RequirementUploadSlot({
|
|||||||
requirementId: requirement.id,
|
requirementId: requirement.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Upload file with progress tracking
|
// Upload file with progress tracking and auto-retry
|
||||||
await new Promise<void>((resolve, reject) => {
|
const maxRetries = 3
|
||||||
const xhr = new XMLHttpRequest()
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
xhr.upload.addEventListener('progress', (event) => {
|
try {
|
||||||
if (event.lengthComputable) {
|
await new Promise<void>((resolve, reject) => {
|
||||||
setProgress(Math.round((event.loaded / event.total) * 100))
|
const xhr = new XMLHttpRequest()
|
||||||
}
|
xhr.upload.addEventListener('progress', (event) => {
|
||||||
})
|
if (event.lengthComputable) {
|
||||||
xhr.addEventListener('load', () => {
|
setProgress(Math.round((event.loaded / event.total) * 100))
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
}
|
||||||
resolve()
|
})
|
||||||
|
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
|
||||||
|
toast.info(`Upload interrupted, retrying... (${attempt}/${maxRetries})`)
|
||||||
|
setProgress(0)
|
||||||
|
await new Promise((r) => setTimeout(r, delay))
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`Upload failed with status ${xhr.status}`))
|
throw uploadErr
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
xhr.addEventListener('error', () => reject(new Error('Upload failed')))
|
}
|
||||||
xhr.open('PUT', url)
|
|
||||||
xhr.setRequestHeader('Content-Type', file.type)
|
|
||||||
xhr.send(file)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Save metadata
|
// Save metadata
|
||||||
await saveFileMetadata.mutateAsync({
|
await saveFileMetadata.mutateAsync({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getUserAvatarUrl } from '../utils/avatar-url'
|
|||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
import { processEligibilityJob } from '../services/award-eligibility-job'
|
||||||
import { resolveAwardWinner } from '../services/award-winner-resolver'
|
import { resolveAwardWinner } from '../services/award-winner-resolver'
|
||||||
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
|
import { getAwardSelectionNotificationTemplate, sendJuryInvitationEmail } from '@/lib/email'
|
||||||
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
||||||
import { sendBatchNotifications } from '../services/notification-sender'
|
import { sendBatchNotifications } from '../services/notification-sender'
|
||||||
import type { NotificationItem } from '../services/notification-sender'
|
import type { NotificationItem } from '../services/notification-sender'
|
||||||
@@ -210,6 +210,7 @@ export const specialAwardRouter = router({
|
|||||||
evaluationRoundId: z.string().nullable().optional(),
|
evaluationRoundId: z.string().nullable().optional(),
|
||||||
juryGroupId: z.string().nullable().optional(),
|
juryGroupId: z.string().nullable().optional(),
|
||||||
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
|
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
|
||||||
|
decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).nullable().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -575,6 +576,108 @@ export const specialAwardRouter = router({
|
|||||||
return { added: input.userIds.length }
|
return { added: input.userIds.length }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
let user = await ctx.prisma.user.findUnique({
|
||||||
|
where: { email: invitee.email },
|
||||||
|
select: { id: true, status: true, role: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
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 },
|
||||||
|
})
|
||||||
|
|
||||||
|
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' })
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
// ─── Jury Queries ───────────────────────────────────────────────────────
|
// ─── Jury Queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user