Platform-wide UX fixes: assignment dialog, invalidation, settings, dashboard
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m4s
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:
File diff suppressed because it is too large
Load Diff
@@ -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'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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user