Platform-wide UX fixes: assignment dialog, invalidation, settings, dashboard
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m4s

1. Assignment dialog overhaul: replace raw UUID inputs with searchable
   juror Combobox (shows name, email, capacity) and multi-select project
   checklist with bulk assignment support

2. Query invalidation sweep: fix missing invalidations in
   assignment-preview-sheet (roundAssignment.execute) and
   filtering-dashboard (filtering.finalizeResults) so data refreshes
   without page reload

3. Rename Submissions tab to Document Windows with descriptive
   header explaining upload window configuration

4. Connect 6 disconnected settings: storage_provider, local_storage_path,
   avatar_max_size_mb, allowed_image_types, whatsapp_enabled,
   whatsapp_provider - all now accessible in Settings UI

5. Admin dashboard redesign: branded Editorial Command Center with
   Dark Blue gradient header, colored border-l-4 stat cards, staggered
   animations, 2-column layout, action-required panel, activity timeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-16 16:05:25 +01:00
parent b2279067e2
commit 5965f7889d
7 changed files with 1086 additions and 614 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,7 @@ import {
FileText,
Trophy,
Clock,
Upload,
Send,
Download,
Plus,
@@ -78,7 +79,24 @@ import {
ArrowRight,
RotateCcw,
X,
Check,
ChevronsUpDown,
Search,
} from 'lucide-react'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
@@ -696,7 +714,7 @@ export default function RoundDetailPage() {
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
{ value: 'jury', label: 'Jury', icon: Users },
{ value: 'config', label: 'Config', icon: Settings },
{ value: 'windows', label: 'Submissions', icon: Clock },
{ value: 'windows', label: 'Document Windows', icon: Upload },
{ value: 'awards', label: 'Awards', icon: Trophy },
].map((tab) => (
<TabsTrigger
@@ -1476,7 +1494,7 @@ export default function RoundDetailPage() {
</div>
{/* Individual Assignments Table */}
<IndividualAssignmentsTable roundId={roundId} />
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
{/* Unassigned Queue */}
<RoundUnassignedQueue roundId={roundId} />
@@ -1635,6 +1653,12 @@ export default function RoundDetailPage() {
{/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */}
<TabsContent value="windows" className="space-y-4">
<div className="space-y-1 mb-4">
<h3 className="text-lg font-semibold">Document Upload Windows</h3>
<p className="text-sm text-muted-foreground">
Configure when applicants can upload documents for each phase of this round. These windows control the submission periods independently of the round&apos;s active status.
</p>
</div>
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
</TabsContent>
@@ -1969,10 +1993,18 @@ function ExportEvaluationsDialog({
// ── Individual Assignments Table ─────────────────────────────────────────
function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
function IndividualAssignmentsTable({
roundId,
projectStates,
}: {
roundId: string
projectStates: any[] | undefined
}) {
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [newUserId, setNewUserId] = useState('')
const [newProjectId, setNewProjectId] = useState('')
const [selectedJurorId, setSelectedJurorId] = useState('')
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
const [projectSearch, setProjectSearch] = useState('')
const utils = trpc.useUtils()
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
@@ -1980,9 +2012,15 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
{ refetchInterval: 15_000 },
)
const { data: juryMembers } = trpc.user.getJuryMembers.useQuery(
{ roundId },
{ enabled: addDialogOpen },
)
const deleteMutation = trpc.assignment.delete.useMutation({
onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
toast.success('Assignment removed')
},
onError: (err) => toast.error(err.message),
@@ -1991,14 +2029,102 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
const createMutation = trpc.assignment.create.useMutation({
onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.user.getJuryMembers.invalidate({ roundId })
toast.success('Assignment created')
setAddDialogOpen(false)
setNewUserId('')
setNewProjectId('')
resetDialog()
},
onError: (err) => toast.error(err.message),
})
const bulkCreateMutation = trpc.assignment.bulkCreate.useMutation({
onSuccess: (result) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.user.getJuryMembers.invalidate({ roundId })
toast.success(`${result.created} assignment(s) created`)
resetDialog()
},
onError: (err) => toast.error(err.message),
})
const resetDialog = useCallback(() => {
setAddDialogOpen(false)
setSelectedJurorId('')
setSelectedProjectIds(new Set())
setProjectSearch('')
}, [])
const selectedJuror = useMemo(
() => juryMembers?.find((j: any) => j.id === selectedJurorId),
[juryMembers, selectedJurorId],
)
// Filter projects by search term
const filteredProjects = useMemo(() => {
const items = projectStates ?? []
if (!projectSearch) return items
const q = projectSearch.toLowerCase()
return items.filter((ps: any) =>
ps.project?.title?.toLowerCase().includes(q) ||
ps.project?.teamName?.toLowerCase().includes(q) ||
ps.project?.competitionCategory?.toLowerCase().includes(q)
)
}, [projectStates, projectSearch])
// Existing assignments for the selected juror (to grey out already-assigned projects)
const jurorExistingProjectIds = useMemo(() => {
if (!selectedJurorId || !assignments) return new Set<string>()
return new Set(
assignments
.filter((a: any) => a.userId === selectedJurorId)
.map((a: any) => a.projectId)
)
}, [selectedJurorId, assignments])
const toggleProject = useCallback((projectId: string) => {
setSelectedProjectIds(prev => {
const next = new Set(prev)
if (next.has(projectId)) {
next.delete(projectId)
} else {
next.add(projectId)
}
return next
})
}, [])
const selectAllUnassigned = useCallback(() => {
const unassigned = filteredProjects
.filter((ps: any) => !jurorExistingProjectIds.has(ps.project?.id))
.map((ps: any) => ps.project?.id)
.filter(Boolean)
setSelectedProjectIds(new Set(unassigned))
}, [filteredProjects, jurorExistingProjectIds])
const handleCreate = useCallback(() => {
if (!selectedJurorId || selectedProjectIds.size === 0) return
const projectIds = Array.from(selectedProjectIds)
if (projectIds.length === 1) {
createMutation.mutate({
userId: selectedJurorId,
projectId: projectIds[0],
roundId,
})
} else {
bulkCreateMutation.mutate({
roundId,
assignments: projectIds.map(projectId => ({
userId: selectedJurorId,
projectId,
})),
})
}
}, [selectedJurorId, selectedProjectIds, roundId, createMutation, bulkCreateMutation])
const isMutating = createMutation.isPending || bulkCreateMutation.isPending
return (
<Card>
<CardHeader>
@@ -2071,44 +2197,220 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
</CardContent>
{/* Add Assignment Dialog */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent>
<Dialog open={addDialogOpen} onOpenChange={(open) => {
if (!open) resetDialog()
else setAddDialogOpen(true)
}}>
<DialogContent className="sm:max-w-[540px]">
<DialogHeader>
<DialogTitle>Add Assignment</DialogTitle>
<DialogDescription>
Manually assign a juror to evaluate a project
Select a juror and one or more projects to assign
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Juror Selector */}
<div className="space-y-2">
<Label className="text-sm">Juror User ID</Label>
<Input
placeholder="Enter jury member user ID..."
value={newUserId}
onChange={(e) => setNewUserId(e.target.value)}
/>
<Label className="text-sm font-medium">Juror</Label>
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={jurorPopoverOpen}
className="w-full justify-between font-normal"
>
{selectedJuror
? (
<span className="flex items-center gap-2 truncate">
<span className="truncate">{selectedJuror.name || selectedJuror.email}</span>
<Badge variant="secondary" className="text-[10px] shrink-0">
{selectedJuror.currentAssignments}/{selectedJuror.maxAssignments ?? '\u221E'}
</Badge>
</span>
)
: <span className="text-muted-foreground">Select a jury member...</span>
}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[480px] p-0" align="start">
<Command>
<CommandInput placeholder="Search by name or email..." />
<CommandList>
<CommandEmpty>No jury members found.</CommandEmpty>
<CommandGroup>
{juryMembers?.map((juror: any) => {
const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0
return (
<CommandItem
key={juror.id}
value={`${juror.name ?? ''} ${juror.email}`}
disabled={atCapacity}
onSelect={() => {
setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id)
setSelectedProjectIds(new Set())
setJurorPopoverOpen(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedJurorId === juror.id ? 'opacity-100' : 'opacity-0',
)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{juror.name || 'Unnamed'}
</p>
<p className="text-xs text-muted-foreground truncate">
{juror.email}
</p>
</div>
<Badge
variant={atCapacity ? 'destructive' : 'secondary'}
className="text-[10px] ml-2 shrink-0"
>
{juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'}
{atCapacity ? ' full' : ''}
</Badge>
</div>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Project Multi-Select */}
<div className="space-y-2">
<Label className="text-sm">Project ID</Label>
<Input
placeholder="Enter project ID..."
value={newProjectId}
onChange={(e) => setNewProjectId(e.target.value)}
/>
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Projects
{selectedProjectIds.size > 0 && (
<span className="ml-1.5 text-muted-foreground font-normal">
({selectedProjectIds.size} selected)
</span>
)}
</Label>
{selectedJurorId && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={selectAllUnassigned}
>
Select all
</Button>
{selectedProjectIds.size > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setSelectedProjectIds(new Set())}
>
Clear
</Button>
)}
</div>
)}
</div>
{/* Search input */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter projects..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{/* Project checklist */}
<ScrollArea className="h-[240px] rounded-md border">
<div className="p-2 space-y-0.5">
{!selectedJurorId ? (
<p className="text-sm text-muted-foreground text-center py-8">
Select a juror first
</p>
) : filteredProjects.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No projects found
</p>
) : (
filteredProjects.map((ps: any) => {
const project = ps.project
if (!project) return null
const alreadyAssigned = jurorExistingProjectIds.has(project.id)
const isSelected = selectedProjectIds.has(project.id)
return (
<label
key={project.id}
className={cn(
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors',
alreadyAssigned
? 'opacity-50 cursor-not-allowed'
: isSelected
? 'bg-accent'
: 'hover:bg-muted/50',
)}
>
<Checkbox
checked={isSelected}
disabled={alreadyAssigned}
onCheckedChange={() => toggleProject(project.id)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<span className="truncate">{project.title}</span>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
{project.competitionCategory && (
<Badge variant="outline" className="text-[10px]">
{project.competitionCategory === 'STARTUP'
? 'Startup'
: project.competitionCategory === 'BUSINESS_CONCEPT'
? 'Concept'
: project.competitionCategory}
</Badge>
)}
{alreadyAssigned && (
<Badge variant="secondary" className="text-[10px]">
Assigned
</Badge>
)}
</div>
</div>
</label>
)
})
)}
</div>
</ScrollArea>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>Cancel</Button>
<Button variant="outline" onClick={resetDialog}>
Cancel
</Button>
<Button
onClick={() => createMutation.mutate({
userId: newUserId,
projectId: newProjectId,
roundId,
})}
disabled={!newUserId || !newProjectId || createMutation.isPending}
onClick={handleCreate}
disabled={!selectedJurorId || selectedProjectIds.size === 0 || isMutating}
>
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create Assignment
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedProjectIds.size <= 1
? 'Create Assignment'
: `Create ${selectedProjectIds.size} Assignments`
}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -9,7 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { SettingsContent } from '@/components/settings/settings-content'
// Categories that only super admins can access
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY'])
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY', 'WHATSAPP'])
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const settings = await prisma.systemSettings.findMany({