Platform-wide UX fixes: assignment dialog, invalidation, settings, dashboard
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:
parent
b2279067e2
commit
5965f7889d
File diff suppressed because it is too large
Load Diff
|
|
@ -71,6 +71,7 @@ import {
|
||||||
FileText,
|
FileText,
|
||||||
Trophy,
|
Trophy,
|
||||||
Clock,
|
Clock,
|
||||||
|
Upload,
|
||||||
Send,
|
Send,
|
||||||
Download,
|
Download,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -78,7 +79,24 @@ import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
X,
|
X,
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Search,
|
||||||
} from 'lucide-react'
|
} 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 { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
||||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
||||||
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
|
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
|
||||||
|
|
@ -696,7 +714,7 @@ export default function RoundDetailPage() {
|
||||||
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
|
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
|
||||||
{ value: 'jury', label: 'Jury', icon: Users },
|
{ value: 'jury', label: 'Jury', icon: Users },
|
||||||
{ value: 'config', label: 'Config', icon: Settings },
|
{ 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 },
|
{ value: 'awards', label: 'Awards', icon: Trophy },
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
|
|
@ -1476,7 +1494,7 @@ export default function RoundDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Individual Assignments Table */}
|
{/* Individual Assignments Table */}
|
||||||
<IndividualAssignmentsTable roundId={roundId} />
|
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
|
||||||
|
|
||||||
{/* Unassigned Queue */}
|
{/* Unassigned Queue */}
|
||||||
<RoundUnassignedQueue roundId={roundId} />
|
<RoundUnassignedQueue roundId={roundId} />
|
||||||
|
|
@ -1635,6 +1653,12 @@ export default function RoundDetailPage() {
|
||||||
|
|
||||||
{/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */}
|
{/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */}
|
||||||
<TabsContent value="windows" className="space-y-4">
|
<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} />
|
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -1969,10 +1993,18 @@ function ExportEvaluationsDialog({
|
||||||
|
|
||||||
// ── Individual Assignments Table ─────────────────────────────────────────
|
// ── Individual Assignments Table ─────────────────────────────────────────
|
||||||
|
|
||||||
function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
|
function IndividualAssignmentsTable({
|
||||||
|
roundId,
|
||||||
|
projectStates,
|
||||||
|
}: {
|
||||||
|
roundId: string
|
||||||
|
projectStates: any[] | undefined
|
||||||
|
}) {
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
const [newUserId, setNewUserId] = useState('')
|
const [selectedJurorId, setSelectedJurorId] = useState('')
|
||||||
const [newProjectId, setNewProjectId] = useState('')
|
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
|
||||||
|
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
|
||||||
|
const [projectSearch, setProjectSearch] = useState('')
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
|
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
|
||||||
|
|
@ -1980,9 +2012,15 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
|
||||||
{ refetchInterval: 15_000 },
|
{ refetchInterval: 15_000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { data: juryMembers } = trpc.user.getJuryMembers.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ enabled: addDialogOpen },
|
||||||
|
)
|
||||||
|
|
||||||
const deleteMutation = trpc.assignment.delete.useMutation({
|
const deleteMutation = trpc.assignment.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.assignment.listByStage.invalidate({ roundId })
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
toast.success('Assignment removed')
|
toast.success('Assignment removed')
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
|
|
@ -1991,14 +2029,102 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
|
||||||
const createMutation = trpc.assignment.create.useMutation({
|
const createMutation = trpc.assignment.create.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.assignment.listByStage.invalidate({ roundId })
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
utils.user.getJuryMembers.invalidate({ roundId })
|
||||||
toast.success('Assignment created')
|
toast.success('Assignment created')
|
||||||
setAddDialogOpen(false)
|
resetDialog()
|
||||||
setNewUserId('')
|
|
||||||
setNewProjectId('')
|
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -2071,44 +2197,220 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{/* Add Assignment Dialog */}
|
{/* Add Assignment Dialog */}
|
||||||
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
<Dialog open={addDialogOpen} onOpenChange={(open) => {
|
||||||
<DialogContent>
|
if (!open) resetDialog()
|
||||||
|
else setAddDialogOpen(true)
|
||||||
|
}}>
|
||||||
|
<DialogContent className="sm:max-w-[540px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Assignment</DialogTitle>
|
<DialogTitle>Add Assignment</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Manually assign a juror to evaluate a project
|
Select a juror and one or more projects to assign
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Juror Selector */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm">Juror User ID</Label>
|
<Label className="text-sm font-medium">Juror</Label>
|
||||||
<Input
|
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
|
||||||
placeholder="Enter jury member user ID..."
|
<PopoverTrigger asChild>
|
||||||
value={newUserId}
|
<Button
|
||||||
onChange={(e) => setNewUserId(e.target.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
|
{/* Project Multi-Select */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm">Project ID</Label>
|
<div className="flex items-center justify-between">
|
||||||
<Input
|
<Label className="text-sm font-medium">
|
||||||
placeholder="Enter project ID..."
|
Projects
|
||||||
value={newProjectId}
|
{selectedProjectIds.size > 0 && (
|
||||||
onChange={(e) => setNewProjectId(e.target.value)}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={resetDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => createMutation.mutate({
|
onClick={handleCreate}
|
||||||
userId: newUserId,
|
disabled={!selectedJurorId || selectedProjectIds.size === 0 || isMutating}
|
||||||
projectId: newProjectId,
|
|
||||||
roundId,
|
|
||||||
})}
|
|
||||||
disabled={!newUserId || !newProjectId || createMutation.isPending}
|
|
||||||
>
|
>
|
||||||
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
Create Assignment
|
{selectedProjectIds.size <= 1
|
||||||
|
? 'Create Assignment'
|
||||||
|
: `Create ${selectedProjectIds.size} Assignments`
|
||||||
|
}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { SettingsContent } from '@/components/settings/settings-content'
|
import { SettingsContent } from '@/components/settings/settings-content'
|
||||||
|
|
||||||
// Categories that only super admins can access
|
// 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 }) {
|
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
||||||
const settings = await prisma.systemSettings.findMany({
|
const settings = await prisma.systemSettings.findMany({
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ export function AssignmentPreviewSheet({
|
||||||
toast.success(`Created ${result.created} assignments`)
|
toast.success(`Created ${result.created} assignments`)
|
||||||
utils.roundAssignment.coverageReport.invalidate({ roundId })
|
utils.roundAssignment.coverageReport.invalidate({ roundId })
|
||||||
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
||||||
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,8 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
utils.filtering.getResults.invalidate()
|
utils.filtering.getResults.invalidate()
|
||||||
utils.filtering.getResultStats.invalidate({ roundId })
|
utils.filtering.getResultStats.invalidate({ roundId })
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
utils.project.list.invalidate()
|
||||||
toast.success(
|
toast.success(
|
||||||
`Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` +
|
`Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` +
|
||||||
(data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '')
|
(data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '')
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Globe,
|
Globe,
|
||||||
Webhook,
|
Webhook,
|
||||||
|
MessageCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
|
@ -103,8 +104,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
])
|
])
|
||||||
|
|
||||||
const storageSettings = getSettingsByKeys([
|
const storageSettings = getSettingsByKeys([
|
||||||
|
'storage_provider',
|
||||||
|
'local_storage_path',
|
||||||
'max_file_size_mb',
|
'max_file_size_mb',
|
||||||
|
'avatar_max_size_mb',
|
||||||
'allowed_file_types',
|
'allowed_file_types',
|
||||||
|
'allowed_image_types',
|
||||||
])
|
])
|
||||||
|
|
||||||
const securitySettings = getSettingsByKeys([
|
const securitySettings = getSettingsByKeys([
|
||||||
|
|
@ -147,6 +152,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
'anomaly_off_hours_end',
|
'anomaly_off_hours_end',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const whatsappSettings = getSettingsByKeys([
|
||||||
|
'whatsapp_enabled',
|
||||||
|
'whatsapp_provider',
|
||||||
|
])
|
||||||
|
|
||||||
const localizationSettings = getSettingsByKeys([
|
const localizationSettings = getSettingsByKeys([
|
||||||
'localization_enabled_locales',
|
'localization_enabled_locales',
|
||||||
'localization_default_locale',
|
'localization_default_locale',
|
||||||
|
|
@ -183,6 +193,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
<Newspaper className="h-4 w-4" />
|
<Newspaper className="h-4 w-4" />
|
||||||
Digest
|
Digest
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<TabsTrigger value="whatsapp" className="gap-2 shrink-0">
|
||||||
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
WhatsApp
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<TabsTrigger value="security" className="gap-2 shrink-0">
|
<TabsTrigger value="security" className="gap-2 shrink-0">
|
||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
|
|
@ -259,6 +275,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
<Newspaper className="h-4 w-4" />
|
<Newspaper className="h-4 w-4" />
|
||||||
Digest
|
Digest
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<TabsTrigger value="whatsapp" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||||
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
WhatsApp
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -502,6 +524,24 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<TabsContent value="whatsapp" className="space-y-6">
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>WhatsApp Notifications</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure WhatsApp messaging for notifications
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<WhatsAppSettingsSection settings={whatsappSettings} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</div>{/* end content area */}
|
</div>{/* end content area */}
|
||||||
</div>{/* end lg:flex */}
|
</div>{/* end lg:flex */}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
@ -794,6 +834,29 @@ function AuditSettingsSection({ settings }: { settings: Record<string, string> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WhatsAppSettingsSection({ settings }: { settings: Record<string, string> }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingToggle
|
||||||
|
label="Enable WhatsApp Notifications"
|
||||||
|
description="Send notifications via WhatsApp in addition to email"
|
||||||
|
settingKey="whatsapp_enabled"
|
||||||
|
value={settings.whatsapp_enabled || 'false'}
|
||||||
|
/>
|
||||||
|
<SettingSelect
|
||||||
|
label="WhatsApp Provider"
|
||||||
|
description="Select the API provider for sending WhatsApp messages"
|
||||||
|
settingKey="whatsapp_provider"
|
||||||
|
value={settings.whatsapp_provider || 'META'}
|
||||||
|
options={[
|
||||||
|
{ value: 'META', label: 'Meta (WhatsApp Business API)' },
|
||||||
|
{ value: 'TWILIO', label: 'Twilio' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
|
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
|
||||||
const mutation = useSettingsMutation()
|
const mutation = useSettingsMutation()
|
||||||
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')
|
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@ import {
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
// Note: Storage provider cache is cleared server-side when settings are updated
|
// Note: Storage provider cache is cleared server-side when settings are updated
|
||||||
|
|
||||||
|
const COMMON_IMAGE_TYPES = [
|
||||||
|
{ value: 'image/png', label: 'PNG (.png)' },
|
||||||
|
{ value: 'image/jpeg', label: 'JPEG (.jpg, .jpeg)' },
|
||||||
|
{ value: 'image/webp', label: 'WebP (.webp)' },
|
||||||
|
{ value: 'image/gif', label: 'GIF (.gif)' },
|
||||||
|
{ value: 'image/svg+xml', label: 'SVG (.svg)' },
|
||||||
|
]
|
||||||
|
|
||||||
const COMMON_FILE_TYPES = [
|
const COMMON_FILE_TYPES = [
|
||||||
{ value: 'application/pdf', label: 'PDF Documents (.pdf)' },
|
{ value: 'application/pdf', label: 'PDF Documents (.pdf)' },
|
||||||
{ value: 'video/mp4', label: 'MP4 Video (.mp4)' },
|
{ value: 'video/mp4', label: 'MP4 Video (.mp4)' },
|
||||||
|
|
@ -41,6 +49,7 @@ const formSchema = z.object({
|
||||||
max_file_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
|
max_file_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
|
||||||
avatar_max_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
|
avatar_max_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
|
||||||
allowed_file_types: z.array(z.string()).min(1, 'Select at least one file type'),
|
allowed_file_types: z.array(z.string()).min(1, 'Select at least one file type'),
|
||||||
|
allowed_image_types: z.array(z.string()).min(1, 'Select at least one image type'),
|
||||||
})
|
})
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
@ -52,6 +61,7 @@ interface StorageSettingsFormProps {
|
||||||
max_file_size_mb?: string
|
max_file_size_mb?: string
|
||||||
avatar_max_size_mb?: string
|
avatar_max_size_mb?: string
|
||||||
allowed_file_types?: string
|
allowed_file_types?: string
|
||||||
|
allowed_image_types?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +78,16 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
|
||||||
allowedTypes = ['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg']
|
allowedTypes = ['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse allowed image types from JSON string
|
||||||
|
let allowedImageTypes: string[] = []
|
||||||
|
try {
|
||||||
|
allowedImageTypes = settings.allowed_image_types
|
||||||
|
? JSON.parse(settings.allowed_image_types)
|
||||||
|
: ['image/png', 'image/jpeg', 'image/webp']
|
||||||
|
} catch {
|
||||||
|
allowedImageTypes = ['image/png', 'image/jpeg', 'image/webp']
|
||||||
|
}
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|
@ -76,6 +96,7 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
|
||||||
max_file_size_mb: settings.max_file_size_mb || '500',
|
max_file_size_mb: settings.max_file_size_mb || '500',
|
||||||
avatar_max_size_mb: settings.avatar_max_size_mb || '5',
|
avatar_max_size_mb: settings.avatar_max_size_mb || '5',
|
||||||
allowed_file_types: allowedTypes,
|
allowed_file_types: allowedTypes,
|
||||||
|
allowed_image_types: allowedImageTypes,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -99,6 +120,7 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
|
||||||
{ key: 'max_file_size_mb', value: data.max_file_size_mb },
|
{ key: 'max_file_size_mb', value: data.max_file_size_mb },
|
||||||
{ key: 'avatar_max_size_mb', value: data.avatar_max_size_mb },
|
{ key: 'avatar_max_size_mb', value: data.avatar_max_size_mb },
|
||||||
{ key: 'allowed_file_types', value: JSON.stringify(data.allowed_file_types) },
|
{ key: 'allowed_file_types', value: JSON.stringify(data.allowed_file_types) },
|
||||||
|
{ key: 'allowed_image_types', value: JSON.stringify(data.allowed_image_types) },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -255,6 +277,57 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allowed_image_types"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="mb-4">
|
||||||
|
<FormLabel>Allowed Image Types (Avatars/Logos)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Select which image formats can be used for profile pictures and project logos
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{COMMON_IMAGE_TYPES.map((type) => (
|
||||||
|
<FormField
|
||||||
|
key={type.value}
|
||||||
|
control={form.control}
|
||||||
|
name="allowed_image_types"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem
|
||||||
|
key={type.value}
|
||||||
|
className="flex items-start space-x-3 space-y-0"
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value?.includes(type.value)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
return checked
|
||||||
|
? field.onChange([...field.value, type.value])
|
||||||
|
: field.onChange(
|
||||||
|
field.value?.filter(
|
||||||
|
(value) => value !== type.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="cursor-pointer text-sm font-normal">
|
||||||
|
{type.label}
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{storageProvider === 's3' && (
|
{storageProvider === 's3' && (
|
||||||
<div className="rounded-lg border border-muted bg-muted/50 p-4">
|
<div className="rounded-lg border border-muted bg-muted/50 p-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue