444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { use, useState } from 'react'
|
||
|
|
import Link from 'next/link'
|
||
|
|
import { useRouter } from 'next/navigation'
|
||
|
|
import { trpc } from '@/lib/trpc/client'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import {
|
||
|
|
Card,
|
||
|
|
CardContent,
|
||
|
|
CardDescription,
|
||
|
|
CardHeader,
|
||
|
|
CardTitle,
|
||
|
|
} from '@/components/ui/card'
|
||
|
|
import { Badge } from '@/components/ui/badge'
|
||
|
|
import { Input } from '@/components/ui/input'
|
||
|
|
import { Textarea } from '@/components/ui/textarea'
|
||
|
|
import { Label } from '@/components/ui/label'
|
||
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
||
|
|
import { Separator } from '@/components/ui/separator'
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from '@/components/ui/select'
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from '@/components/ui/dialog'
|
||
|
|
import {
|
||
|
|
ArrowLeft,
|
||
|
|
Loader2,
|
||
|
|
Save,
|
||
|
|
Trash2,
|
||
|
|
LayoutTemplate,
|
||
|
|
Plus,
|
||
|
|
X,
|
||
|
|
GripVertical,
|
||
|
|
} from 'lucide-react'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
|
||
|
|
const ROUND_TYPE_LABELS: Record<string, string> = {
|
||
|
|
FILTERING: 'Filtering',
|
||
|
|
EVALUATION: 'Evaluation',
|
||
|
|
LIVE_EVENT: 'Live Event',
|
||
|
|
}
|
||
|
|
|
||
|
|
const CRITERION_TYPES = [
|
||
|
|
{ value: 'numeric', label: 'Numeric (1-10)' },
|
||
|
|
{ value: 'text', label: 'Text' },
|
||
|
|
{ value: 'boolean', label: 'Yes/No' },
|
||
|
|
{ value: 'section_header', label: 'Section Header' },
|
||
|
|
]
|
||
|
|
|
||
|
|
type Criterion = {
|
||
|
|
id: string
|
||
|
|
label: string
|
||
|
|
type: string
|
||
|
|
description?: string
|
||
|
|
weight?: number
|
||
|
|
min?: number
|
||
|
|
max?: number
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function RoundTemplateDetailPage({
|
||
|
|
params,
|
||
|
|
}: {
|
||
|
|
params: Promise<{ id: string }>
|
||
|
|
}) {
|
||
|
|
const { id } = use(params)
|
||
|
|
const router = useRouter()
|
||
|
|
const utils = trpc.useUtils()
|
||
|
|
|
||
|
|
const { data: template, isLoading } = trpc.roundTemplate.getById.useQuery({ id })
|
||
|
|
const [name, setName] = useState('')
|
||
|
|
const [description, setDescription] = useState('')
|
||
|
|
const [roundType, setRoundType] = useState('EVALUATION')
|
||
|
|
const [criteria, setCriteria] = useState<Criterion[]>([])
|
||
|
|
const [initialized, setInitialized] = useState(false)
|
||
|
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||
|
|
|
||
|
|
// Initialize form state from loaded data
|
||
|
|
if (template && !initialized) {
|
||
|
|
setName(template.name)
|
||
|
|
setDescription(template.description || '')
|
||
|
|
setRoundType(template.roundType)
|
||
|
|
setCriteria((template.criteriaJson as Criterion[]) || [])
|
||
|
|
setInitialized(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
const updateTemplate = trpc.roundTemplate.update.useMutation({
|
||
|
|
onSuccess: () => {
|
||
|
|
utils.roundTemplate.getById.invalidate({ id })
|
||
|
|
utils.roundTemplate.list.invalidate()
|
||
|
|
toast.success('Template saved')
|
||
|
|
},
|
||
|
|
onError: (err) => {
|
||
|
|
toast.error(err.message)
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
const deleteTemplate = trpc.roundTemplate.delete.useMutation({
|
||
|
|
onSuccess: () => {
|
||
|
|
utils.roundTemplate.list.invalidate()
|
||
|
|
router.push('/admin/round-templates')
|
||
|
|
toast.success('Template deleted')
|
||
|
|
},
|
||
|
|
onError: (err) => {
|
||
|
|
toast.error(err.message)
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
const handleSave = () => {
|
||
|
|
updateTemplate.mutate({
|
||
|
|
id,
|
||
|
|
name: name.trim(),
|
||
|
|
description: description.trim() || undefined,
|
||
|
|
roundType: roundType as 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT',
|
||
|
|
criteriaJson: criteria,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const addCriterion = () => {
|
||
|
|
setCriteria([
|
||
|
|
...criteria,
|
||
|
|
{
|
||
|
|
id: `criterion_${Date.now()}`,
|
||
|
|
label: '',
|
||
|
|
type: 'numeric',
|
||
|
|
weight: 1,
|
||
|
|
min: 1,
|
||
|
|
max: 10,
|
||
|
|
},
|
||
|
|
])
|
||
|
|
}
|
||
|
|
|
||
|
|
const updateCriterion = (index: number, updates: Partial<Criterion>) => {
|
||
|
|
setCriteria(criteria.map((c, i) => (i === index ? { ...c, ...updates } : c)))
|
||
|
|
}
|
||
|
|
|
||
|
|
const removeCriterion = (index: number) => {
|
||
|
|
setCriteria(criteria.filter((_, i) => i !== index))
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<Skeleton className="h-9 w-36" />
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Skeleton className="h-8 w-64" />
|
||
|
|
<Skeleton className="h-4 w-96" />
|
||
|
|
</div>
|
||
|
|
<Skeleton className="h-64 w-full" />
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!template) {
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<Button variant="ghost" asChild className="-ml-4">
|
||
|
|
<Link href="/admin/round-templates">
|
||
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
|
|
Back to Templates
|
||
|
|
</Link>
|
||
|
|
</Button>
|
||
|
|
<Card>
|
||
|
|
<CardContent className="py-12 text-center">
|
||
|
|
<p className="font-medium">Template not found</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* Header */}
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
<Button variant="ghost" asChild className="-ml-4">
|
||
|
|
<Link href="/admin/round-templates">
|
||
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
|
|
Back to Templates
|
||
|
|
</Link>
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-start justify-between">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<LayoutTemplate className="h-7 w-7 text-primary" />
|
||
|
|
<div>
|
||
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||
|
|
{template.name}
|
||
|
|
</h1>
|
||
|
|
<p className="text-muted-foreground">
|
||
|
|
Edit template configuration and criteria
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => setDeleteOpen(true)}
|
||
|
|
className="text-destructive"
|
||
|
|
>
|
||
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
||
|
|
Delete
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleSave} disabled={updateTemplate.isPending}>
|
||
|
|
{updateTemplate.isPending ? (
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Save className="mr-2 h-4 w-4" />
|
||
|
|
)}
|
||
|
|
Save Changes
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Basic Info */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-lg">Basic Information</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="name">Template Name</Label>
|
||
|
|
<Input
|
||
|
|
id="name"
|
||
|
|
value={name}
|
||
|
|
onChange={(e) => setName(e.target.value)}
|
||
|
|
placeholder="e.g., Standard Evaluation Round"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="description">Description</Label>
|
||
|
|
<Textarea
|
||
|
|
id="description"
|
||
|
|
value={description}
|
||
|
|
onChange={(e) => setDescription(e.target.value)}
|
||
|
|
placeholder="Describe what this template is for..."
|
||
|
|
rows={3}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Round Type</Label>
|
||
|
|
<Select value={roundType} onValueChange={setRoundType}>
|
||
|
|
<SelectTrigger className="w-[240px]">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{Object.entries(ROUND_TYPE_LABELS).map(([value, label]) => (
|
||
|
|
<SelectItem key={value} value={value}>
|
||
|
|
{label}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div className="pt-2 text-sm text-muted-foreground">
|
||
|
|
Created {new Date(template.createdAt).toLocaleDateString()} | Last updated{' '}
|
||
|
|
{new Date(template.updatedAt).toLocaleDateString()}
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Criteria */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<CardTitle className="text-lg">Evaluation Criteria</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Define the criteria jurors will use to evaluate projects
|
||
|
|
</CardDescription>
|
||
|
|
</div>
|
||
|
|
<Button variant="outline" size="sm" onClick={addCriterion}>
|
||
|
|
<Plus className="mr-2 h-4 w-4" />
|
||
|
|
Add Criterion
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{criteria.length === 0 ? (
|
||
|
|
<div className="text-center py-8 text-muted-foreground">
|
||
|
|
<p>No criteria defined yet.</p>
|
||
|
|
<Button variant="outline" className="mt-3" onClick={addCriterion}>
|
||
|
|
<Plus className="mr-2 h-4 w-4" />
|
||
|
|
Add First Criterion
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{criteria.map((criterion, index) => (
|
||
|
|
<div key={criterion.id}>
|
||
|
|
{index > 0 && <Separator className="mb-4" />}
|
||
|
|
<div className="flex items-start gap-3">
|
||
|
|
<div className="mt-2 text-muted-foreground">
|
||
|
|
<GripVertical className="h-5 w-5" />
|
||
|
|
</div>
|
||
|
|
<div className="flex-1 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
|
|
<div className="sm:col-span-2 lg:col-span-2">
|
||
|
|
<Label className="text-xs text-muted-foreground">Label</Label>
|
||
|
|
<Input
|
||
|
|
value={criterion.label}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateCriterion(index, { label: e.target.value })
|
||
|
|
}
|
||
|
|
placeholder="e.g., Innovation"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs text-muted-foreground">Type</Label>
|
||
|
|
<Select
|
||
|
|
value={criterion.type}
|
||
|
|
onValueChange={(val) =>
|
||
|
|
updateCriterion(index, { type: val })
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{CRITERION_TYPES.map((t) => (
|
||
|
|
<SelectItem key={t.value} value={t.value}>
|
||
|
|
{t.label}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
{criterion.type === 'numeric' && (
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs text-muted-foreground">Weight</Label>
|
||
|
|
<Input
|
||
|
|
type="number"
|
||
|
|
min={0}
|
||
|
|
max={10}
|
||
|
|
step={0.1}
|
||
|
|
value={criterion.weight ?? 1}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateCriterion(index, {
|
||
|
|
weight: parseFloat(e.target.value) || 1,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div className="sm:col-span-2 lg:col-span-4">
|
||
|
|
<Label className="text-xs text-muted-foreground">
|
||
|
|
Description (optional)
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
value={criterion.description || ''}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateCriterion(index, { description: e.target.value })
|
||
|
|
}
|
||
|
|
placeholder="Help text for jurors..."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="mt-5 h-8 w-8 text-muted-foreground hover:text-destructive"
|
||
|
|
onClick={() => removeCriterion(index)}
|
||
|
|
>
|
||
|
|
<X className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Template Metadata */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-lg">Template Info</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="grid gap-4 sm:grid-cols-3 text-sm">
|
||
|
|
<div>
|
||
|
|
<p className="text-muted-foreground">Type</p>
|
||
|
|
<Badge variant="secondary" className="mt-1">
|
||
|
|
{ROUND_TYPE_LABELS[template.roundType] || template.roundType}
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="text-muted-foreground">Criteria Count</p>
|
||
|
|
<p className="font-medium mt-1">{criteria.length}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="text-muted-foreground">Has Custom Settings</p>
|
||
|
|
<p className="font-medium mt-1">
|
||
|
|
{template.settingsJson && Object.keys(template.settingsJson as object).length > 0
|
||
|
|
? 'Yes'
|
||
|
|
: 'No'}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Delete Dialog */}
|
||
|
|
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Delete Template</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Are you sure you want to delete "{template.name}"? This action
|
||
|
|
cannot be undone.
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="destructive"
|
||
|
|
onClick={() => deleteTemplate.mutate({ id })}
|
||
|
|
disabled={deleteTemplate.isPending}
|
||
|
|
>
|
||
|
|
{deleteTemplate.isPending && (
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
)}
|
||
|
|
Delete Template
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|