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,
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export function AssignmentPreviewSheet({
|
|||
toast.success(`Created ${result.created} assignments`)
|
||||
utils.roundAssignment.coverageReport.invalidate({ roundId })
|
||||
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (err) => {
|
||||
|
|
|
|||
|
|
@ -198,6 +198,8 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||
onSuccess: (data) => {
|
||||
utils.filtering.getResults.invalidate()
|
||||
utils.filtering.getResultStats.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
utils.project.list.invalidate()
|
||||
toast.success(
|
||||
`Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` +
|
||||
(data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '')
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
ShieldAlert,
|
||||
Globe,
|
||||
Webhook,
|
||||
MessageCircle,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
|
@ -103,8 +104,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
])
|
||||
|
||||
const storageSettings = getSettingsByKeys([
|
||||
'storage_provider',
|
||||
'local_storage_path',
|
||||
'max_file_size_mb',
|
||||
'avatar_max_size_mb',
|
||||
'allowed_file_types',
|
||||
'allowed_image_types',
|
||||
])
|
||||
|
||||
const securitySettings = getSettingsByKeys([
|
||||
|
|
@ -147,6 +152,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
'anomaly_off_hours_end',
|
||||
])
|
||||
|
||||
const whatsappSettings = getSettingsByKeys([
|
||||
'whatsapp_enabled',
|
||||
'whatsapp_provider',
|
||||
])
|
||||
|
||||
const localizationSettings = getSettingsByKeys([
|
||||
'localization_enabled_locales',
|
||||
'localization_default_locale',
|
||||
|
|
@ -183,6 +193,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<Newspaper className="h-4 w-4" />
|
||||
Digest
|
||||
</TabsTrigger>
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="whatsapp" className="gap-2 shrink-0">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="security" className="gap-2 shrink-0">
|
||||
<Shield className="h-4 w-4" />
|
||||
|
|
@ -259,6 +275,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<Newspaper className="h-4 w-4" />
|
||||
Digest
|
||||
</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>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -502,6 +524,24 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
</Card>
|
||||
</AnimatedCard>
|
||||
</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 lg:flex */}
|
||||
</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> }) {
|
||||
const mutation = useSettingsMutation()
|
||||
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ import {
|
|||
} from '@/components/ui/form'
|
||||
// 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 = [
|
||||
{ value: 'application/pdf', label: 'PDF Documents (.pdf)' },
|
||||
{ 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'),
|
||||
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_image_types: z.array(z.string()).min(1, 'Select at least one image type'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
|
|
@ -52,6 +61,7 @@ interface StorageSettingsFormProps {
|
|||
max_file_size_mb?: string
|
||||
avatar_max_size_mb?: 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']
|
||||
}
|
||||
|
||||
// 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>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
|
|
@ -76,6 +96,7 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
|
|||
max_file_size_mb: settings.max_file_size_mb || '500',
|
||||
avatar_max_size_mb: settings.avatar_max_size_mb || '5',
|
||||
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: 'avatar_max_size_mb', value: data.avatar_max_size_mb },
|
||||
{ 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' && (
|
||||
<div className="rounded-lg border border-muted bg-muted/50 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
|
|||
Loading…
Reference in New Issue