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 [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>
|
||||||
|
|||||||
@@ -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