feat: add Decision Mode dropdown to award edit page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,7 @@ export default function EditAwardPage({
|
||||
const [votingEndAt, setVotingEndAt] = useState('')
|
||||
const [evaluationRoundId, setEvaluationRoundId] = useState('')
|
||||
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
|
||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||
@@ -80,6 +81,7 @@ export default function EditAwardPage({
|
||||
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
||||
setEvaluationRoundId(award.evaluationRoundId || '')
|
||||
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
|
||||
setDecisionMode((award.decisionMode as typeof decisionMode) || 'JURY_VOTE')
|
||||
}
|
||||
}, [award])
|
||||
|
||||
@@ -98,6 +100,7 @@ export default function EditAwardPage({
|
||||
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
||||
evaluationRoundId: evaluationRoundId || undefined,
|
||||
eligibilityMode,
|
||||
decisionMode,
|
||||
})
|
||||
toast.success('Award updated')
|
||||
router.push(`/admin/awards/${awardId}`)
|
||||
@@ -222,6 +225,23 @@ export default function EditAwardPage({
|
||||
</Select>
|
||||
</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' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getUserAvatarUrl } from '../utils/avatar-url'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
||||
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 { sendBatchNotifications } from '../services/notification-sender'
|
||||
import type { NotificationItem } from '../services/notification-sender'
|
||||
@@ -210,6 +210,7 @@ export const specialAwardRouter = router({
|
||||
evaluationRoundId: z.string().nullable().optional(),
|
||||
juryGroupId: z.string().nullable().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 }) => {
|
||||
@@ -575,6 +576,108 @@ export const specialAwardRouter = router({
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user