feat: admin UX improvements — notify buttons, eval config, round finalization
Custom body support for advancement/rejection notification emails, evaluation config toggle fix, user actions improvements, round finalization with reorder support, project detail page enhancements, award pool duplicate prevention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -432,6 +432,7 @@ export function MembersContent() {
|
||||
userEmail={user.email}
|
||||
userStatus={user.status}
|
||||
userRole={user.role as RoleValue}
|
||||
userRoles={(user as unknown as { roles?: RoleValue[] }).roles}
|
||||
currentUserRole={currentUserRole}
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -524,6 +525,7 @@ export function MembersContent() {
|
||||
userEmail={user.email}
|
||||
userStatus={user.status}
|
||||
userRole={user.role as RoleValue}
|
||||
userRoles={(user as unknown as { roles?: RoleValue[] }).roles}
|
||||
currentUserRole={currentUserRole}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Trophy } from 'lucide-react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { EmailPreviewDialog } from './email-preview-dialog'
|
||||
|
||||
interface NotifyAdvancedButtonProps {
|
||||
@@ -14,9 +16,10 @@ interface NotifyAdvancedButtonProps {
|
||||
export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [customMessage, setCustomMessage] = useState<string | undefined>()
|
||||
const [fullCustomBody, setFullCustomBody] = useState(false)
|
||||
|
||||
const preview = trpc.round.previewAdvancementEmail.useQuery(
|
||||
{ roundId, targetRoundId, customMessage },
|
||||
{ roundId, targetRoundId, customMessage, fullCustomBody },
|
||||
{ enabled: open }
|
||||
)
|
||||
|
||||
@@ -32,18 +35,31 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
|
||||
>
|
||||
<Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Notify Advanced Teams</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Send advancement emails to passed projects
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
|
||||
>
|
||||
<Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Notify Advanced Teams</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Send advancement emails to passed projects
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Switch
|
||||
id="advancement-full-custom-body"
|
||||
checked={fullCustomBody}
|
||||
onCheckedChange={setFullCustomBody}
|
||||
/>
|
||||
<Label htmlFor="advancement-full-custom-body" className="text-xs cursor-pointer">
|
||||
<span className="font-medium">Full custom body</span>
|
||||
<span className="text-muted-foreground ml-1">— only your message is sent (no standard text)</span>
|
||||
</Label>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EmailPreviewDialog
|
||||
open={open}
|
||||
@@ -53,7 +69,7 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
|
||||
recipientCount={preview.data?.recipientCount ?? 0}
|
||||
previewHtml={preview.data?.html}
|
||||
isPreviewLoading={preview.isLoading}
|
||||
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg })}
|
||||
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg, fullCustomBody })}
|
||||
isSending={sendMutation.isPending}
|
||||
onRefreshPreview={(msg) => setCustomMessage(msg)}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { XCircle } from 'lucide-react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { EmailPreviewDialog } from './email-preview-dialog'
|
||||
|
||||
interface NotifyRejectedButtonProps {
|
||||
@@ -13,9 +15,10 @@ interface NotifyRejectedButtonProps {
|
||||
export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [customMessage, setCustomMessage] = useState<string | undefined>()
|
||||
const [fullCustomBody, setFullCustomBody] = useState(false)
|
||||
|
||||
const preview = trpc.round.previewRejectionEmail.useQuery(
|
||||
{ roundId, customMessage },
|
||||
{ roundId, customMessage, fullCustomBody },
|
||||
{ enabled: open }
|
||||
)
|
||||
|
||||
@@ -31,18 +34,31 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-red-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
|
||||
>
|
||||
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Notify Non-Advanced</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Send rejection emails to non-advanced projects
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-red-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
|
||||
>
|
||||
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Notify Non-Advanced</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Send rejection emails to non-advanced projects
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Switch
|
||||
id="rejection-full-custom-body"
|
||||
checked={fullCustomBody}
|
||||
onCheckedChange={setFullCustomBody}
|
||||
/>
|
||||
<Label htmlFor="rejection-full-custom-body" className="text-xs cursor-pointer">
|
||||
<span className="font-medium">Full custom body</span>
|
||||
<span className="text-muted-foreground ml-1">— only your message is sent (no standard text)</span>
|
||||
</Label>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EmailPreviewDialog
|
||||
open={open}
|
||||
@@ -52,7 +68,7 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
|
||||
recipientCount={preview.data?.recipientCount ?? 0}
|
||||
previewHtml={preview.data?.html}
|
||||
isPreviewLoading={preview.isLoading}
|
||||
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg })}
|
||||
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg, fullCustomBody })}
|
||||
isSending={sendMutation.isPending}
|
||||
onRefreshPreview={(msg) => setCustomMessage(msg)}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,7 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
||||
}
|
||||
|
||||
const visConfig = (config.applicantVisibility as {
|
||||
enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean
|
||||
enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean; hideFromRejected?: boolean
|
||||
}) ?? {}
|
||||
|
||||
const updateVisibility = (key: string, value: unknown) => {
|
||||
@@ -293,6 +293,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="hideFromRejected">Hide from Rejected Applicants</Label>
|
||||
<p className="text-xs text-muted-foreground">Applicants whose project was rejected will not see evaluations from this round</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="hideFromRejected"
|
||||
checked={visConfig.hideFromRejected ?? false}
|
||||
onCheckedChange={(v) => updateVisibility('hideFromRejected', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
|
||||
Evaluations are only visible to applicants after this round closes.
|
||||
</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
Trash2,
|
||||
Loader2,
|
||||
Shield,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
|
||||
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
@@ -50,10 +50,11 @@ interface UserActionsProps {
|
||||
userEmail: string
|
||||
userStatus: string
|
||||
userRole: Role
|
||||
userRoles?: Role[]
|
||||
currentUserRole?: Role
|
||||
}
|
||||
|
||||
export function UserActions({ userId, userEmail, userStatus, userRole, currentUserRole }: UserActionsProps) {
|
||||
export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
|
||||
@@ -64,13 +65,13 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
|
||||
utils.user.list.invalidate()
|
||||
},
|
||||
})
|
||||
const updateUser = trpc.user.update.useMutation({
|
||||
const updateRoles = trpc.user.updateRoles.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.user.list.invalidate()
|
||||
toast.success('Role updated successfully')
|
||||
toast.success('Roles updated successfully')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update role')
|
||||
toast.error(error.message || 'Failed to update roles')
|
||||
},
|
||||
})
|
||||
|
||||
@@ -88,9 +89,20 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
|
||||
// Can this user's role be changed by the current user?
|
||||
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
|
||||
|
||||
const handleRoleChange = (newRole: Role) => {
|
||||
if (newRole === userRole) return
|
||||
updateUser.mutate({ id: userId, role: newRole })
|
||||
// Current roles for this user (array or fallback to single role)
|
||||
const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole]
|
||||
|
||||
const handleToggleRole = (role: Role) => {
|
||||
const has = currentRoles.includes(role)
|
||||
let newRoles: Role[]
|
||||
if (has) {
|
||||
// Don't allow removing the last role
|
||||
if (currentRoles.length <= 1) return
|
||||
newRoles = currentRoles.filter(r => r !== role)
|
||||
} else {
|
||||
newRoles = [...currentRoles, role]
|
||||
}
|
||||
updateRoles.mutate({ userId, roles: newRoles })
|
||||
}
|
||||
|
||||
const handleSendInvitation = async () => {
|
||||
@@ -144,22 +156,20 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
|
||||
</DropdownMenuItem>
|
||||
{canChangeRole && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={updateUser.isPending}>
|
||||
<DropdownMenuSubTrigger disabled={updateRoles.isPending}>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
{updateUser.isPending ? 'Updating...' : 'Change Role'}
|
||||
{updateRoles.isPending ? 'Updating...' : 'Roles'}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{getAvailableRoles().map((role) => (
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuCheckboxItem
|
||||
key={role}
|
||||
onClick={() => handleRoleChange(role)}
|
||||
disabled={role === userRole}
|
||||
checked={currentRoles.includes(role)}
|
||||
onCheckedChange={() => handleToggleRole(role)}
|
||||
disabled={currentRoles.includes(role) && currentRoles.length <= 1}
|
||||
>
|
||||
{role === userRole && <Check className="mr-2 h-4 w-4" />}
|
||||
<span className={role === userRole ? 'font-medium' : role !== userRole ? 'ml-6' : ''}>
|
||||
{ROLE_LABELS[role]}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{ROLE_LABELS[role]}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
@@ -214,6 +224,7 @@ interface UserMobileActionsProps {
|
||||
userEmail: string
|
||||
userStatus: string
|
||||
userRole: Role
|
||||
userRoles?: Role[]
|
||||
currentUserRole?: Role
|
||||
}
|
||||
|
||||
@@ -222,23 +233,25 @@ export function UserMobileActions({
|
||||
userEmail,
|
||||
userStatus,
|
||||
userRole,
|
||||
userRoles,
|
||||
currentUserRole,
|
||||
}: UserMobileActionsProps) {
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const utils = trpc.useUtils()
|
||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||
const updateUser = trpc.user.update.useMutation({
|
||||
const updateRoles = trpc.user.updateRoles.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.user.list.invalidate()
|
||||
toast.success('Role updated successfully')
|
||||
toast.success('Roles updated successfully')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update role')
|
||||
toast.error(error.message || 'Failed to update roles')
|
||||
},
|
||||
})
|
||||
|
||||
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
|
||||
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
|
||||
const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole]
|
||||
|
||||
const handleSendInvitation = async () => {
|
||||
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
|
||||
@@ -283,21 +296,31 @@ export function UserMobileActions({
|
||||
</Button>
|
||||
</div>
|
||||
{canChangeRole && (
|
||||
<select
|
||||
value={userRole}
|
||||
onChange={(e) => updateUser.mutate({ id: userId, role: e.target.value as Role })}
|
||||
disabled={updateUser.isPending}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(isSuperAdmin
|
||||
? (['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
|
||||
: (['JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
|
||||
).map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{ROLE_LABELS[role]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
).map((role) => {
|
||||
const isActive = currentRoles.includes(role)
|
||||
return (
|
||||
<Button
|
||||
key={role}
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
disabled={updateRoles.isPending || (isActive && currentRoles.length <= 1)}
|
||||
onClick={() => {
|
||||
const newRoles = isActive
|
||||
? currentRoles.filter(r => r !== role)
|
||||
: [...currentRoles, role]
|
||||
updateRoles.mutate({ userId, roles: newRoles })
|
||||
}}
|
||||
>
|
||||
{ROLE_LABELS[role]}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user