Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,291 +1,291 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect } 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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function EditAwardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: awardId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
|
||||
const updateAward = trpc.specialAward.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.get.invalidate({ id: awardId })
|
||||
utils.specialAward.list.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [criteriaText, setCriteriaText] = useState('')
|
||||
const [scoringMode, setScoringMode] = useState<'PICK_WINNER' | 'RANKED' | 'SCORED'>('PICK_WINNER')
|
||||
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
||||
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
||||
const [votingStartAt, setVotingStartAt] = useState('')
|
||||
const [votingEndAt, setVotingEndAt] = useState('')
|
||||
|
||||
// Helper to format date for datetime-local input
|
||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
// Format: YYYY-MM-DDTHH:mm
|
||||
return d.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
// Load existing values when award data arrives
|
||||
useEffect(() => {
|
||||
if (award) {
|
||||
setName(award.name)
|
||||
setDescription(award.description || '')
|
||||
setCriteriaText(award.criteriaText || '')
|
||||
setScoringMode(award.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
||||
setUseAiEligibility(award.useAiEligibility)
|
||||
setMaxRankedPicks(String(award.maxRankedPicks || 3))
|
||||
setVotingStartAt(formatDateForInput(award.votingStartAt))
|
||||
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
||||
}
|
||||
}, [award])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) return
|
||||
try {
|
||||
await updateAward.mutateAsync({
|
||||
id: awardId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
criteriaText: criteriaText.trim() || undefined,
|
||||
useAiEligibility,
|
||||
scoringMode,
|
||||
maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
||||
votingStartAt: votingStartAt ? new Date(votingStartAt) : undefined,
|
||||
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
||||
})
|
||||
toast.success('Award updated')
|
||||
router.push(`/admin/awards/${awardId}`)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to update award'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-[400px] w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!award) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/awards/${awardId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Award
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Edit Award
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update award settings and eligibility criteria
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Award Details</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the award name, criteria, and scoring mode
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Award Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this award"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
||||
<Textarea
|
||||
id="criteria"
|
||||
value={criteriaText}
|
||||
onChange={(e) => setCriteriaText(e.target.value)}
|
||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This text will be used by AI to determine which projects are
|
||||
eligible for this award.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="ai-toggle">AI Eligibility</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use AI to automatically evaluate project eligibility based on the criteria above.
|
||||
Turn off for awards decided by feeling or manual selection.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="ai-toggle"
|
||||
checked={useAiEligibility}
|
||||
onCheckedChange={setUseAiEligibility}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoring">Scoring Mode</Label>
|
||||
<Select
|
||||
value={scoringMode}
|
||||
onValueChange={(v) =>
|
||||
setScoringMode(v as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="scoring">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">
|
||||
Pick Winner — Each juror picks 1
|
||||
</SelectItem>
|
||||
<SelectItem value="RANKED">
|
||||
Ranked — Each juror ranks top N
|
||||
</SelectItem>
|
||||
<SelectItem value="SCORED">
|
||||
Scored — Use evaluation form
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{scoringMode === 'RANKED' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||
<Input
|
||||
id="maxPicks"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={maxRankedPicks}
|
||||
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Voting Window Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Voting Window</CardTitle>
|
||||
<CardDescription>
|
||||
Set the time period during which jurors can submit their votes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="votingStart">Voting Opens</Label>
|
||||
<Input
|
||||
id="votingStart"
|
||||
type="datetime-local"
|
||||
value={votingStartAt}
|
||||
onChange={(e) => setVotingStartAt(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When jurors can start voting (leave empty to set when opening voting)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="votingEnd">Voting Closes</Label>
|
||||
<Input
|
||||
id="votingEnd"
|
||||
type="datetime-local"
|
||||
value={votingEndAt}
|
||||
onChange={(e) => setVotingEndAt(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deadline for juror votes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/awards/${awardId}`}>Cancel</Link>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={updateAward.isPending || !name.trim()}
|
||||
>
|
||||
{updateAward.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect } 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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function EditAwardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: awardId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
|
||||
const updateAward = trpc.specialAward.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.get.invalidate({ id: awardId })
|
||||
utils.specialAward.list.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [criteriaText, setCriteriaText] = useState('')
|
||||
const [scoringMode, setScoringMode] = useState<'PICK_WINNER' | 'RANKED' | 'SCORED'>('PICK_WINNER')
|
||||
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
||||
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
||||
const [votingStartAt, setVotingStartAt] = useState('')
|
||||
const [votingEndAt, setVotingEndAt] = useState('')
|
||||
|
||||
// Helper to format date for datetime-local input
|
||||
const formatDateForInput = (date: Date | string | null | undefined): string => {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
// Format: YYYY-MM-DDTHH:mm
|
||||
return d.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
// Load existing values when award data arrives
|
||||
useEffect(() => {
|
||||
if (award) {
|
||||
setName(award.name)
|
||||
setDescription(award.description || '')
|
||||
setCriteriaText(award.criteriaText || '')
|
||||
setScoringMode(award.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
||||
setUseAiEligibility(award.useAiEligibility)
|
||||
setMaxRankedPicks(String(award.maxRankedPicks || 3))
|
||||
setVotingStartAt(formatDateForInput(award.votingStartAt))
|
||||
setVotingEndAt(formatDateForInput(award.votingEndAt))
|
||||
}
|
||||
}, [award])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) return
|
||||
try {
|
||||
await updateAward.mutateAsync({
|
||||
id: awardId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
criteriaText: criteriaText.trim() || undefined,
|
||||
useAiEligibility,
|
||||
scoringMode,
|
||||
maxRankedPicks: scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
||||
votingStartAt: votingStartAt ? new Date(votingStartAt) : undefined,
|
||||
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
|
||||
})
|
||||
toast.success('Award updated')
|
||||
router.push(`/admin/awards/${awardId}`)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to update award'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-[400px] w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!award) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/awards/${awardId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Award
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Edit Award
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update award settings and eligibility criteria
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Award Details</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the award name, criteria, and scoring mode
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Award Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this award"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
||||
<Textarea
|
||||
id="criteria"
|
||||
value={criteriaText}
|
||||
onChange={(e) => setCriteriaText(e.target.value)}
|
||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This text will be used by AI to determine which projects are
|
||||
eligible for this award.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="ai-toggle">AI Eligibility</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use AI to automatically evaluate project eligibility based on the criteria above.
|
||||
Turn off for awards decided by feeling or manual selection.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="ai-toggle"
|
||||
checked={useAiEligibility}
|
||||
onCheckedChange={setUseAiEligibility}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoring">Scoring Mode</Label>
|
||||
<Select
|
||||
value={scoringMode}
|
||||
onValueChange={(v) =>
|
||||
setScoringMode(v as 'PICK_WINNER' | 'RANKED' | 'SCORED')
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="scoring">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">
|
||||
Pick Winner — Each juror picks 1
|
||||
</SelectItem>
|
||||
<SelectItem value="RANKED">
|
||||
Ranked — Each juror ranks top N
|
||||
</SelectItem>
|
||||
<SelectItem value="SCORED">
|
||||
Scored — Use evaluation form
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{scoringMode === 'RANKED' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||
<Input
|
||||
id="maxPicks"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={maxRankedPicks}
|
||||
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Voting Window Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Voting Window</CardTitle>
|
||||
<CardDescription>
|
||||
Set the time period during which jurors can submit their votes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="votingStart">Voting Opens</Label>
|
||||
<Input
|
||||
id="votingStart"
|
||||
type="datetime-local"
|
||||
value={votingStartAt}
|
||||
onChange={(e) => setVotingStartAt(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When jurors can start voting (leave empty to set when opening voting)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="votingEnd">Voting Closes</Label>
|
||||
<Input
|
||||
id="votingEnd"
|
||||
type="datetime-local"
|
||||
value={votingEndAt}
|
||||
onChange={(e) => setVotingEndAt(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deadline for juror votes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/awards/${awardId}`}>Cancel</Link>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={updateAward.isPending || !name.trim()}
|
||||
>
|
||||
{updateAward.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,227 +1,227 @@
|
||||
'use client'
|
||||
|
||||
import { 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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function CreateAwardPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [criteriaText, setCriteriaText] = useState('')
|
||||
const [scoringMode, setScoringMode] = useState<
|
||||
'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
>('PICK_WINNER')
|
||||
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
||||
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
||||
const [programId, setProgramId] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: programs } = trpc.program.list.useQuery()
|
||||
const createAward = trpc.specialAward.create.useMutation({
|
||||
onSuccess: () => utils.specialAward.list.invalidate(),
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !programId) return
|
||||
try {
|
||||
const award = await createAward.mutateAsync({
|
||||
programId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
criteriaText: criteriaText.trim() || undefined,
|
||||
useAiEligibility,
|
||||
scoringMode,
|
||||
maxRankedPicks:
|
||||
scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
||||
})
|
||||
toast.success('Award created')
|
||||
router.push(`/admin/awards/${award.id}`)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to create award'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/awards">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Create Special Award
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Define a new award with eligibility criteria and voting rules
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Award Details</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the award name, criteria, and scoring mode
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="program">Edition</Label>
|
||||
<Select value={programId} onValueChange={setProgramId}>
|
||||
<SelectTrigger id="program">
|
||||
<SelectValue placeholder="Select an edition" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.year} Edition
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Award Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this award"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
||||
<Textarea
|
||||
id="criteria"
|
||||
value={criteriaText}
|
||||
onChange={(e) => setCriteriaText(e.target.value)}
|
||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This text will be used by AI to determine which projects are
|
||||
eligible for this award.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="ai-toggle">AI Eligibility</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use AI to automatically evaluate project eligibility based on the criteria above.
|
||||
Turn off for awards decided by feeling or manual selection.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="ai-toggle"
|
||||
checked={useAiEligibility}
|
||||
onCheckedChange={setUseAiEligibility}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoring">Scoring Mode</Label>
|
||||
<Select
|
||||
value={scoringMode}
|
||||
onValueChange={(v) =>
|
||||
setScoringMode(
|
||||
v as 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="scoring">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">
|
||||
Pick Winner — Each juror picks 1
|
||||
</SelectItem>
|
||||
<SelectItem value="RANKED">
|
||||
Ranked — Each juror ranks top N
|
||||
</SelectItem>
|
||||
<SelectItem value="SCORED">
|
||||
Scored — Use evaluation form
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{scoringMode === 'RANKED' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||
<Input
|
||||
id="maxPicks"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={maxRankedPicks}
|
||||
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/awards">Cancel</Link>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={createAward.isPending || !name.trim() || !programId}
|
||||
>
|
||||
{createAward.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create Award
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { 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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function CreateAwardPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [criteriaText, setCriteriaText] = useState('')
|
||||
const [scoringMode, setScoringMode] = useState<
|
||||
'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
>('PICK_WINNER')
|
||||
const [useAiEligibility, setUseAiEligibility] = useState(true)
|
||||
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
||||
const [programId, setProgramId] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: programs } = trpc.program.list.useQuery()
|
||||
const createAward = trpc.specialAward.create.useMutation({
|
||||
onSuccess: () => utils.specialAward.list.invalidate(),
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !programId) return
|
||||
try {
|
||||
const award = await createAward.mutateAsync({
|
||||
programId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
criteriaText: criteriaText.trim() || undefined,
|
||||
useAiEligibility,
|
||||
scoringMode,
|
||||
maxRankedPicks:
|
||||
scoringMode === 'RANKED' ? parseInt(maxRankedPicks) : undefined,
|
||||
})
|
||||
toast.success('Award created')
|
||||
router.push(`/admin/awards/${award.id}`)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to create award'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/awards">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Create Special Award
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Define a new award with eligibility criteria and voting rules
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Award Details</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the award name, criteria, and scoring mode
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="program">Edition</Label>
|
||||
<Select value={programId} onValueChange={setProgramId}>
|
||||
<SelectTrigger id="program">
|
||||
<SelectValue placeholder="Select an edition" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.year} Edition
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Award Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Mediterranean Entrepreneurship Award"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this award"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="criteria">Eligibility Criteria</Label>
|
||||
<Textarea
|
||||
id="criteria"
|
||||
value={criteriaText}
|
||||
onChange={(e) => setCriteriaText(e.target.value)}
|
||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This text will be used by AI to determine which projects are
|
||||
eligible for this award.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="ai-toggle">AI Eligibility</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use AI to automatically evaluate project eligibility based on the criteria above.
|
||||
Turn off for awards decided by feeling or manual selection.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="ai-toggle"
|
||||
checked={useAiEligibility}
|
||||
onCheckedChange={setUseAiEligibility}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoring">Scoring Mode</Label>
|
||||
<Select
|
||||
value={scoringMode}
|
||||
onValueChange={(v) =>
|
||||
setScoringMode(
|
||||
v as 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="scoring">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">
|
||||
Pick Winner — Each juror picks 1
|
||||
</SelectItem>
|
||||
<SelectItem value="RANKED">
|
||||
Ranked — Each juror ranks top N
|
||||
</SelectItem>
|
||||
<SelectItem value="SCORED">
|
||||
Scored — Use evaluation form
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{scoringMode === 'RANKED' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||
<Input
|
||||
id="maxPicks"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={maxRankedPicks}
|
||||
onChange={(e) => setMaxRankedPicks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/awards">Cancel</Link>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={createAward.isPending || !name.trim() || !programId}
|
||||
>
|
||||
{createAward.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create Award
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,238 +1,238 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import Link from 'next/link'
|
||||
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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trophy, Users, CheckCircle2, Search } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
DRAFT: 'secondary',
|
||||
NOMINATIONS_OPEN: 'default',
|
||||
VOTING_OPEN: 'default',
|
||||
CLOSED: 'outline',
|
||||
ARCHIVED: 'secondary',
|
||||
}
|
||||
|
||||
const SCORING_LABELS: Record<string, string> = {
|
||||
PICK_WINNER: 'Pick Winner',
|
||||
RANKED: 'Ranked',
|
||||
SCORED: 'Scored',
|
||||
}
|
||||
|
||||
export default function AwardsListPage() {
|
||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [scoringFilter, setScoringFilter] = useState('all')
|
||||
|
||||
const filteredAwards = useMemo(() => {
|
||||
if (!awards) return []
|
||||
return awards.filter((award) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
award.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
award.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesStatus = statusFilter === 'all' || award.status === statusFilter
|
||||
const matchesScoring = scoringFilter === 'all' || award.scoringMode === scoringFilter
|
||||
return matchesSearch && matchesStatus && matchesScoring
|
||||
})
|
||||
}, [awards, debouncedSearch, statusFilter, scoringFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-[180px]" />
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Cards skeleton */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Special Awards
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage named awards with eligibility criteria and jury voting
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/awards/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Award
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search awards..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||
<SelectItem value="NOMINATIONS_OPEN">Nominations Open</SelectItem>
|
||||
<SelectItem value="VOTING_OPEN">Voting Open</SelectItem>
|
||||
<SelectItem value="CLOSED">Closed</SelectItem>
|
||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={scoringFilter} onValueChange={setScoringFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All scoring" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All scoring</SelectItem>
|
||||
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
||||
<SelectItem value="RANKED">Ranked</SelectItem>
|
||||
<SelectItem value="SCORED">Scored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{awards && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredAwards.length} of {awards.length} awards
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Awards Grid */}
|
||||
{filteredAwards.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredAwards.map((award, index) => (
|
||||
<AnimatedCard key={award.id} index={index}>
|
||||
<Link href={`/admin/awards/${award.id}`}>
|
||||
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md cursor-pointer h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-amber-500" />
|
||||
{award.name}
|
||||
</CardTitle>
|
||||
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||
{award.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{award.description && (
|
||||
<CardDescription className="line-clamp-2">
|
||||
{award.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{award._count.eligibilities} eligible
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{award._count.jurors} jurors
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{SCORING_LABELS[award.scoringMode] || award.scoringMode}
|
||||
</Badge>
|
||||
</div>
|
||||
{award.winnerProject && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">Winner:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{award.winnerProject.title}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</div>
|
||||
) : awards && awards.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No awards match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No awards yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Create special awards with eligibility criteria and jury voting for outstanding projects.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/awards/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Award
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import Link from 'next/link'
|
||||
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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trophy, Users, CheckCircle2, Search } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
DRAFT: 'secondary',
|
||||
NOMINATIONS_OPEN: 'default',
|
||||
VOTING_OPEN: 'default',
|
||||
CLOSED: 'outline',
|
||||
ARCHIVED: 'secondary',
|
||||
}
|
||||
|
||||
const SCORING_LABELS: Record<string, string> = {
|
||||
PICK_WINNER: 'Pick Winner',
|
||||
RANKED: 'Ranked',
|
||||
SCORED: 'Scored',
|
||||
}
|
||||
|
||||
export default function AwardsListPage() {
|
||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [scoringFilter, setScoringFilter] = useState('all')
|
||||
|
||||
const filteredAwards = useMemo(() => {
|
||||
if (!awards) return []
|
||||
return awards.filter((award) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
award.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
award.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesStatus = statusFilter === 'all' || award.status === statusFilter
|
||||
const matchesScoring = scoringFilter === 'all' || award.scoringMode === scoringFilter
|
||||
return matchesSearch && matchesStatus && matchesScoring
|
||||
})
|
||||
}, [awards, debouncedSearch, statusFilter, scoringFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-[180px]" />
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Cards skeleton */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Special Awards
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage named awards with eligibility criteria and jury voting
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/awards/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Award
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search awards..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||
<SelectItem value="NOMINATIONS_OPEN">Nominations Open</SelectItem>
|
||||
<SelectItem value="VOTING_OPEN">Voting Open</SelectItem>
|
||||
<SelectItem value="CLOSED">Closed</SelectItem>
|
||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={scoringFilter} onValueChange={setScoringFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All scoring" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All scoring</SelectItem>
|
||||
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
||||
<SelectItem value="RANKED">Ranked</SelectItem>
|
||||
<SelectItem value="SCORED">Scored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{awards && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredAwards.length} of {awards.length} awards
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Awards Grid */}
|
||||
{filteredAwards.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredAwards.map((award, index) => (
|
||||
<AnimatedCard key={award.id} index={index}>
|
||||
<Link href={`/admin/awards/${award.id}`}>
|
||||
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md cursor-pointer h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-amber-500" />
|
||||
{award.name}
|
||||
</CardTitle>
|
||||
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
|
||||
{award.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{award.description && (
|
||||
<CardDescription className="line-clamp-2">
|
||||
{award.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{award._count.eligibilities} eligible
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{award._count.jurors} jurors
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{SCORING_LABELS[award.scoringMode] || award.scoringMode}
|
||||
</Badge>
|
||||
</div>
|
||||
{award.winnerProject && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">Winner:</span>{' '}
|
||||
<span className="font-medium">
|
||||
{award.winnerProject.title}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</div>
|
||||
) : awards && awards.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No awards match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No awards yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Create special awards with eligibility criteria and jury voting for outstanding projects.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/awards/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Award
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,481 +1,481 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Trash2,
|
||||
Eye,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Dynamically import BlockEditor to avoid SSR issues
|
||||
const BlockEditor = dynamic(
|
||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const resourceTypeOptions = [
|
||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
||||
{ value: 'OTHER', label: 'Other', icon: File },
|
||||
]
|
||||
|
||||
const cohortOptions = [
|
||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
||||
]
|
||||
|
||||
export default function EditLearningResourcePage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const resourceId = params.id as string
|
||||
|
||||
// Fetch resource
|
||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||
const { data: stats } = trpc.learningResource.getStats.useQuery({ id: resourceId })
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [contentJson, setContentJson] = useState<string>('')
|
||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
||||
const [externalUrl, setExternalUrl] = useState('')
|
||||
const [isPublished, setIsPublished] = useState(false)
|
||||
const [programId, setProgramId] = useState<string | null>(null)
|
||||
|
||||
// API
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
const utils = trpc.useUtils()
|
||||
const updateResource = trpc.learningResource.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.learningResource.get.invalidate({ id: resourceId })
|
||||
utils.learningResource.list.invalidate()
|
||||
},
|
||||
})
|
||||
const deleteResource = trpc.learningResource.delete.useMutation({
|
||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||
})
|
||||
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
||||
|
||||
// Populate form when resource loads
|
||||
useEffect(() => {
|
||||
if (resource) {
|
||||
setTitle(resource.title)
|
||||
setDescription(resource.description || '')
|
||||
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
||||
setResourceType(resource.resourceType)
|
||||
setCohortLevel(resource.cohortLevel)
|
||||
setExternalUrl(resource.externalUrl || '')
|
||||
setIsPublished(resource.isPublished)
|
||||
setProgramId(resource.programId)
|
||||
}
|
||||
}, [resource])
|
||||
|
||||
// Handle file upload for BlockNote
|
||||
const handleUploadFile = async (file: File): Promise<string> => {
|
||||
try {
|
||||
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
})
|
||||
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
|
||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload file')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Please enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
if (resourceType === 'LINK' && !externalUrl) {
|
||||
toast.error('Please enter an external URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateResource.mutateAsync({
|
||||
id: resourceId,
|
||||
title,
|
||||
description: description || undefined,
|
||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
||||
externalUrl: externalUrl || null,
|
||||
isPublished,
|
||||
})
|
||||
|
||||
toast.success('Resource updated successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteResource.mutateAsync({ id: resourceId })
|
||||
toast.success('Resource deleted successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !resource) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Resource not found</AlertTitle>
|
||||
<AlertDescription>
|
||||
The resource you're looking for does not exist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</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/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update this learning resource
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{resource.title}"? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about this resource
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Ocean Conservation Best Practices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Short Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this resource"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Resource Type</Label>
|
||||
<Select value={resourceType} onValueChange={setResourceType}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resourceTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<option.icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cohort">Access Level</Label>
|
||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
||||
<SelectTrigger id="cohort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cohortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceType === 'LINK' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">External URL *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
placeholder="https://example.com/resource"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content Editor */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Content</CardTitle>
|
||||
<CardDescription>
|
||||
Rich text content with images and videos. Type / for commands.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BlockEditor
|
||||
key={resourceId}
|
||||
initialContent={contentJson || undefined}
|
||||
onChange={setContentJson}
|
||||
onUploadFile={handleUploadFile}
|
||||
className="min-h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Publish Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Publish Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="published">Published</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Make this resource visible to jury members
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="published"
|
||||
checked={isPublished}
|
||||
onCheckedChange={setIsPublished}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="program">Program</Label>
|
||||
<Select
|
||||
value={programId || 'global'}
|
||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||
>
|
||||
<SelectTrigger id="program">
|
||||
<SelectValue placeholder="Select program" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||
{programs?.map((program) => (
|
||||
<SelectItem key={program.id} value={program.id}>
|
||||
{program.year} Edition
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Statistics */}
|
||||
{stats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
Statistics
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
||||
<p className="text-sm text-muted-foreground">Total views</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
||||
<p className="text-sm text-muted-foreground">Unique users</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={updateResource.isPending || !title.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{updateResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button variant="outline" asChild className="w-full">
|
||||
<Link href="/admin/learning">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Trash2,
|
||||
Eye,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Dynamically import BlockEditor to avoid SSR issues
|
||||
const BlockEditor = dynamic(
|
||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const resourceTypeOptions = [
|
||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
||||
{ value: 'OTHER', label: 'Other', icon: File },
|
||||
]
|
||||
|
||||
const cohortOptions = [
|
||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
||||
]
|
||||
|
||||
export default function EditLearningResourcePage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const resourceId = params.id as string
|
||||
|
||||
// Fetch resource
|
||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||
const { data: stats } = trpc.learningResource.getStats.useQuery({ id: resourceId })
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [contentJson, setContentJson] = useState<string>('')
|
||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
||||
const [externalUrl, setExternalUrl] = useState('')
|
||||
const [isPublished, setIsPublished] = useState(false)
|
||||
const [programId, setProgramId] = useState<string | null>(null)
|
||||
|
||||
// API
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
const utils = trpc.useUtils()
|
||||
const updateResource = trpc.learningResource.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.learningResource.get.invalidate({ id: resourceId })
|
||||
utils.learningResource.list.invalidate()
|
||||
},
|
||||
})
|
||||
const deleteResource = trpc.learningResource.delete.useMutation({
|
||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||
})
|
||||
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
||||
|
||||
// Populate form when resource loads
|
||||
useEffect(() => {
|
||||
if (resource) {
|
||||
setTitle(resource.title)
|
||||
setDescription(resource.description || '')
|
||||
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
|
||||
setResourceType(resource.resourceType)
|
||||
setCohortLevel(resource.cohortLevel)
|
||||
setExternalUrl(resource.externalUrl || '')
|
||||
setIsPublished(resource.isPublished)
|
||||
setProgramId(resource.programId)
|
||||
}
|
||||
}, [resource])
|
||||
|
||||
// Handle file upload for BlockNote
|
||||
const handleUploadFile = async (file: File): Promise<string> => {
|
||||
try {
|
||||
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
})
|
||||
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
|
||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload file')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Please enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
if (resourceType === 'LINK' && !externalUrl) {
|
||||
toast.error('Please enter an external URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateResource.mutateAsync({
|
||||
id: resourceId,
|
||||
title,
|
||||
description: description || undefined,
|
||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
||||
externalUrl: externalUrl || null,
|
||||
isPublished,
|
||||
})
|
||||
|
||||
toast.success('Resource updated successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteResource.mutateAsync({ id: resourceId })
|
||||
toast.success('Resource deleted successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !resource) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Resource not found</AlertTitle>
|
||||
<AlertDescription>
|
||||
The resource you're looking for does not exist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</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/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update this learning resource
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{resource.title}"? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about this resource
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Ocean Conservation Best Practices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Short Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this resource"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Resource Type</Label>
|
||||
<Select value={resourceType} onValueChange={setResourceType}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resourceTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<option.icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cohort">Access Level</Label>
|
||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
||||
<SelectTrigger id="cohort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cohortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceType === 'LINK' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">External URL *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
placeholder="https://example.com/resource"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content Editor */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Content</CardTitle>
|
||||
<CardDescription>
|
||||
Rich text content with images and videos. Type / for commands.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BlockEditor
|
||||
key={resourceId}
|
||||
initialContent={contentJson || undefined}
|
||||
onChange={setContentJson}
|
||||
onUploadFile={handleUploadFile}
|
||||
className="min-h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Publish Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Publish Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="published">Published</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Make this resource visible to jury members
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="published"
|
||||
checked={isPublished}
|
||||
onCheckedChange={setIsPublished}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="program">Program</Label>
|
||||
<Select
|
||||
value={programId || 'global'}
|
||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||
>
|
||||
<SelectTrigger id="program">
|
||||
<SelectValue placeholder="Select program" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||
{programs?.map((program) => (
|
||||
<SelectItem key={program.id} value={program.id}>
|
||||
{program.year} Edition
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Statistics */}
|
||||
{stats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
Statistics
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{stats.totalViews}</p>
|
||||
<p className="text-sm text-muted-foreground">Total views</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
|
||||
<p className="text-sm text-muted-foreground">Unique users</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={updateResource.isPending || !title.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{updateResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button variant="outline" asChild className="w-full">
|
||||
<Link href="/admin/learning">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,327 +1,327 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
|
||||
|
||||
// Dynamically import BlockEditor to avoid SSR issues
|
||||
const BlockEditor = dynamic(
|
||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const resourceTypeOptions = [
|
||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
||||
{ value: 'OTHER', label: 'Other', icon: File },
|
||||
]
|
||||
|
||||
const cohortOptions = [
|
||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
||||
]
|
||||
|
||||
export default function NewLearningResourcePage() {
|
||||
const router = useRouter()
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [contentJson, setContentJson] = useState<string>('')
|
||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
||||
const [externalUrl, setExternalUrl] = useState('')
|
||||
const [isPublished, setIsPublished] = useState(false)
|
||||
|
||||
// API
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
const [programId, setProgramId] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createResource = trpc.learningResource.create.useMutation({
|
||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||
})
|
||||
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
||||
|
||||
// Handle file upload for BlockNote
|
||||
const handleUploadFile = async (file: File): Promise<string> => {
|
||||
try {
|
||||
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
})
|
||||
|
||||
// Upload to MinIO
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
|
||||
// Return the MinIO URL
|
||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload file')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Please enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
if (resourceType === 'LINK' && !externalUrl) {
|
||||
toast.error('Please enter an external URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createResource.mutateAsync({
|
||||
programId,
|
||||
title,
|
||||
description: description || undefined,
|
||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
||||
externalUrl: externalUrl || undefined,
|
||||
isPublished,
|
||||
})
|
||||
|
||||
toast.success('Resource created successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create a new learning resource for jury members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about this resource
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Ocean Conservation Best Practices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Short Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this resource"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Resource Type</Label>
|
||||
<Select value={resourceType} onValueChange={setResourceType}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resourceTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<option.icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cohort">Access Level</Label>
|
||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
||||
<SelectTrigger id="cohort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cohortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceType === 'LINK' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">External URL *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
placeholder="https://example.com/resource"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content Editor */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Content</CardTitle>
|
||||
<CardDescription>
|
||||
Rich text content with images and videos. Type / for commands.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BlockEditor
|
||||
initialContent={contentJson || undefined}
|
||||
onChange={setContentJson}
|
||||
onUploadFile={handleUploadFile}
|
||||
className="min-h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Publish Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Publish Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="published">Published</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Make this resource visible to jury members
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="published"
|
||||
checked={isPublished}
|
||||
onCheckedChange={setIsPublished}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="program">Program</Label>
|
||||
<Select
|
||||
value={programId || 'global'}
|
||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||
>
|
||||
<SelectTrigger id="program">
|
||||
<SelectValue placeholder="Select program" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||
{programs?.map((program) => (
|
||||
<SelectItem key={program.id} value={program.id}>
|
||||
{program.year} Edition
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={createResource.isPending || !title.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{createResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create Resource
|
||||
</Button>
|
||||
<Button variant="outline" asChild className="w-full">
|
||||
<Link href="/admin/learning">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
|
||||
|
||||
// Dynamically import BlockEditor to avoid SSR issues
|
||||
const BlockEditor = dynamic(
|
||||
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const resourceTypeOptions = [
|
||||
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
|
||||
{ value: 'PDF', label: 'PDF', icon: FileText },
|
||||
{ value: 'VIDEO', label: 'Video', icon: Video },
|
||||
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
|
||||
{ value: 'OTHER', label: 'Other', icon: File },
|
||||
]
|
||||
|
||||
const cohortOptions = [
|
||||
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
|
||||
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
|
||||
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
|
||||
]
|
||||
|
||||
export default function NewLearningResourcePage() {
|
||||
const router = useRouter()
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [contentJson, setContentJson] = useState<string>('')
|
||||
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
|
||||
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
|
||||
const [externalUrl, setExternalUrl] = useState('')
|
||||
const [isPublished, setIsPublished] = useState(false)
|
||||
|
||||
// API
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
const [programId, setProgramId] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createResource = trpc.learningResource.create.useMutation({
|
||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||
})
|
||||
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
||||
|
||||
// Handle file upload for BlockNote
|
||||
const handleUploadFile = async (file: File): Promise<string> => {
|
||||
try {
|
||||
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
})
|
||||
|
||||
// Upload to MinIO
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
|
||||
// Return the MinIO URL
|
||||
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
|
||||
return `${minioEndpoint}/${bucket}/${objectKey}`
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload file')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Please enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
if (resourceType === 'LINK' && !externalUrl) {
|
||||
toast.error('Please enter an external URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createResource.mutateAsync({
|
||||
programId,
|
||||
title,
|
||||
description: description || undefined,
|
||||
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
|
||||
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
|
||||
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
|
||||
externalUrl: externalUrl || undefined,
|
||||
isPublished,
|
||||
})
|
||||
|
||||
toast.success('Resource created successfully')
|
||||
router.push('/admin/learning')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create a new learning resource for jury members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resource Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about this resource
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Ocean Conservation Best Practices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Short Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this resource"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Resource Type</Label>
|
||||
<Select value={resourceType} onValueChange={setResourceType}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resourceTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<option.icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cohort">Access Level</Label>
|
||||
<Select value={cohortLevel} onValueChange={setCohortLevel}>
|
||||
<SelectTrigger id="cohort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cohortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceType === 'LINK' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">External URL *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
placeholder="https://example.com/resource"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content Editor */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Content</CardTitle>
|
||||
<CardDescription>
|
||||
Rich text content with images and videos. Type / for commands.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BlockEditor
|
||||
initialContent={contentJson || undefined}
|
||||
onChange={setContentJson}
|
||||
onUploadFile={handleUploadFile}
|
||||
className="min-h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Publish Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Publish Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="published">Published</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Make this resource visible to jury members
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="published"
|
||||
checked={isPublished}
|
||||
onCheckedChange={setIsPublished}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="program">Program</Label>
|
||||
<Select
|
||||
value={programId || 'global'}
|
||||
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
|
||||
>
|
||||
<SelectTrigger id="program">
|
||||
<SelectValue placeholder="Select program" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global (All Programs)</SelectItem>
|
||||
{programs?.map((program) => (
|
||||
<SelectItem key={program.id} value={program.id}>
|
||||
{program.year} Edition
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={createResource.isPending || !title.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{createResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create Resource
|
||||
</Button>
|
||||
<Button variant="outline" asChild className="w-full">
|
||||
<Link href="/admin/learning">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,247 +1,247 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Plus,
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Pencil,
|
||||
ExternalLink,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
const resourceTypeIcons = {
|
||||
PDF: FileText,
|
||||
VIDEO: Video,
|
||||
DOCUMENT: File,
|
||||
LINK: LinkIcon,
|
||||
OTHER: File,
|
||||
}
|
||||
|
||||
const cohortColors: Record<string, string> = {
|
||||
ALL: 'bg-gray-100 text-gray-800',
|
||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
||||
FINALIST: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
|
||||
export default function LearningHubPage() {
|
||||
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
|
||||
const resources = data?.data
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [cohortFilter, setCohortFilter] = useState('all')
|
||||
|
||||
const filteredResources = useMemo(() => {
|
||||
if (!resources) return []
|
||||
return resources.filter((resource) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
|
||||
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
|
||||
return matchesSearch && matchesType && matchesCohort
|
||||
})
|
||||
}, [resources, debouncedSearch, typeFilter, cohortFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Resource list skeleton */}
|
||||
<div className="grid gap-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage educational resources for jury members
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/learning/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Resource
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search resources..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="PDF">PDF</SelectItem>
|
||||
<SelectItem value="VIDEO">Video</SelectItem>
|
||||
<SelectItem value="DOCUMENT">Document</SelectItem>
|
||||
<SelectItem value="LINK">Link</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={cohortFilter} onValueChange={setCohortFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All cohorts" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All cohorts</SelectItem>
|
||||
<SelectItem value="ALL">All (cohort)</SelectItem>
|
||||
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
|
||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{resources && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredResources.length} of {resources.length} resources
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Resource List */}
|
||||
{filteredResources.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{filteredResources.map((resource) => {
|
||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
||||
return (
|
||||
<Card key={resource.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{resource.title}</h3>
|
||||
{!resource.isPublished && (
|
||||
<Badge variant="secondary">Draft</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
|
||||
{resource.cohortLevel}
|
||||
</Badge>
|
||||
<span>{resource.resourceType}</span>
|
||||
<span>-</span>
|
||||
<span>{resource._count.accessLogs} views</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
<a
|
||||
href={resource.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<Link href={`/admin/learning/${resource.id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : resources && resources.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No resources match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No resources yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Add learning materials like videos, documents, and links for program participants.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/learning/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Resource
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Plus,
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Pencil,
|
||||
ExternalLink,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
const resourceTypeIcons = {
|
||||
PDF: FileText,
|
||||
VIDEO: Video,
|
||||
DOCUMENT: File,
|
||||
LINK: LinkIcon,
|
||||
OTHER: File,
|
||||
}
|
||||
|
||||
const cohortColors: Record<string, string> = {
|
||||
ALL: 'bg-gray-100 text-gray-800',
|
||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
||||
FINALIST: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
|
||||
export default function LearningHubPage() {
|
||||
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
|
||||
const resources = data?.data
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [cohortFilter, setCohortFilter] = useState('all')
|
||||
|
||||
const filteredResources = useMemo(() => {
|
||||
if (!resources) return []
|
||||
return resources.filter((resource) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
|
||||
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
|
||||
return matchesSearch && matchesType && matchesCohort
|
||||
})
|
||||
}, [resources, debouncedSearch, typeFilter, cohortFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Resource list skeleton */}
|
||||
<div className="grid gap-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Learning Hub</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage educational resources for jury members
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/learning/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Resource
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search resources..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="PDF">PDF</SelectItem>
|
||||
<SelectItem value="VIDEO">Video</SelectItem>
|
||||
<SelectItem value="DOCUMENT">Document</SelectItem>
|
||||
<SelectItem value="LINK">Link</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={cohortFilter} onValueChange={setCohortFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All cohorts" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All cohorts</SelectItem>
|
||||
<SelectItem value="ALL">All (cohort)</SelectItem>
|
||||
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
|
||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{resources && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredResources.length} of {resources.length} resources
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Resource List */}
|
||||
{filteredResources.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{filteredResources.map((resource) => {
|
||||
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
|
||||
return (
|
||||
<Card key={resource.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{resource.title}</h3>
|
||||
{!resource.isPublished && (
|
||||
<Badge variant="secondary">Draft</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
|
||||
{resource.cohortLevel}
|
||||
</Badge>
|
||||
<span>{resource.resourceType}</span>
|
||||
<span>-</span>
|
||||
<span>{resource._count.accessLogs} views</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
<a
|
||||
href={resource.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<Link href={`/admin/learning/${resource.id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : resources && resources.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No resources match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No resources yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Add learning materials like videos, documents, and links for program participants.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/learning/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Resource
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export default function MemberDetailPage() {
|
||||
)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState<string>('JURY_MEMBER')
|
||||
const [status, setStatus] = useState<string>('NONE')
|
||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||
@@ -83,6 +84,7 @@ export default function MemberDetailPage() {
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.name || '')
|
||||
setEmail(user.email || '')
|
||||
setRole(user.role)
|
||||
setStatus(user.status)
|
||||
setExpertiseTags(user.expertiseTags || [])
|
||||
@@ -94,6 +96,7 @@ export default function MemberDetailPage() {
|
||||
try {
|
||||
await updateUser.mutateAsync({
|
||||
id: userId,
|
||||
email: email || undefined,
|
||||
name: name || null,
|
||||
role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
|
||||
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
|
||||
@@ -212,7 +215,12 @@ export default function MemberDetailPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" value={user.email} disabled />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,472 +1,472 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
LayoutTemplate,
|
||||
Eye,
|
||||
Variable,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const AVAILABLE_VARIABLES = [
|
||||
{ name: '{{projectName}}', desc: 'Project title' },
|
||||
{ name: '{{userName}}', desc: "Recipient's name" },
|
||||
{ name: '{{deadline}}', desc: 'Deadline date' },
|
||||
{ name: '{{roundName}}', desc: 'Round name' },
|
||||
{ name: '{{programName}}', desc: 'Program name' },
|
||||
]
|
||||
|
||||
interface TemplateFormData {
|
||||
name: string
|
||||
category: string
|
||||
subject: string
|
||||
body: string
|
||||
variables: string[]
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const defaultForm: TemplateFormData = {
|
||||
name: '',
|
||||
category: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
variables: [],
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
export default function MessageTemplatesPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<TemplateFormData>(defaultForm)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: templates, isLoading } = trpc.message.listTemplates.useQuery()
|
||||
|
||||
const createMutation = trpc.message.createTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.message.listTemplates.invalidate()
|
||||
toast.success('Template created')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.message.updateTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.message.listTemplates.invalidate()
|
||||
toast.success('Template updated')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const deleteMutation = trpc.message.deleteTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.message.listTemplates.invalidate()
|
||||
toast.success('Template deleted')
|
||||
setDeleteId(null)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingId(null)
|
||||
setFormData(defaultForm)
|
||||
setShowPreview(false)
|
||||
}
|
||||
|
||||
const openEdit = (template: Record<string, unknown>) => {
|
||||
setEditingId(String(template.id))
|
||||
setFormData({
|
||||
name: String(template.name || ''),
|
||||
category: String(template.category || ''),
|
||||
subject: String(template.subject || ''),
|
||||
body: String(template.body || ''),
|
||||
variables: Array.isArray(template.variables) ? template.variables.map(String) : [],
|
||||
isActive: template.isActive !== false,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const insertVariable = (variable: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
body: prev.body + variable,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name.trim() || !formData.subject.trim()) {
|
||||
toast.error('Name and subject are required')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formData.name.trim(),
|
||||
category: formData.category.trim() || 'General',
|
||||
subject: formData.subject.trim(),
|
||||
body: formData.body.trim(),
|
||||
variables: formData.variables.length > 0 ? formData.variables : undefined,
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, ...payload, isActive: formData.isActive })
|
||||
} else {
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const getPreviewText = (text: string): string => {
|
||||
return text
|
||||
.replace(/\{\{userName\}\}/g, 'John Doe')
|
||||
.replace(/\{\{projectName\}\}/g, 'Ocean Cleanup Initiative')
|
||||
.replace(/\{\{roundName\}\}/g, 'Round 1 - Semi-Finals')
|
||||
.replace(/\{\{programName\}\}/g, 'MOPC 2026')
|
||||
.replace(/\{\{deadline\}\}/g, 'March 15, 2026')
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/messages">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Messages
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Message Templates</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create and manage reusable message templates
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => { setFormData(defaultForm); setEditingId(null); setDialogOpen(true) }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? 'Edit Template' : 'Create Template'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define a reusable message template with variable placeholders.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Template Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Evaluation Reminder"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Input
|
||||
placeholder="e.g., Notification, Reminder"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Subject</Label>
|
||||
<Input
|
||||
placeholder="e.g., Reminder: {{roundName}} evaluation deadline"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Message Body</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
{showPreview ? 'Edit' : 'Preview'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showPreview ? (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Subject: {getPreviewText(formData.subject)}
|
||||
</p>
|
||||
<div className="text-sm whitespace-pre-wrap border-t pt-2">
|
||||
{getPreviewText(formData.body) || 'No content yet'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder="Write your template message..."
|
||||
value={formData.body}
|
||||
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
|
||||
rows={8}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable buttons */}
|
||||
{!showPreview && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-1">
|
||||
<Variable className="h-3 w-3" />
|
||||
Insert Variable
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{AVAILABLE_VARIABLES.map((v) => (
|
||||
<Button
|
||||
key={v.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => insertVariable(v.name)}
|
||||
title={v.desc}
|
||||
>
|
||||
{v.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="template-active"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, isActive: checked })
|
||||
}
|
||||
/>
|
||||
<label htmlFor="template-active" className="text-sm cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editingId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Variable reference panel */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Variable className="h-4 w-4" />
|
||||
Available Template Variables
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{AVAILABLE_VARIABLES.map((v) => (
|
||||
<div key={v.name} className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted rounded px-2 py-1 font-mono">
|
||||
{v.name}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">{v.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Templates list */}
|
||||
{isLoading ? (
|
||||
<TemplatesSkeleton />
|
||||
) : templates && (templates as unknown[]).length > 0 ? (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Category</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Subject</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(templates as Array<Record<string, unknown>>).map((template) => (
|
||||
<TableRow key={String(template.id)}>
|
||||
<TableCell className="font-medium">
|
||||
{String(template.name)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{template.category ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{String(template.category)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-sm text-muted-foreground truncate max-w-[200px]">
|
||||
{String(template.subject || '')}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{template.isActive !== false ? (
|
||||
<Badge variant="default" className="text-xs">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">Inactive</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(template)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteId(String(template.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<LayoutTemplate className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No templates yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a template to speed up message composition.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Template</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this template? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteId && deleteMutation.mutate({ id: deleteId })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplatesSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-8 w-16 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
LayoutTemplate,
|
||||
Eye,
|
||||
Variable,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const AVAILABLE_VARIABLES = [
|
||||
{ name: '{{projectName}}', desc: 'Project title' },
|
||||
{ name: '{{userName}}', desc: "Recipient's name" },
|
||||
{ name: '{{deadline}}', desc: 'Deadline date' },
|
||||
{ name: '{{roundName}}', desc: 'Round name' },
|
||||
{ name: '{{programName}}', desc: 'Program name' },
|
||||
]
|
||||
|
||||
interface TemplateFormData {
|
||||
name: string
|
||||
category: string
|
||||
subject: string
|
||||
body: string
|
||||
variables: string[]
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const defaultForm: TemplateFormData = {
|
||||
name: '',
|
||||
category: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
variables: [],
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
export default function MessageTemplatesPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<TemplateFormData>(defaultForm)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: templates, isLoading } = trpc.message.listTemplates.useQuery()
|
||||
|
||||
const createMutation = trpc.message.createTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.message.listTemplates.invalidate()
|
||||
toast.success('Template created')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.message.updateTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.message.listTemplates.invalidate()
|
||||
toast.success('Template updated')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const deleteMutation = trpc.message.deleteTemplate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.message.listTemplates.invalidate()
|
||||
toast.success('Template deleted')
|
||||
setDeleteId(null)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingId(null)
|
||||
setFormData(defaultForm)
|
||||
setShowPreview(false)
|
||||
}
|
||||
|
||||
const openEdit = (template: Record<string, unknown>) => {
|
||||
setEditingId(String(template.id))
|
||||
setFormData({
|
||||
name: String(template.name || ''),
|
||||
category: String(template.category || ''),
|
||||
subject: String(template.subject || ''),
|
||||
body: String(template.body || ''),
|
||||
variables: Array.isArray(template.variables) ? template.variables.map(String) : [],
|
||||
isActive: template.isActive !== false,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const insertVariable = (variable: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
body: prev.body + variable,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name.trim() || !formData.subject.trim()) {
|
||||
toast.error('Name and subject are required')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formData.name.trim(),
|
||||
category: formData.category.trim() || 'General',
|
||||
subject: formData.subject.trim(),
|
||||
body: formData.body.trim(),
|
||||
variables: formData.variables.length > 0 ? formData.variables : undefined,
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, ...payload, isActive: formData.isActive })
|
||||
} else {
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const getPreviewText = (text: string): string => {
|
||||
return text
|
||||
.replace(/\{\{userName\}\}/g, 'John Doe')
|
||||
.replace(/\{\{projectName\}\}/g, 'Ocean Cleanup Initiative')
|
||||
.replace(/\{\{roundName\}\}/g, 'Round 1 - Semi-Finals')
|
||||
.replace(/\{\{programName\}\}/g, 'MOPC 2026')
|
||||
.replace(/\{\{deadline\}\}/g, 'March 15, 2026')
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/messages">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Messages
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Message Templates</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create and manage reusable message templates
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => { setFormData(defaultForm); setEditingId(null); setDialogOpen(true) }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? 'Edit Template' : 'Create Template'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define a reusable message template with variable placeholders.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Template Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Evaluation Reminder"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Input
|
||||
placeholder="e.g., Notification, Reminder"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Subject</Label>
|
||||
<Input
|
||||
placeholder="e.g., Reminder: {{roundName}} evaluation deadline"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Message Body</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
{showPreview ? 'Edit' : 'Preview'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showPreview ? (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Subject: {getPreviewText(formData.subject)}
|
||||
</p>
|
||||
<div className="text-sm whitespace-pre-wrap border-t pt-2">
|
||||
{getPreviewText(formData.body) || 'No content yet'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder="Write your template message..."
|
||||
value={formData.body}
|
||||
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
|
||||
rows={8}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable buttons */}
|
||||
{!showPreview && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-1">
|
||||
<Variable className="h-3 w-3" />
|
||||
Insert Variable
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{AVAILABLE_VARIABLES.map((v) => (
|
||||
<Button
|
||||
key={v.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => insertVariable(v.name)}
|
||||
title={v.desc}
|
||||
>
|
||||
{v.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="template-active"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, isActive: checked })
|
||||
}
|
||||
/>
|
||||
<label htmlFor="template-active" className="text-sm cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editingId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Variable reference panel */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Variable className="h-4 w-4" />
|
||||
Available Template Variables
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{AVAILABLE_VARIABLES.map((v) => (
|
||||
<div key={v.name} className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted rounded px-2 py-1 font-mono">
|
||||
{v.name}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">{v.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Templates list */}
|
||||
{isLoading ? (
|
||||
<TemplatesSkeleton />
|
||||
) : templates && (templates as unknown[]).length > 0 ? (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Category</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Subject</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(templates as Array<Record<string, unknown>>).map((template) => (
|
||||
<TableRow key={String(template.id)}>
|
||||
<TableCell className="font-medium">
|
||||
{String(template.name)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{template.category ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{String(template.category)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-sm text-muted-foreground truncate max-w-[200px]">
|
||||
{String(template.subject || '')}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{template.isActive !== false ? (
|
||||
<Badge variant="default" className="text-xs">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">Inactive</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(template)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteId(String(template.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<LayoutTemplate className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No templates yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a template to speed up message composition.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Template</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this template? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteId && deleteMutation.mutate({ id: deleteId })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplatesSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-8 w-16 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { CircleDot } from 'lucide-react'
|
||||
import { DashboardContent } from './dashboard-content'
|
||||
|
||||
export const metadata: Metadata = { title: 'Admin Dashboard' }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ edition?: string }>
|
||||
}
|
||||
|
||||
export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
||||
let editionId: string | null = null
|
||||
let sessionName = 'Admin'
|
||||
|
||||
try {
|
||||
const [session, params] = await Promise.all([
|
||||
auth(),
|
||||
searchParams,
|
||||
])
|
||||
|
||||
editionId = params.edition || null
|
||||
sessionName = session?.user?.name || 'Admin'
|
||||
|
||||
if (!editionId) {
|
||||
const defaultEdition = await prisma.program.findFirst({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
editionId = defaultEdition?.id || null
|
||||
|
||||
if (!editionId) {
|
||||
const anyEdition = await prisma.program.findFirst({
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
editionId = anyEdition?.id || null
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AdminDashboard] Page init failed:', err)
|
||||
}
|
||||
|
||||
if (!editionId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No edition selected</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an edition from the sidebar to view dashboard
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DashboardContent editionId={editionId} sessionName={sessionName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import type { Metadata } from 'next'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { CircleDot } from 'lucide-react'
|
||||
import { DashboardContent } from './dashboard-content'
|
||||
|
||||
export const metadata: Metadata = { title: 'Admin Dashboard' }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ edition?: string }>
|
||||
}
|
||||
|
||||
export default async function AdminDashboardPage({ searchParams }: PageProps) {
|
||||
let editionId: string | null = null
|
||||
let sessionName = 'Admin'
|
||||
|
||||
try {
|
||||
const [session, params] = await Promise.all([
|
||||
auth(),
|
||||
searchParams,
|
||||
])
|
||||
|
||||
editionId = params.edition || null
|
||||
sessionName = session?.user?.name || 'Admin'
|
||||
|
||||
if (!editionId) {
|
||||
const defaultEdition = await prisma.program.findFirst({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
editionId = defaultEdition?.id || null
|
||||
|
||||
if (!editionId) {
|
||||
const anyEdition = await prisma.program.findFirst({
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
editionId = anyEdition?.id || null
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AdminDashboard] Page init failed:', err)
|
||||
}
|
||||
|
||||
if (!editionId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No edition selected</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an edition from the sidebar to view dashboard
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DashboardContent editionId={editionId} sessionName={sessionName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,282 +1,282 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function EditPartnerPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params.id as string
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
website: '',
|
||||
partnerType: 'PARTNER',
|
||||
visibility: 'ADMIN_ONLY',
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const { data: partner, isLoading } = trpc.partner.get.useQuery({ id })
|
||||
|
||||
useEffect(() => {
|
||||
if (partner) {
|
||||
setFormData({
|
||||
name: partner.name,
|
||||
description: partner.description || '',
|
||||
website: partner.website || '',
|
||||
partnerType: partner.partnerType,
|
||||
visibility: partner.visibility,
|
||||
isActive: partner.isActive,
|
||||
})
|
||||
}
|
||||
}, [partner])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updatePartner = trpc.partner.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
utils.partner.get.invalidate()
|
||||
toast.success('Partner updated successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update partner')
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const deletePartner = trpc.partner.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
toast.success('Partner deleted successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to delete partner')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
updatePartner.mutate({
|
||||
id,
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
website: formData.website || null,
|
||||
partnerType: formData.partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
|
||||
visibility: formData.visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
|
||||
isActive: formData.isActive,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete handled via AlertDialog in JSX
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update partner information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Partner</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this partner. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deletePartner.mutate({ id })}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Partner Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about the partner organization
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Organization Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="partnerType">Partner Type</Label>
|
||||
<Select
|
||||
value={formData.partnerType}
|
||||
onValueChange={(value) => setFormData({ ...formData, partnerType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||
<SelectItem value="MEDIA">Media</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Input
|
||||
id="website"
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visibility">Visibility</Label>
|
||||
<Select
|
||||
value={formData.visibility}
|
||||
onValueChange={(value) => setFormData({ ...formData, visibility: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
|
||||
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
|
||||
<SelectItem value="PUBLIC">Public</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-8">
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||
/>
|
||||
<Label htmlFor="isActive">Active</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function EditPartnerPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params.id as string
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
website: '',
|
||||
partnerType: 'PARTNER',
|
||||
visibility: 'ADMIN_ONLY',
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const { data: partner, isLoading } = trpc.partner.get.useQuery({ id })
|
||||
|
||||
useEffect(() => {
|
||||
if (partner) {
|
||||
setFormData({
|
||||
name: partner.name,
|
||||
description: partner.description || '',
|
||||
website: partner.website || '',
|
||||
partnerType: partner.partnerType,
|
||||
visibility: partner.visibility,
|
||||
isActive: partner.isActive,
|
||||
})
|
||||
}
|
||||
}, [partner])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updatePartner = trpc.partner.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
utils.partner.get.invalidate()
|
||||
toast.success('Partner updated successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update partner')
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const deletePartner = trpc.partner.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
toast.success('Partner deleted successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to delete partner')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
updatePartner.mutate({
|
||||
id,
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
website: formData.website || null,
|
||||
partnerType: formData.partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
|
||||
visibility: formData.visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
|
||||
isActive: formData.isActive,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete handled via AlertDialog in JSX
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update partner information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Partner</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this partner. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deletePartner.mutate({ id })}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Partner Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about the partner organization
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Organization Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="partnerType">Partner Type</Label>
|
||||
<Select
|
||||
value={formData.partnerType}
|
||||
onValueChange={(value) => setFormData({ ...formData, partnerType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||
<SelectItem value="MEDIA">Media</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Input
|
||||
id="website"
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visibility">Visibility</Label>
|
||||
<Select
|
||||
value={formData.visibility}
|
||||
onValueChange={(value) => setFormData({ ...formData, visibility: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
|
||||
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
|
||||
<SelectItem value="PUBLIC">Public</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-8">
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||
/>
|
||||
<Label htmlFor="isActive">Active</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,170 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function NewPartnerPage() {
|
||||
const router = useRouter()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [partnerType, setPartnerType] = useState('PARTNER')
|
||||
const [visibility, setVisibility] = useState('ADMIN_ONLY')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createPartner = trpc.partner.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
toast.success('Partner created successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to create partner')
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const name = formData.get('name') as string
|
||||
const description = formData.get('description') as string
|
||||
const website = formData.get('website') as string
|
||||
|
||||
createPartner.mutate({
|
||||
name,
|
||||
programId: null,
|
||||
description: description || undefined,
|
||||
website: website || undefined,
|
||||
partnerType: partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
|
||||
visibility: visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Add Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Add a new partner or sponsor organization
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Partner Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about the partner organization
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Organization Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="e.g., Ocean Conservation Foundation"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="partnerType">Partner Type</Label>
|
||||
<Select value={partnerType} onValueChange={setPartnerType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||
<SelectItem value="MEDIA">Media</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Describe the organization and partnership..."
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Input
|
||||
id="website"
|
||||
name="website"
|
||||
type="url"
|
||||
placeholder="https://example.org"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visibility">Visibility</Label>
|
||||
<Select value={visibility} onValueChange={setVisibility}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
|
||||
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
|
||||
<SelectItem value="PUBLIC">Public</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Add Partner
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function NewPartnerPage() {
|
||||
const router = useRouter()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [partnerType, setPartnerType] = useState('PARTNER')
|
||||
const [visibility, setVisibility] = useState('ADMIN_ONLY')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createPartner = trpc.partner.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
toast.success('Partner created successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to create partner')
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const name = formData.get('name') as string
|
||||
const description = formData.get('description') as string
|
||||
const website = formData.get('website') as string
|
||||
|
||||
createPartner.mutate({
|
||||
name,
|
||||
programId: null,
|
||||
description: description || undefined,
|
||||
website: website || undefined,
|
||||
partnerType: partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
|
||||
visibility: visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Add Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Add a new partner or sponsor organization
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Partner Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about the partner organization
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Organization Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="e.g., Ocean Conservation Foundation"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="partnerType">Partner Type</Label>
|
||||
<Select value={partnerType} onValueChange={setPartnerType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||
<SelectItem value="MEDIA">Media</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Describe the organization and partnership..."
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Input
|
||||
id="website"
|
||||
name="website"
|
||||
type="url"
|
||||
placeholder="https://example.org"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visibility">Visibility</Label>
|
||||
<Select value={visibility} onValueChange={setVisibility}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
|
||||
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
|
||||
<SelectItem value="PUBLIC">Public</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Add Partner
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,259 +1,259 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
ExternalLink,
|
||||
Building2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
const visibilityIcons = {
|
||||
ADMIN_ONLY: EyeOff,
|
||||
JURY_VISIBLE: Eye,
|
||||
PUBLIC: Globe,
|
||||
}
|
||||
|
||||
const partnerTypeColors: Record<string, string> = {
|
||||
SPONSOR: 'bg-yellow-100 text-yellow-800',
|
||||
PARTNER: 'bg-blue-100 text-blue-800',
|
||||
SUPPORTER: 'bg-green-100 text-green-800',
|
||||
MEDIA: 'bg-purple-100 text-purple-800',
|
||||
OTHER: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
|
||||
export default function PartnersPage() {
|
||||
const { data, isLoading } = trpc.partner.list.useQuery({ perPage: 50 })
|
||||
const partners = data?.data
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [activeFilter, setActiveFilter] = useState('all')
|
||||
|
||||
const filteredPartners = useMemo(() => {
|
||||
if (!partners) return []
|
||||
return partners.filter((partner) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
partner.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
partner.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesType = typeFilter === 'all' || partner.partnerType === typeFilter
|
||||
const matchesActive =
|
||||
activeFilter === 'all' ||
|
||||
(activeFilter === 'active' && partner.isActive) ||
|
||||
(activeFilter === 'inactive' && !partner.isActive)
|
||||
return matchesSearch && matchesType && matchesActive
|
||||
})
|
||||
}, [partners, debouncedSearch, typeFilter, activeFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Partner cards skeleton */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Partners</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage partner and sponsor organizations
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/partners/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Partner
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search partners..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||
<SelectItem value="MEDIA">Media</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{partners && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredPartners.length} of {partners.length} partners
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Partners Grid */}
|
||||
{filteredPartners.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPartners.map((partner) => {
|
||||
const VisibilityIcon = visibilityIcons[partner.visibility as keyof typeof visibilityIcons] || Eye
|
||||
return (
|
||||
<Card key={partner.id} className={!partner.isActive ? 'opacity-60' : ''}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||
<Building2 className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{partner.name}</h3>
|
||||
{!partner.isActive && (
|
||||
<Badge variant="secondary">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className={partnerTypeColors[partner.partnerType] || ''} variant="outline">
|
||||
{partner.partnerType}
|
||||
</Badge>
|
||||
<VisibilityIcon className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
{partner.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
||||
{partner.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-4 pt-4 border-t">
|
||||
{partner.website && (
|
||||
<a
|
||||
href={partner.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
Website
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<Link href={`/admin/partners/${partner.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : partners && partners.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No partners match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No partners yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Add sponsor and partner organizations to showcase on the platform.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/partners/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Partner
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
ExternalLink,
|
||||
Building2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
const visibilityIcons = {
|
||||
ADMIN_ONLY: EyeOff,
|
||||
JURY_VISIBLE: Eye,
|
||||
PUBLIC: Globe,
|
||||
}
|
||||
|
||||
const partnerTypeColors: Record<string, string> = {
|
||||
SPONSOR: 'bg-yellow-100 text-yellow-800',
|
||||
PARTNER: 'bg-blue-100 text-blue-800',
|
||||
SUPPORTER: 'bg-green-100 text-green-800',
|
||||
MEDIA: 'bg-purple-100 text-purple-800',
|
||||
OTHER: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
|
||||
export default function PartnersPage() {
|
||||
const { data, isLoading } = trpc.partner.list.useQuery({ perPage: 50 })
|
||||
const partners = data?.data
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [activeFilter, setActiveFilter] = useState('all')
|
||||
|
||||
const filteredPartners = useMemo(() => {
|
||||
if (!partners) return []
|
||||
return partners.filter((partner) => {
|
||||
const matchesSearch =
|
||||
!debouncedSearch ||
|
||||
partner.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
partner.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
const matchesType = typeFilter === 'all' || partner.partnerType === typeFilter
|
||||
const matchesActive =
|
||||
activeFilter === 'all' ||
|
||||
(activeFilter === 'active' && partner.isActive) ||
|
||||
(activeFilter === 'inactive' && !partner.isActive)
|
||||
return matchesSearch && matchesType && matchesActive
|
||||
})
|
||||
}, [partners, debouncedSearch, typeFilter, activeFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
<Skeleton className="h-10 w-[160px]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Partner cards skeleton */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Partners</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage partner and sponsor organizations
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/partners/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Partner
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search partners..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="SPONSOR">Sponsor</SelectItem>
|
||||
<SelectItem value="PARTNER">Partner</SelectItem>
|
||||
<SelectItem value="SUPPORTER">Supporter</SelectItem>
|
||||
<SelectItem value="MEDIA">Media</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{partners && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredPartners.length} of {partners.length} partners
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Partners Grid */}
|
||||
{filteredPartners.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPartners.map((partner) => {
|
||||
const VisibilityIcon = visibilityIcons[partner.visibility as keyof typeof visibilityIcons] || Eye
|
||||
return (
|
||||
<Card key={partner.id} className={!partner.isActive ? 'opacity-60' : ''}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||
<Building2 className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{partner.name}</h3>
|
||||
{!partner.isActive && (
|
||||
<Badge variant="secondary">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className={partnerTypeColors[partner.partnerType] || ''} variant="outline">
|
||||
{partner.partnerType}
|
||||
</Badge>
|
||||
<VisibilityIcon className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
{partner.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
||||
{partner.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-4 pt-4 border-t">
|
||||
{partner.website && (
|
||||
<a
|
||||
href={partner.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
Website
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<Link href={`/admin/partners/${partner.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : partners && partners.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No partners match your filters
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground/40" />
|
||||
<h3 className="mt-3 text-lg font-medium">No partners yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
Add sponsor and partner organizations to showcase on the platform.
|
||||
</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/admin/partners/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Partner
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,274 +1,274 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function EditProgramPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params.id as string
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
status: 'DRAFT',
|
||||
applyMode: 'round' as 'edition' | 'round' | 'both',
|
||||
})
|
||||
|
||||
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
|
||||
|
||||
useEffect(() => {
|
||||
if (program) {
|
||||
const settings = (program.settingsJson as Record<string, any>) || {}
|
||||
setFormData({
|
||||
name: program.name,
|
||||
slug: program.slug || '',
|
||||
description: program.description || '',
|
||||
status: program.status,
|
||||
applyMode: settings.applyMode || 'round',
|
||||
})
|
||||
}
|
||||
}, [program])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updateProgram = trpc.program.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate()
|
||||
utils.program.get.invalidate({ id })
|
||||
toast.success('Program updated successfully')
|
||||
router.push(`/admin/programs/${id}`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update program')
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteProgram = trpc.program.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate()
|
||||
toast.success('Program deleted successfully')
|
||||
router.push('/admin/programs')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to delete program')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
updateProgram.mutate({
|
||||
id,
|
||||
name: formData.name,
|
||||
slug: formData.slug || undefined,
|
||||
description: formData.description || undefined,
|
||||
status: formData.status as 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
|
||||
settingsJson: {
|
||||
applyMode: formData.applyMode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Delete handled via AlertDialog in JSX
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/programs/${id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Program</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update program information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Program</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this program and all its rounds and projects.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteProgram.mutate({ id })}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Program Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about the program
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Program Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => setFormData({ ...formData, status: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">Edition Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder="e.g., mopc-2026"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL-friendly identifier for edition-wide applications (optional)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="applyMode">Application Flow</Label>
|
||||
<Select
|
||||
value={formData.applyMode}
|
||||
onValueChange={(value) => setFormData({ ...formData, applyMode: value as 'edition' | 'round' | 'both' })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="edition">Edition-wide only</SelectItem>
|
||||
<SelectItem value="round">Round-specific only</SelectItem>
|
||||
<SelectItem value="both">Allow both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls whether applicants apply to the program or specific rounds
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Link href={`/admin/programs/${id}`}>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function EditProgramPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params.id as string
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
status: 'DRAFT',
|
||||
applyMode: 'round' as 'edition' | 'round' | 'both',
|
||||
})
|
||||
|
||||
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
|
||||
|
||||
useEffect(() => {
|
||||
if (program) {
|
||||
const settings = (program.settingsJson as Record<string, any>) || {}
|
||||
setFormData({
|
||||
name: program.name,
|
||||
slug: program.slug || '',
|
||||
description: program.description || '',
|
||||
status: program.status,
|
||||
applyMode: settings.applyMode || 'round',
|
||||
})
|
||||
}
|
||||
}, [program])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updateProgram = trpc.program.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate()
|
||||
utils.program.get.invalidate({ id })
|
||||
toast.success('Program updated successfully')
|
||||
router.push(`/admin/programs/${id}`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update program')
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteProgram = trpc.program.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate()
|
||||
toast.success('Program deleted successfully')
|
||||
router.push('/admin/programs')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to delete program')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
updateProgram.mutate({
|
||||
id,
|
||||
name: formData.name,
|
||||
slug: formData.slug || undefined,
|
||||
description: formData.description || undefined,
|
||||
status: formData.status as 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
|
||||
settingsJson: {
|
||||
applyMode: formData.applyMode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Delete handled via AlertDialog in JSX
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/programs/${id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Program</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update program information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Program</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this program and all its rounds and projects.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteProgram.mutate({ id })}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Program Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about the program
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Program Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => setFormData({ ...formData, status: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">Edition Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder="e.g., mopc-2026"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL-friendly identifier for edition-wide applications (optional)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="applyMode">Application Flow</Label>
|
||||
<Select
|
||||
value={formData.applyMode}
|
||||
onValueChange={(value) => setFormData({ ...formData, applyMode: value as 'edition' | 'round' | 'both' })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="edition">Edition-wide only</SelectItem>
|
||||
<SelectItem value="round">Round-specific only</SelectItem>
|
||||
<SelectItem value="both">Allow both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls whether applicants apply to the program or specific rounds
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Link href={`/admin/programs/${id}`}>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,433 +1,433 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Target,
|
||||
Calendar,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface MilestoneFormData {
|
||||
name: string
|
||||
description: string
|
||||
isRequired: boolean
|
||||
deadlineOffsetDays: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
const defaultMilestoneForm: MilestoneFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
isRequired: false,
|
||||
deadlineOffsetDays: 30,
|
||||
sortOrder: 0,
|
||||
}
|
||||
|
||||
export default function MentorshipMilestonesPage() {
|
||||
const params = useParams()
|
||||
const programId = params.id as string
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<MilestoneFormData>(defaultMilestoneForm)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: milestones, isLoading } = trpc.mentor.getMilestones.useQuery({ programId })
|
||||
|
||||
const createMutation = trpc.mentor.createMilestone.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.getMilestones.invalidate({ programId })
|
||||
toast.success('Milestone created')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.mentor.updateMilestone.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.getMilestones.invalidate({ programId })
|
||||
toast.success('Milestone updated')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const deleteMutation = trpc.mentor.deleteMilestone.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.getMilestones.invalidate({ programId })
|
||||
toast.success('Milestone deleted')
|
||||
setDeleteId(null)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const reorderMutation = trpc.mentor.reorderMilestones.useMutation({
|
||||
onSuccess: () => utils.mentor.getMilestones.invalidate({ programId }),
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingId(null)
|
||||
setFormData(defaultMilestoneForm)
|
||||
}
|
||||
|
||||
const openEdit = (milestone: Record<string, unknown>) => {
|
||||
setEditingId(String(milestone.id))
|
||||
setFormData({
|
||||
name: String(milestone.name || ''),
|
||||
description: String(milestone.description || ''),
|
||||
isRequired: Boolean(milestone.isRequired),
|
||||
deadlineOffsetDays: Number(milestone.deadlineOffsetDays || 30),
|
||||
sortOrder: Number(milestone.sortOrder || 0),
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
const nextOrder = milestones ? (milestones as unknown[]).length : 0
|
||||
setFormData({ ...defaultMilestoneForm, sortOrder: nextOrder })
|
||||
setEditingId(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Milestone name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({
|
||||
milestoneId: editingId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
isRequired: formData.isRequired,
|
||||
deadlineOffsetDays: formData.deadlineOffsetDays,
|
||||
sortOrder: formData.sortOrder,
|
||||
})
|
||||
} else {
|
||||
createMutation.mutate({
|
||||
programId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
isRequired: formData.isRequired,
|
||||
deadlineOffsetDays: formData.deadlineOffsetDays,
|
||||
sortOrder: formData.sortOrder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const moveMilestone = (id: string, direction: 'up' | 'down') => {
|
||||
if (!milestones) return
|
||||
const list = milestones as Array<Record<string, unknown>>
|
||||
const index = list.findIndex((m) => String(m.id) === id)
|
||||
if (index === -1) return
|
||||
if (direction === 'up' && index === 0) return
|
||||
if (direction === 'down' && index === list.length - 1) return
|
||||
|
||||
const ids = list.map((m) => String(m.id))
|
||||
const [moved] = ids.splice(index, 1)
|
||||
ids.splice(direction === 'up' ? index - 1 : index + 1, 0, moved)
|
||||
|
||||
reorderMutation.mutate({ milestoneIds: ids })
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/programs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Programs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Mentorship Milestones
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure milestones for the mentorship program
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Milestone
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? 'Edit Milestone' : 'Add Milestone'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure a milestone for the mentorship program.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Business Plan Review"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
placeholder="Describe what this milestone involves..."
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="milestone-required"
|
||||
checked={formData.isRequired}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, isRequired: !!checked })
|
||||
}
|
||||
/>
|
||||
<label htmlFor="milestone-required" className="text-sm cursor-pointer">
|
||||
Required milestone
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Deadline Offset (days from program start)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={formData.deadlineOffsetDays}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
deadlineOffsetDays: parseInt(e.target.value) || 30,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editingId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Milestones list */}
|
||||
{isLoading ? (
|
||||
<MilestonesSkeleton />
|
||||
) : milestones && (milestones as unknown[]).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{(milestones as Array<Record<string, unknown>>).map((milestone, index) => {
|
||||
const completions = milestone.completions as Array<unknown> | undefined
|
||||
const completionCount = completions ? completions.length : 0
|
||||
|
||||
return (
|
||||
<Card key={String(milestone.id)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Order number and reorder buttons */}
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
disabled={index === 0 || reorderMutation.isPending}
|
||||
onClick={() => moveMilestone(String(milestone.id), 'up')}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium text-muted-foreground w-5 text-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
disabled={
|
||||
index === (milestones as unknown[]).length - 1 ||
|
||||
reorderMutation.isPending
|
||||
}
|
||||
onClick={() => moveMilestone(String(milestone.id), 'down')}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{String(milestone.name)}</span>
|
||||
{!!milestone.isRequired && (
|
||||
<Badge variant="default" className="text-xs">Required</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Calendar className="mr-1 h-3 w-3" />
|
||||
Day {String(milestone.deadlineOffsetDays || 30)}
|
||||
</Badge>
|
||||
{completionCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{completionCount} completions
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!!milestone.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{String(milestone.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(milestone)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteId(String(milestone.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Target className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No milestones defined</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add milestones to track mentor-mentee progress.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Milestone</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this milestone? Progress data associated
|
||||
with it may be lost.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
deleteId && deleteMutation.mutate({ milestoneId: deleteId })
|
||||
}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MilestonesSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-16 w-6" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Target,
|
||||
Calendar,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface MilestoneFormData {
|
||||
name: string
|
||||
description: string
|
||||
isRequired: boolean
|
||||
deadlineOffsetDays: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
const defaultMilestoneForm: MilestoneFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
isRequired: false,
|
||||
deadlineOffsetDays: 30,
|
||||
sortOrder: 0,
|
||||
}
|
||||
|
||||
export default function MentorshipMilestonesPage() {
|
||||
const params = useParams()
|
||||
const programId = params.id as string
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<MilestoneFormData>(defaultMilestoneForm)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: milestones, isLoading } = trpc.mentor.getMilestones.useQuery({ programId })
|
||||
|
||||
const createMutation = trpc.mentor.createMilestone.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.getMilestones.invalidate({ programId })
|
||||
toast.success('Milestone created')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.mentor.updateMilestone.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.getMilestones.invalidate({ programId })
|
||||
toast.success('Milestone updated')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const deleteMutation = trpc.mentor.deleteMilestone.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.mentor.getMilestones.invalidate({ programId })
|
||||
toast.success('Milestone deleted')
|
||||
setDeleteId(null)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const reorderMutation = trpc.mentor.reorderMilestones.useMutation({
|
||||
onSuccess: () => utils.mentor.getMilestones.invalidate({ programId }),
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingId(null)
|
||||
setFormData(defaultMilestoneForm)
|
||||
}
|
||||
|
||||
const openEdit = (milestone: Record<string, unknown>) => {
|
||||
setEditingId(String(milestone.id))
|
||||
setFormData({
|
||||
name: String(milestone.name || ''),
|
||||
description: String(milestone.description || ''),
|
||||
isRequired: Boolean(milestone.isRequired),
|
||||
deadlineOffsetDays: Number(milestone.deadlineOffsetDays || 30),
|
||||
sortOrder: Number(milestone.sortOrder || 0),
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
const nextOrder = milestones ? (milestones as unknown[]).length : 0
|
||||
setFormData({ ...defaultMilestoneForm, sortOrder: nextOrder })
|
||||
setEditingId(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Milestone name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({
|
||||
milestoneId: editingId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
isRequired: formData.isRequired,
|
||||
deadlineOffsetDays: formData.deadlineOffsetDays,
|
||||
sortOrder: formData.sortOrder,
|
||||
})
|
||||
} else {
|
||||
createMutation.mutate({
|
||||
programId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
isRequired: formData.isRequired,
|
||||
deadlineOffsetDays: formData.deadlineOffsetDays,
|
||||
sortOrder: formData.sortOrder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const moveMilestone = (id: string, direction: 'up' | 'down') => {
|
||||
if (!milestones) return
|
||||
const list = milestones as Array<Record<string, unknown>>
|
||||
const index = list.findIndex((m) => String(m.id) === id)
|
||||
if (index === -1) return
|
||||
if (direction === 'up' && index === 0) return
|
||||
if (direction === 'down' && index === list.length - 1) return
|
||||
|
||||
const ids = list.map((m) => String(m.id))
|
||||
const [moved] = ids.splice(index, 1)
|
||||
ids.splice(direction === 'up' ? index - 1 : index + 1, 0, moved)
|
||||
|
||||
reorderMutation.mutate({ milestoneIds: ids })
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/programs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Programs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Mentorship Milestones
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure milestones for the mentorship program
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Milestone
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? 'Edit Milestone' : 'Add Milestone'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure a milestone for the mentorship program.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Business Plan Review"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
placeholder="Describe what this milestone involves..."
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="milestone-required"
|
||||
checked={formData.isRequired}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, isRequired: !!checked })
|
||||
}
|
||||
/>
|
||||
<label htmlFor="milestone-required" className="text-sm cursor-pointer">
|
||||
Required milestone
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Deadline Offset (days from program start)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={formData.deadlineOffsetDays}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
deadlineOffsetDays: parseInt(e.target.value) || 30,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editingId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Milestones list */}
|
||||
{isLoading ? (
|
||||
<MilestonesSkeleton />
|
||||
) : milestones && (milestones as unknown[]).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{(milestones as Array<Record<string, unknown>>).map((milestone, index) => {
|
||||
const completions = milestone.completions as Array<unknown> | undefined
|
||||
const completionCount = completions ? completions.length : 0
|
||||
|
||||
return (
|
||||
<Card key={String(milestone.id)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Order number and reorder buttons */}
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
disabled={index === 0 || reorderMutation.isPending}
|
||||
onClick={() => moveMilestone(String(milestone.id), 'up')}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium text-muted-foreground w-5 text-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
disabled={
|
||||
index === (milestones as unknown[]).length - 1 ||
|
||||
reorderMutation.isPending
|
||||
}
|
||||
onClick={() => moveMilestone(String(milestone.id), 'down')}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{String(milestone.name)}</span>
|
||||
{!!milestone.isRequired && (
|
||||
<Badge variant="default" className="text-xs">Required</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Calendar className="mr-1 h-3 w-3" />
|
||||
Day {String(milestone.deadlineOffsetDays || 30)}
|
||||
</Badge>
|
||||
{completionCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{completionCount} completions
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!!milestone.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{String(milestone.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(milestone)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteId(String(milestone.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Target className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No milestones defined</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add milestones to track mentor-mentee progress.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Milestone</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this milestone? Progress data associated
|
||||
with it may be lost.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
deleteId && deleteMutation.mutate({ milestoneId: deleteId })
|
||||
}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MilestonesSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-16 w-6" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,140 +1,140 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { api } from '@/lib/trpc/server'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
interface ProgramDetailPageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
||||
DRAFT: 'secondary',
|
||||
ACTIVE: 'default',
|
||||
CLOSED: 'success',
|
||||
ARCHIVED: 'secondary',
|
||||
}
|
||||
|
||||
export default async function ProgramDetailPage({ params }: ProgramDetailPageProps) {
|
||||
const { id } = await params
|
||||
const caller = await api()
|
||||
|
||||
let program
|
||||
try {
|
||||
program = await caller.program.get({ id })
|
||||
} catch {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/programs">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold">{program.name}</h1>
|
||||
<Badge variant={statusColors[program.status] || 'secondary'}>
|
||||
{program.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{program.year} Edition
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/programs/${id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{program.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Description</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{program.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Stages</CardTitle>
|
||||
<CardDescription>
|
||||
Pipeline stages for this program
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/admin/rounds/pipelines?programId=${id}`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Manage Pipeline
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(program.stages as Array<{ id: string; name: string; status: string; _count: { projects: number; assignments: number }; createdAt?: Date }>).length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No stages created yet. Set up a pipeline to get started.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Stage</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Projects</TableHead>
|
||||
<TableHead>Assignments</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(program.stages as Array<{ id: string; name: string; status: string; _count: { projects: number; assignments: number } }>).map((stage) => (
|
||||
<TableRow key={stage.id}>
|
||||
<TableCell>
|
||||
<span className="font-medium">
|
||||
{stage.name}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[stage.status] || 'secondary'}>
|
||||
{stage.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{stage._count.projects}</TableCell>
|
||||
<TableCell>{stage._count.assignments}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { api } from '@/lib/trpc/server'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
interface ProgramDetailPageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
||||
DRAFT: 'secondary',
|
||||
ACTIVE: 'default',
|
||||
CLOSED: 'success',
|
||||
ARCHIVED: 'secondary',
|
||||
}
|
||||
|
||||
export default async function ProgramDetailPage({ params }: ProgramDetailPageProps) {
|
||||
const { id } = await params
|
||||
const caller = await api()
|
||||
|
||||
let program
|
||||
try {
|
||||
program = await caller.program.get({ id })
|
||||
} catch {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/programs">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold">{program.name}</h1>
|
||||
<Badge variant={statusColors[program.status] || 'secondary'}>
|
||||
{program.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{program.year} Edition
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/admin/programs/${id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{program.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Description</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{program.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Stages</CardTitle>
|
||||
<CardDescription>
|
||||
Pipeline stages for this program
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/admin/rounds/pipelines?programId=${id}`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Manage Pipeline
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(program.stages as Array<{ id: string; name: string; status: string; _count: { projects: number; assignments: number }; createdAt?: Date }>).length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No stages created yet. Set up a pipeline to get started.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Stage</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Projects</TableHead>
|
||||
<TableHead>Assignments</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(program.stages as Array<{ id: string; name: string; status: string; _count: { projects: number; assignments: number } }>).map((stage) => (
|
||||
<TableRow key={stage.id}>
|
||||
<TableCell>
|
||||
<span className="font-medium">
|
||||
{stage.name}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[stage.status] || 'secondary'}>
|
||||
{stage.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{stage._count.projects}</TableCell>
|
||||
<TableCell>{stage._count.assignments}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,133 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function NewProgramPage() {
|
||||
const router = useRouter()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createProgram = trpc.program.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate()
|
||||
toast.success('Program created successfully')
|
||||
router.push('/admin/programs')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to create program')
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const name = formData.get('name') as string
|
||||
const year = parseInt(formData.get('year') as string, 10)
|
||||
const description = formData.get('description') as string
|
||||
|
||||
createProgram.mutate({
|
||||
name,
|
||||
year,
|
||||
description: description || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/programs">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Create Program</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Set up a new ocean protection program
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Program Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about the program
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Program Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="e.g., Monaco Ocean Protection Challenge 2026"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="year">Year *</Label>
|
||||
<Input
|
||||
id="year"
|
||||
name="year"
|
||||
type="number"
|
||||
min={2020}
|
||||
max={2100}
|
||||
defaultValue={currentYear}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Describe the program objectives and scope..."
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Link href="/admin/programs">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Program
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function NewProgramPage() {
|
||||
const router = useRouter()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createProgram = trpc.program.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate()
|
||||
toast.success('Program created successfully')
|
||||
router.push('/admin/programs')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to create program')
|
||||
setIsSubmitting(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const name = formData.get('name') as string
|
||||
const year = parseInt(formData.get('year') as string, 10)
|
||||
const description = formData.get('description') as string
|
||||
|
||||
createProgram.mutate({
|
||||
name,
|
||||
year,
|
||||
description: description || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/programs">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Create Program</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Set up a new ocean protection program
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Program Details</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about the program
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Program Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="e.g., Monaco Ocean Protection Challenge 2026"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="year">Year *</Label>
|
||||
<Input
|
||||
id="year"
|
||||
name="year"
|
||||
type="number"
|
||||
min={2020}
|
||||
max={2100}
|
||||
defaultValue={currentYear}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Describe the program objectives and scope..."
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Link href="/admin/programs">
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Program
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,276 +1,276 @@
|
||||
import { Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
FolderKanban,
|
||||
Eye,
|
||||
Pencil,
|
||||
Copy,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
async function ProgramsContent() {
|
||||
const programs = await prisma.program.findMany({
|
||||
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
|
||||
include: {
|
||||
pipelines: {
|
||||
include: {
|
||||
tracks: {
|
||||
include: {
|
||||
stages: {
|
||||
select: { id: true, status: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// Flatten stages per program for convenience
|
||||
const programsWithStageCounts = programs.map((p) => {
|
||||
const allStages = p.pipelines.flatMap((pl) =>
|
||||
pl.tracks.flatMap((t) => t.stages)
|
||||
)
|
||||
const activeStages = allStages.filter((s) => s.status === 'STAGE_ACTIVE')
|
||||
return { ...p, stageCount: allStages.length, activeStageCount: activeStages.length }
|
||||
})
|
||||
|
||||
if (programsWithStageCounts.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FolderKanban className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No programs yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create your first program to start managing projects and rounds
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/programs/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Program
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
||||
ACTIVE: 'default',
|
||||
COMPLETED: 'success',
|
||||
DRAFT: 'secondary',
|
||||
ARCHIVED: 'secondary',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop table view */}
|
||||
<Card className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead>Year</TableHead>
|
||||
<TableHead>Stages</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{programsWithStageCounts.map((program) => (
|
||||
<TableRow key={program.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{program.name}</p>
|
||||
{program.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{program.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{program.year}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p>{program.stageCount} total</p>
|
||||
{program.activeStageCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{program.activeStageCount} active
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[program.status] || 'secondary'}>
|
||||
{program.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDateOnly(program.createdAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/programs/${program.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/programs/${program.id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/programs/${program.id}/apply-settings` as Route}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Apply Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className="space-y-4 md:hidden">
|
||||
{programsWithStageCounts.map((program) => (
|
||||
<Card key={program.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">{program.name}</CardTitle>
|
||||
<CardDescription>{program.year}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={statusColors[program.status] || 'secondary'}>
|
||||
{program.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Stages</span>
|
||||
<span>
|
||||
{program.stageCount} ({program.activeStageCount} active)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{formatDateOnly(program.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||
<Link href={`/admin/programs/${program.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||
<Link href={`/admin/programs/${program.id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||
<Link href={`/admin/programs/${program.id}/apply-settings` as Route}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Apply
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgramsSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-9" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProgramsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Programs</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your ocean protection programs
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/programs/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Program
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<ProgramsSkeleton />}>
|
||||
<ProgramsContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
FolderKanban,
|
||||
Eye,
|
||||
Pencil,
|
||||
Copy,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
async function ProgramsContent() {
|
||||
const programs = await prisma.program.findMany({
|
||||
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
|
||||
include: {
|
||||
pipelines: {
|
||||
include: {
|
||||
tracks: {
|
||||
include: {
|
||||
stages: {
|
||||
select: { id: true, status: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// Flatten stages per program for convenience
|
||||
const programsWithStageCounts = programs.map((p) => {
|
||||
const allStages = p.pipelines.flatMap((pl) =>
|
||||
pl.tracks.flatMap((t) => t.stages)
|
||||
)
|
||||
const activeStages = allStages.filter((s) => s.status === 'STAGE_ACTIVE')
|
||||
return { ...p, stageCount: allStages.length, activeStageCount: activeStages.length }
|
||||
})
|
||||
|
||||
if (programsWithStageCounts.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FolderKanban className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No programs yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create your first program to start managing projects and rounds
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/programs/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Program
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
|
||||
ACTIVE: 'default',
|
||||
COMPLETED: 'success',
|
||||
DRAFT: 'secondary',
|
||||
ARCHIVED: 'secondary',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop table view */}
|
||||
<Card className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead>Year</TableHead>
|
||||
<TableHead>Stages</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{programsWithStageCounts.map((program) => (
|
||||
<TableRow key={program.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{program.name}</p>
|
||||
{program.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{program.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{program.year}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p>{program.stageCount} total</p>
|
||||
{program.activeStageCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{program.activeStageCount} active
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[program.status] || 'secondary'}>
|
||||
{program.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDateOnly(program.createdAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/programs/${program.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/programs/${program.id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/programs/${program.id}/apply-settings` as Route}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Apply Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className="space-y-4 md:hidden">
|
||||
{programsWithStageCounts.map((program) => (
|
||||
<Card key={program.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">{program.name}</CardTitle>
|
||||
<CardDescription>{program.year}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={statusColors[program.status] || 'secondary'}>
|
||||
{program.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Stages</span>
|
||||
<span>
|
||||
{program.stageCount} ({program.activeStageCount} active)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{formatDateOnly(program.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||
<Link href={`/admin/programs/${program.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||
<Link href={`/admin/programs/${program.id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||
<Link href={`/admin/programs/${program.id}/apply-settings` as Route}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Apply
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgramsSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-9" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProgramsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Programs</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your ocean protection programs
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/programs/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Program
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<ProgramsSkeleton />}>
|
||||
<ProgramsContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Select,
|
||||
@@ -103,6 +104,7 @@ const fileTypeIcons: Record<string, React.ReactNode> = {
|
||||
function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
const [statusNotificationConfirmed, setStatusNotificationConfirmed] = useState(false)
|
||||
|
||||
// Fetch project data
|
||||
const { data: project, isLoading } = trpc.project.get.useQuery({
|
||||
@@ -172,6 +174,24 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
}, [project, form])
|
||||
|
||||
const tags = form.watch('tags')
|
||||
const selectedStatus = form.watch('status')
|
||||
const previousStatus = (project?.status ?? 'SUBMITTED') as UpdateProjectForm['status']
|
||||
const statusTriggersNotifications = ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus)
|
||||
const requiresStatusNotificationConfirmation = Boolean(
|
||||
project && selectedStatus !== previousStatus && statusTriggersNotifications
|
||||
)
|
||||
const notificationRecipientEmails = Array.from(
|
||||
new Set(
|
||||
(project?.teamMembers ?? [])
|
||||
.map((member) => member.user?.email?.toLowerCase().trim() ?? '')
|
||||
.filter((email) => email.length > 0)
|
||||
)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setStatusNotificationConfirmed(false)
|
||||
form.clearErrors('status')
|
||||
}, [selectedStatus, form])
|
||||
|
||||
// Add tag
|
||||
const addTag = useCallback(() => {
|
||||
@@ -194,6 +214,14 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
)
|
||||
|
||||
const onSubmit = async (data: UpdateProjectForm) => {
|
||||
if (requiresStatusNotificationConfirmation && !statusNotificationConfirmed) {
|
||||
form.setError('status', {
|
||||
type: 'manual',
|
||||
message: 'Confirm participant notifications before saving this status change.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await updateProject.mutateAsync({
|
||||
id: projectId,
|
||||
title: data.title,
|
||||
@@ -370,6 +398,39 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
<SelectItem value="REJECTED">Rejected</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{requiresStatusNotificationConfirmation && (
|
||||
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||
<p className="text-xs font-medium">
|
||||
Participant Notification Check
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saving this status will send automated notifications.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recipients ({notificationRecipientEmails.length}):{' '}
|
||||
{notificationRecipientEmails.length > 0
|
||||
? notificationRecipientEmails.slice(0, 8).join(', ')
|
||||
: 'No linked participant accounts found'}
|
||||
{notificationRecipientEmails.length > 8 ? ', ...' : ''}
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="confirm-status-notifications"
|
||||
checked={statusNotificationConfirmed}
|
||||
onCheckedChange={(checked) => {
|
||||
const confirmed = checked === true
|
||||
setStatusNotificationConfirmed(confirmed)
|
||||
if (confirmed) {
|
||||
form.clearErrors('status')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormLabel htmlFor="confirm-status-notifications" className="text-sm font-normal leading-5">
|
||||
I verified participant recipients and approve sending automated notifications.
|
||||
</FormLabel>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -557,7 +618,10 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<Link href={`/admin/projects/${projectId}`}>Cancel</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || (requiresStatusNotificationConfirmation && !statusNotificationConfirmed)}
|
||||
>
|
||||
{updateProject.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
|
||||
@@ -1,393 +1,393 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Users,
|
||||
User,
|
||||
Check,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { getInitials } from '@/lib/utils'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
// Type for mentor suggestion from the API
|
||||
interface MentorSuggestion {
|
||||
mentorId: string
|
||||
confidenceScore: number
|
||||
expertiseMatchScore: number
|
||||
reasoning: string
|
||||
mentor: {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string
|
||||
expertiseTags: string[]
|
||||
assignmentCount: number
|
||||
} | null
|
||||
}
|
||||
|
||||
function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Fetch project
|
||||
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({
|
||||
id: projectId,
|
||||
})
|
||||
|
||||
// Fetch suggestions
|
||||
const { data: suggestions, isLoading: suggestionsLoading, refetch } = trpc.mentor.getSuggestions.useQuery(
|
||||
{ projectId, limit: 5 },
|
||||
{ enabled: !!project && !project.mentorAssignment }
|
||||
)
|
||||
|
||||
// Assign mentor mutation
|
||||
const assignMutation = trpc.mentor.assign.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Mentor assigned!')
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-assign mutation
|
||||
const autoAssignMutation = trpc.mentor.autoAssign.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Mentor auto-assigned!')
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Unassign mutation
|
||||
const unassignMutation = trpc.mentor.unassign.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Mentor removed')
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleAssign = (mentorId: string, suggestion?: MentorSuggestion) => {
|
||||
assignMutation.mutate({
|
||||
projectId,
|
||||
mentorId,
|
||||
method: suggestion ? 'AI_SUGGESTED' : 'MANUAL',
|
||||
aiConfidenceScore: suggestion?.confidenceScore,
|
||||
expertiseMatchScore: suggestion?.expertiseMatchScore,
|
||||
aiReasoning: suggestion?.reasoning,
|
||||
})
|
||||
}
|
||||
|
||||
if (projectLoading) {
|
||||
return <MentorAssignmentSkeleton />
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p>Project not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const hasMentor = !!project.mentorAssignment
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/projects/${projectId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Mentor Assignment</h1>
|
||||
<p className="text-muted-foreground">{project.title}</p>
|
||||
</div>
|
||||
|
||||
{/* Current Assignment */}
|
||||
{hasMentor && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Current Mentor</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarFallback>
|
||||
{getInitials(project.mentorAssignment!.mentor.name || project.mentorAssignment!.mentor.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{project.mentorAssignment!.mentor.name || 'Unnamed'}</p>
|
||||
<p className="text-sm text-muted-foreground">{project.mentorAssignment!.mentor.email}</p>
|
||||
{project.mentorAssignment!.mentor.expertiseTags && project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge variant="outline" className="mb-2">
|
||||
{project.mentorAssignment!.method.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
<div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => unassignMutation.mutate({ projectId })}
|
||||
disabled={unassignMutation.isPending}
|
||||
>
|
||||
{unassignMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Remove'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* AI Suggestions */}
|
||||
{!hasMentor && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
AI-Suggested Mentors
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Mentors matched based on expertise and project needs
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={suggestionsLoading}
|
||||
>
|
||||
{suggestionsLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Refresh'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => autoAssignMutation.mutate({ projectId, useAI: true })}
|
||||
disabled={autoAssignMutation.isPending}
|
||||
>
|
||||
{autoAssignMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Auto-Assign Best Match
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{suggestionsLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : suggestions?.suggestions.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
No mentor suggestions available. Try adding more users with expertise tags.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{suggestions?.suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={suggestion.mentorId}
|
||||
className={`p-4 rounded-lg border-2 transition-colors ${
|
||||
selectedMentorId === suggestion.mentorId
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div className="relative">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarFallback>
|
||||
{suggestion.mentor ? getInitials(suggestion.mentor.name || suggestion.mentor.email) : '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{index === 0 && (
|
||||
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
1
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{suggestion.mentor?.name || 'Unnamed'}</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{suggestion.mentor?.assignmentCount || 0} projects
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{suggestion.mentor?.email}</p>
|
||||
|
||||
{/* Expertise tags */}
|
||||
{suggestion.mentor?.expertiseTags && suggestion.mentor.expertiseTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{suggestion.mentor.expertiseTags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Match scores */}
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground w-28">Confidence:</span>
|
||||
<Progress value={suggestion.confidenceScore * 100} className="flex-1 h-2" />
|
||||
<span className="w-12 text-right">{(suggestion.confidenceScore * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground w-28">Expertise Match:</span>
|
||||
<Progress value={suggestion.expertiseMatchScore * 100} className="flex-1 h-2" />
|
||||
<span className="w-12 text-right">{(suggestion.expertiseMatchScore * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Reasoning */}
|
||||
{suggestion.reasoning && (
|
||||
<p className="mt-2 text-sm text-muted-foreground italic">
|
||||
"{suggestion.reasoning}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleAssign(suggestion.mentorId, suggestion)}
|
||||
disabled={assignMutation.isPending}
|
||||
variant={selectedMentorId === suggestion.mentorId ? 'default' : 'outline'}
|
||||
>
|
||||
{assignMutation.isPending && selectedMentorId === suggestion.mentorId ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Assign
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Manual Assignment */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Manual Assignment
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Search and select a mentor manually
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use the AI suggestions above or search for a specific user in the Users section
|
||||
to assign them as a mentor manually.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MentorAssignmentSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MentorAssignmentPage({ params }: PageProps) {
|
||||
const { id } = use(params)
|
||||
|
||||
return (
|
||||
<Suspense fallback={<MentorAssignmentSkeleton />}>
|
||||
<MentorAssignmentContent projectId={id} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Users,
|
||||
User,
|
||||
Check,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { getInitials } from '@/lib/utils'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
// Type for mentor suggestion from the API
|
||||
interface MentorSuggestion {
|
||||
mentorId: string
|
||||
confidenceScore: number
|
||||
expertiseMatchScore: number
|
||||
reasoning: string
|
||||
mentor: {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string
|
||||
expertiseTags: string[]
|
||||
assignmentCount: number
|
||||
} | null
|
||||
}
|
||||
|
||||
function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Fetch project
|
||||
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({
|
||||
id: projectId,
|
||||
})
|
||||
|
||||
// Fetch suggestions
|
||||
const { data: suggestions, isLoading: suggestionsLoading, refetch } = trpc.mentor.getSuggestions.useQuery(
|
||||
{ projectId, limit: 5 },
|
||||
{ enabled: !!project && !project.mentorAssignment }
|
||||
)
|
||||
|
||||
// Assign mentor mutation
|
||||
const assignMutation = trpc.mentor.assign.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Mentor assigned!')
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-assign mutation
|
||||
const autoAssignMutation = trpc.mentor.autoAssign.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Mentor auto-assigned!')
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Unassign mutation
|
||||
const unassignMutation = trpc.mentor.unassign.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Mentor removed')
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleAssign = (mentorId: string, suggestion?: MentorSuggestion) => {
|
||||
assignMutation.mutate({
|
||||
projectId,
|
||||
mentorId,
|
||||
method: suggestion ? 'AI_SUGGESTED' : 'MANUAL',
|
||||
aiConfidenceScore: suggestion?.confidenceScore,
|
||||
expertiseMatchScore: suggestion?.expertiseMatchScore,
|
||||
aiReasoning: suggestion?.reasoning,
|
||||
})
|
||||
}
|
||||
|
||||
if (projectLoading) {
|
||||
return <MentorAssignmentSkeleton />
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p>Project not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const hasMentor = !!project.mentorAssignment
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/projects/${projectId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Mentor Assignment</h1>
|
||||
<p className="text-muted-foreground">{project.title}</p>
|
||||
</div>
|
||||
|
||||
{/* Current Assignment */}
|
||||
{hasMentor && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Current Mentor</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarFallback>
|
||||
{getInitials(project.mentorAssignment!.mentor.name || project.mentorAssignment!.mentor.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{project.mentorAssignment!.mentor.name || 'Unnamed'}</p>
|
||||
<p className="text-sm text-muted-foreground">{project.mentorAssignment!.mentor.email}</p>
|
||||
{project.mentorAssignment!.mentor.expertiseTags && project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge variant="outline" className="mb-2">
|
||||
{project.mentorAssignment!.method.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
<div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => unassignMutation.mutate({ projectId })}
|
||||
disabled={unassignMutation.isPending}
|
||||
>
|
||||
{unassignMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Remove'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* AI Suggestions */}
|
||||
{!hasMentor && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
AI-Suggested Mentors
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Mentors matched based on expertise and project needs
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={suggestionsLoading}
|
||||
>
|
||||
{suggestionsLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Refresh'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => autoAssignMutation.mutate({ projectId, useAI: true })}
|
||||
disabled={autoAssignMutation.isPending}
|
||||
>
|
||||
{autoAssignMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Auto-Assign Best Match
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{suggestionsLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : suggestions?.suggestions.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
No mentor suggestions available. Try adding more users with expertise tags.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{suggestions?.suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={suggestion.mentorId}
|
||||
className={`p-4 rounded-lg border-2 transition-colors ${
|
||||
selectedMentorId === suggestion.mentorId
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div className="relative">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarFallback>
|
||||
{suggestion.mentor ? getInitials(suggestion.mentor.name || suggestion.mentor.email) : '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{index === 0 && (
|
||||
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
1
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{suggestion.mentor?.name || 'Unnamed'}</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{suggestion.mentor?.assignmentCount || 0} projects
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{suggestion.mentor?.email}</p>
|
||||
|
||||
{/* Expertise tags */}
|
||||
{suggestion.mentor?.expertiseTags && suggestion.mentor.expertiseTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{suggestion.mentor.expertiseTags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Match scores */}
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground w-28">Confidence:</span>
|
||||
<Progress value={suggestion.confidenceScore * 100} className="flex-1 h-2" />
|
||||
<span className="w-12 text-right">{(suggestion.confidenceScore * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground w-28">Expertise Match:</span>
|
||||
<Progress value={suggestion.expertiseMatchScore * 100} className="flex-1 h-2" />
|
||||
<span className="w-12 text-right">{(suggestion.expertiseMatchScore * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Reasoning */}
|
||||
{suggestion.reasoning && (
|
||||
<p className="mt-2 text-sm text-muted-foreground italic">
|
||||
"{suggestion.reasoning}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleAssign(suggestion.mentorId, suggestion)}
|
||||
disabled={assignMutation.isPending}
|
||||
variant={selectedMentorId === suggestion.mentorId ? 'default' : 'outline'}
|
||||
>
|
||||
{assignMutation.isPending && selectedMentorId === suggestion.mentorId ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Assign
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Manual Assignment */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Manual Assignment
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Search and select a mentor manually
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use the AI suggestions above or search for a specific user in the Users section
|
||||
to assign them as a mentor manually.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MentorAssignmentSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MentorAssignmentPage({ params }: PageProps) {
|
||||
const { id } = use(params)
|
||||
|
||||
return (
|
||||
<Suspense fallback={<MentorAssignmentSkeleton />}>
|
||||
<MentorAssignmentContent projectId={id} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,242 +1,242 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CSVImportForm } from '@/components/forms/csv-import-form'
|
||||
import { NotionImportForm } from '@/components/forms/notion-import-form'
|
||||
import { TypeformImportForm } from '@/components/forms/typeform-import-form'
|
||||
import { ArrowLeft, FileSpreadsheet, AlertCircle, Database, FileText } from 'lucide-react'
|
||||
|
||||
function ImportPageContent() {
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
const searchParams = useSearchParams()
|
||||
const stageIdParam = searchParams.get('stage')
|
||||
|
||||
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
|
||||
|
||||
// Fetch active programs with stages
|
||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
|
||||
status: 'ACTIVE',
|
||||
includeStages: true,
|
||||
})
|
||||
|
||||
// Get all stages from programs
|
||||
const stages = programs?.flatMap((p) =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({
|
||||
...s,
|
||||
programId: p.id,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
|
||||
const selectedStage = stages.find((s: { id: string }) => s.id === selectedStageId)
|
||||
|
||||
if (loadingPrograms) {
|
||||
return <ImportPageSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Import Projects</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Import projects from a CSV file into a stage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stage selection */}
|
||||
{!selectedStageId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Select Stage</CardTitle>
|
||||
<CardDescription>
|
||||
Choose the stage you want to import projects into
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{stages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Active Stages</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a stage first before importing projects
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/rounds/new-pipeline">Create Pipeline</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select value={selectedStageId} onValueChange={setSelectedStageId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
<div className="flex flex-col">
|
||||
<span>{stage.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{stage.programName}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selectedStageId) {
|
||||
router.push(`/admin/projects/import?stage=${selectedStageId}`)
|
||||
}
|
||||
}}
|
||||
disabled={!selectedStageId}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Import form */}
|
||||
{selectedStageId && selectedStage && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Importing into: {selectedStage.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedStage.programName}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
setSelectedStageId('')
|
||||
router.push('/admin/projects/import')
|
||||
}}
|
||||
>
|
||||
Change Stage
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="csv" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="csv" className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
CSV
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notion" className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
Notion
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="typeform" className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Typeform
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="csv" className="mt-4">
|
||||
<CSVImportForm
|
||||
programId={selectedStage.programId}
|
||||
stageName={selectedStage.name}
|
||||
onSuccess={() => {
|
||||
utils.project.list.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="notion" className="mt-4">
|
||||
<NotionImportForm
|
||||
programId={selectedStage.programId}
|
||||
stageName={selectedStage.name}
|
||||
onSuccess={() => {
|
||||
utils.project.list.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="typeform" className="mt-4">
|
||||
<TypeformImportForm
|
||||
programId={selectedStage.programId}
|
||||
stageName={selectedStage.name}
|
||||
onSuccess={() => {
|
||||
utils.project.list.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-64" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ImportProjectsPage() {
|
||||
return (
|
||||
<Suspense fallback={<ImportPageSkeleton />}>
|
||||
<ImportPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { Suspense, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CSVImportForm } from '@/components/forms/csv-import-form'
|
||||
import { NotionImportForm } from '@/components/forms/notion-import-form'
|
||||
import { TypeformImportForm } from '@/components/forms/typeform-import-form'
|
||||
import { ArrowLeft, FileSpreadsheet, AlertCircle, Database, FileText } from 'lucide-react'
|
||||
|
||||
function ImportPageContent() {
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
const searchParams = useSearchParams()
|
||||
const stageIdParam = searchParams.get('stage')
|
||||
|
||||
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
|
||||
|
||||
// Fetch active programs with stages
|
||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
|
||||
status: 'ACTIVE',
|
||||
includeStages: true,
|
||||
})
|
||||
|
||||
// Get all stages from programs
|
||||
const stages = programs?.flatMap((p) =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({
|
||||
...s,
|
||||
programId: p.id,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
|
||||
const selectedStage = stages.find((s: { id: string }) => s.id === selectedStageId)
|
||||
|
||||
if (loadingPrograms) {
|
||||
return <ImportPageSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Import Projects</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Import projects from a CSV file into a stage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stage selection */}
|
||||
{!selectedStageId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Select Stage</CardTitle>
|
||||
<CardDescription>
|
||||
Choose the stage you want to import projects into
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{stages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Active Stages</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a stage first before importing projects
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/rounds/new-pipeline">Create Pipeline</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select value={selectedStageId} onValueChange={setSelectedStageId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
<div className="flex flex-col">
|
||||
<span>{stage.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{stage.programName}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selectedStageId) {
|
||||
router.push(`/admin/projects/import?stage=${selectedStageId}`)
|
||||
}
|
||||
}}
|
||||
disabled={!selectedStageId}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Import form */}
|
||||
{selectedStageId && selectedStage && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Importing into: {selectedStage.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedStage.programName}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
setSelectedStageId('')
|
||||
router.push('/admin/projects/import')
|
||||
}}
|
||||
>
|
||||
Change Stage
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="csv" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="csv" className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
CSV
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notion" className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
Notion
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="typeform" className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Typeform
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="csv" className="mt-4">
|
||||
<CSVImportForm
|
||||
programId={selectedStage.programId}
|
||||
stageName={selectedStage.name}
|
||||
onSuccess={() => {
|
||||
utils.project.list.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="notion" className="mt-4">
|
||||
<NotionImportForm
|
||||
programId={selectedStage.programId}
|
||||
stageName={selectedStage.name}
|
||||
onSuccess={() => {
|
||||
utils.project.list.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="typeform" className="mt-4">
|
||||
<TypeformImportForm
|
||||
programId={selectedStage.programId}
|
||||
stageName={selectedStage.name}
|
||||
onSuccess={() => {
|
||||
utils.project.list.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="mt-2 h-4 w-64" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ImportProjectsPage() {
|
||||
return (
|
||||
<Suspense fallback={<ImportPageSkeleton />}>
|
||||
<ImportPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -392,11 +392,14 @@ export default function ProjectsPage() {
|
||||
const [allMatchingSelected, setAllMatchingSelected] = useState(false)
|
||||
const [bulkStatus, setBulkStatus] = useState<string>('')
|
||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
||||
const [bulkNotificationsConfirmed, setBulkNotificationsConfirmed] = useState(false)
|
||||
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
|
||||
const [bulkAssignStageId, setBulkAssignStageId] = useState('')
|
||||
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
|
||||
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
|
||||
|
||||
const bulkStatusTriggersNotifications = ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(bulkStatus)
|
||||
|
||||
// Query for fetching all matching IDs (used for "select all across pages")
|
||||
const allIdsQuery = trpc.project.listAllIds.useQuery(
|
||||
{
|
||||
@@ -452,6 +455,26 @@ export default function ProjectsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const bulkNotificationPreview = trpc.project.previewStatusNotificationRecipients.useQuery(
|
||||
{
|
||||
ids: Array.from(selectedIds),
|
||||
status: (bulkStatus || 'SUBMITTED') as
|
||||
| 'SUBMITTED'
|
||||
| 'ELIGIBLE'
|
||||
| 'ASSIGNED'
|
||||
| 'SEMIFINALIST'
|
||||
| 'FINALIST'
|
||||
| 'REJECTED',
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
bulkConfirmOpen &&
|
||||
selectedIds.size > 0 &&
|
||||
bulkStatusTriggersNotifications,
|
||||
staleTime: 30_000,
|
||||
}
|
||||
)
|
||||
|
||||
const bulkAssignToStage = trpc.projectPool.assignToStage.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to stage`)
|
||||
@@ -524,15 +547,21 @@ export default function ProjectsPage() {
|
||||
setSelectedIds(new Set())
|
||||
setAllMatchingSelected(false)
|
||||
setBulkStatus('')
|
||||
setBulkNotificationsConfirmed(false)
|
||||
}
|
||||
|
||||
const handleBulkApply = () => {
|
||||
if (!bulkStatus || selectedIds.size === 0) return
|
||||
setBulkNotificationsConfirmed(false)
|
||||
setBulkConfirmOpen(true)
|
||||
}
|
||||
|
||||
const handleBulkConfirm = () => {
|
||||
if (!bulkStatus || selectedIds.size === 0) return
|
||||
if (bulkStatusTriggersNotifications && !bulkNotificationsConfirmed) {
|
||||
toast.error('Confirm participant recipients before sending notifications')
|
||||
return
|
||||
}
|
||||
bulkUpdateStatus.mutate({
|
||||
ids: Array.from(selectedIds),
|
||||
status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED',
|
||||
@@ -1283,7 +1312,15 @@ export default function ProjectsPage() {
|
||||
)}
|
||||
|
||||
{/* Bulk Status Update Confirmation Dialog */}
|
||||
<AlertDialog open={bulkConfirmOpen} onOpenChange={setBulkConfirmOpen}>
|
||||
<AlertDialog
|
||||
open={bulkConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
setBulkConfirmOpen(open)
|
||||
if (!open) {
|
||||
setBulkNotificationsConfirmed(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Update Project Status</AlertDialogTitle>
|
||||
@@ -1302,6 +1339,64 @@ export default function ProjectsPage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bulkStatusTriggersNotifications && (
|
||||
<div className="space-y-3 rounded-md border bg-muted/20 p-3">
|
||||
<p className="text-sm font-medium">Participant Notification Check</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Review recipients before automated emails are sent.
|
||||
</p>
|
||||
|
||||
{bulkNotificationPreview.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading recipients...
|
||||
</div>
|
||||
) : bulkNotificationPreview.data ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{bulkNotificationPreview.data.totalRecipients} recipient
|
||||
{bulkNotificationPreview.data.totalRecipients !== 1 ? 's' : ''} across{' '}
|
||||
{bulkNotificationPreview.data.projectsWithRecipients} project
|
||||
{bulkNotificationPreview.data.projectsWithRecipients !== 1 ? 's' : ''}.
|
||||
</p>
|
||||
|
||||
<div className="max-h-44 space-y-2 overflow-auto rounded-md border bg-background p-2">
|
||||
{bulkNotificationPreview.data.projects
|
||||
.filter((project) => project.recipientCount > 0)
|
||||
.slice(0, 8)
|
||||
.map((project) => (
|
||||
<div key={project.id} className="text-xs">
|
||||
<p className="font-medium">
|
||||
{project.title} ({project.recipientCount})
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{project.recipientsPreview.join(', ')}
|
||||
{project.hasMoreRecipients ? ', ...' : ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{bulkNotificationPreview.data.projectsWithRecipients === 0 && (
|
||||
<p className="text-xs text-amber-700">
|
||||
No linked participant accounts found. Status will update, but no team notifications will be sent.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="bulk-notification-confirm"
|
||||
checked={bulkNotificationsConfirmed}
|
||||
onCheckedChange={(checked) => setBulkNotificationsConfirmed(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="bulk-notification-confirm" className="text-sm font-normal leading-5">
|
||||
I verified the recipient list and want to send these automated notifications.
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
@@ -1310,12 +1405,12 @@ export default function ProjectsPage() {
|
||||
<AlertDialogAction
|
||||
onClick={handleBulkConfirm}
|
||||
className={bulkStatus === 'REJECTED' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
|
||||
disabled={bulkUpdateStatus.isPending}
|
||||
disabled={bulkUpdateStatus.isPending || (bulkStatusTriggersNotifications && !bulkNotificationsConfirmed)}
|
||||
>
|
||||
{bulkUpdateStatus.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Update {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
|
||||
{bulkStatusTriggersNotifications ? 'Update + Notify' : 'Update'} {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,350 +1,350 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function ProjectPoolPage() {
|
||||
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
|
||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
||||
const [targetStageId, setTargetStageId] = useState<string>('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const perPage = 50
|
||||
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
|
||||
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
|
||||
{
|
||||
programId: selectedProgramId,
|
||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||
search: searchQuery || undefined,
|
||||
page: currentPage,
|
||||
perPage,
|
||||
},
|
||||
{ enabled: !!selectedProgramId }
|
||||
)
|
||||
|
||||
// Get stages from the selected program (program.list includes rounds/stages)
|
||||
const { data: selectedProgramData, isLoading: isLoadingStages } = trpc.program.get.useQuery(
|
||||
{ id: selectedProgramId },
|
||||
{ enabled: !!selectedProgramId }
|
||||
)
|
||||
const stages = (selectedProgramData?.stages || []) as Array<{ id: string; name: string }>
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const assignMutation = trpc.projectPool.assignToStage.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.project.list.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to stage`)
|
||||
setSelectedProjects([])
|
||||
setAssignDialogOpen(false)
|
||||
setTargetStageId('')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to assign projects')
|
||||
},
|
||||
})
|
||||
|
||||
const handleBulkAssign = () => {
|
||||
if (selectedProjects.length === 0 || !targetStageId) return
|
||||
assignMutation.mutate({
|
||||
projectIds: selectedProjects,
|
||||
stageId: targetStageId,
|
||||
})
|
||||
}
|
||||
|
||||
const handleQuickAssign = (projectId: string, stageId: string) => {
|
||||
assignMutation.mutate({
|
||||
projectIds: [projectId],
|
||||
stageId,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (!poolData?.projects) return
|
||||
if (selectedProjects.length === poolData.projects.length) {
|
||||
setSelectedProjects([])
|
||||
} else {
|
||||
setSelectedProjects(poolData.projects.map((p) => p.id))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectProject = (projectId: string) => {
|
||||
if (selectedProjects.includes(projectId)) {
|
||||
setSelectedProjects(selectedProjects.filter((id) => id !== projectId))
|
||||
} else {
|
||||
setSelectedProjects([...selectedProjects, projectId])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Project Pool</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Assign unassigned projects to evaluation stages
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Program Selector */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Program</label>
|
||||
<Select value={selectedProgramId} onValueChange={(value) => {
|
||||
setSelectedProgramId(value)
|
||||
setSelectedProjects([])
|
||||
setCurrentPage(1)
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select program..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((program) => (
|
||||
<SelectItem key={program.id} value={program.id}>
|
||||
{program.name} {program.year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Category</label>
|
||||
<Select value={categoryFilter} onValueChange={(value: any) => {
|
||||
setCategoryFilter(value)
|
||||
setCurrentPage(1)
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
<SelectItem value="STARTUP">Startup</SelectItem>
|
||||
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Search</label>
|
||||
<Input
|
||||
placeholder="Project or team name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedProjects.length > 0 && (
|
||||
<Button onClick={() => setAssignDialogOpen(true)} className="whitespace-nowrap">
|
||||
Assign {selectedProjects.length} Project{selectedProjects.length > 1 ? 's' : ''}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Projects Table */}
|
||||
{selectedProgramId && (
|
||||
<>
|
||||
{isLoadingPool ? (
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : poolData && poolData.total > 0 ? (
|
||||
<>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b">
|
||||
<tr className="text-sm">
|
||||
<th className="p-3 text-left">
|
||||
<Checkbox
|
||||
checked={selectedProjects.length === poolData.projects.length && poolData.projects.length > 0}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">Project</th>
|
||||
<th className="p-3 text-left font-medium">Category</th>
|
||||
<th className="p-3 text-left font-medium">Country</th>
|
||||
<th className="p-3 text-left font-medium">Submitted</th>
|
||||
<th className="p-3 text-left font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{poolData.projects.map((project) => (
|
||||
<tr key={project.id} className="border-b hover:bg-muted/50">
|
||||
<td className="p-3">
|
||||
<Checkbox
|
||||
checked={selectedProjects.includes(project.id)}
|
||||
onCheckedChange={() => toggleSelectProject(project.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/admin/projects/${project.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
<div className="font-medium">{project.title}</div>
|
||||
<div className="text-sm text-muted-foreground">{project.teamName}</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{project.country || '-'}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{project.submittedAt
|
||||
? new Date(project.submittedAt).toLocaleDateString()
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{isLoadingStages ? (
|
||||
<Skeleton className="h-9 w-[200px]" />
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(stageId) => handleQuickAssign(project.id, stageId)}
|
||||
disabled={assignMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Assign to stage..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stages?.map((stage: { id: string; name: string }) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{poolData.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total} projects
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === poolData.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
No unassigned projects found for this program
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!selectedProgramId && (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
Select a program to view unassigned projects
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bulk Assignment Dialog */}
|
||||
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Projects to Stage</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Select value={targetStageId} onValueChange={setTargetStageId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select stage..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stages?.map((stage: { id: string; name: string }) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBulkAssign}
|
||||
disabled={!targetStageId || assignMutation.isPending}
|
||||
>
|
||||
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Assign
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function ProjectPoolPage() {
|
||||
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
|
||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
||||
const [targetStageId, setTargetStageId] = useState<string>('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const perPage = 50
|
||||
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
|
||||
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
|
||||
{
|
||||
programId: selectedProgramId,
|
||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||
search: searchQuery || undefined,
|
||||
page: currentPage,
|
||||
perPage,
|
||||
},
|
||||
{ enabled: !!selectedProgramId }
|
||||
)
|
||||
|
||||
// Get stages from the selected program (program.list includes rounds/stages)
|
||||
const { data: selectedProgramData, isLoading: isLoadingStages } = trpc.program.get.useQuery(
|
||||
{ id: selectedProgramId },
|
||||
{ enabled: !!selectedProgramId }
|
||||
)
|
||||
const stages = (selectedProgramData?.stages || []) as Array<{ id: string; name: string }>
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const assignMutation = trpc.projectPool.assignToStage.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.project.list.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to stage`)
|
||||
setSelectedProjects([])
|
||||
setAssignDialogOpen(false)
|
||||
setTargetStageId('')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to assign projects')
|
||||
},
|
||||
})
|
||||
|
||||
const handleBulkAssign = () => {
|
||||
if (selectedProjects.length === 0 || !targetStageId) return
|
||||
assignMutation.mutate({
|
||||
projectIds: selectedProjects,
|
||||
stageId: targetStageId,
|
||||
})
|
||||
}
|
||||
|
||||
const handleQuickAssign = (projectId: string, stageId: string) => {
|
||||
assignMutation.mutate({
|
||||
projectIds: [projectId],
|
||||
stageId,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (!poolData?.projects) return
|
||||
if (selectedProjects.length === poolData.projects.length) {
|
||||
setSelectedProjects([])
|
||||
} else {
|
||||
setSelectedProjects(poolData.projects.map((p) => p.id))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectProject = (projectId: string) => {
|
||||
if (selectedProjects.includes(projectId)) {
|
||||
setSelectedProjects(selectedProjects.filter((id) => id !== projectId))
|
||||
} else {
|
||||
setSelectedProjects([...selectedProjects, projectId])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Project Pool</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Assign unassigned projects to evaluation stages
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Program Selector */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Program</label>
|
||||
<Select value={selectedProgramId} onValueChange={(value) => {
|
||||
setSelectedProgramId(value)
|
||||
setSelectedProjects([])
|
||||
setCurrentPage(1)
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select program..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((program) => (
|
||||
<SelectItem key={program.id} value={program.id}>
|
||||
{program.name} {program.year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Category</label>
|
||||
<Select value={categoryFilter} onValueChange={(value: any) => {
|
||||
setCategoryFilter(value)
|
||||
setCurrentPage(1)
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
<SelectItem value="STARTUP">Startup</SelectItem>
|
||||
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Search</label>
|
||||
<Input
|
||||
placeholder="Project or team name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedProjects.length > 0 && (
|
||||
<Button onClick={() => setAssignDialogOpen(true)} className="whitespace-nowrap">
|
||||
Assign {selectedProjects.length} Project{selectedProjects.length > 1 ? 's' : ''}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Projects Table */}
|
||||
{selectedProgramId && (
|
||||
<>
|
||||
{isLoadingPool ? (
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : poolData && poolData.total > 0 ? (
|
||||
<>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b">
|
||||
<tr className="text-sm">
|
||||
<th className="p-3 text-left">
|
||||
<Checkbox
|
||||
checked={selectedProjects.length === poolData.projects.length && poolData.projects.length > 0}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">Project</th>
|
||||
<th className="p-3 text-left font-medium">Category</th>
|
||||
<th className="p-3 text-left font-medium">Country</th>
|
||||
<th className="p-3 text-left font-medium">Submitted</th>
|
||||
<th className="p-3 text-left font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{poolData.projects.map((project) => (
|
||||
<tr key={project.id} className="border-b hover:bg-muted/50">
|
||||
<td className="p-3">
|
||||
<Checkbox
|
||||
checked={selectedProjects.includes(project.id)}
|
||||
onCheckedChange={() => toggleSelectProject(project.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/admin/projects/${project.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
<div className="font-medium">{project.title}</div>
|
||||
<div className="text-sm text-muted-foreground">{project.teamName}</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{project.country || '-'}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{project.submittedAt
|
||||
? new Date(project.submittedAt).toLocaleDateString()
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{isLoadingStages ? (
|
||||
<Skeleton className="h-9 w-[200px]" />
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(stageId) => handleQuickAssign(project.id, stageId)}
|
||||
disabled={assignMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Assign to stage..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stages?.map((stage: { id: string; name: string }) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{poolData.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total} projects
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === poolData.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
No unassigned projects found for this program
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!selectedProgramId && (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
Select a program to view unassigned projects
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bulk Assignment Dialog */}
|
||||
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Projects to Stage</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Select value={targetStageId} onValueChange={setTargetStageId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select stage..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stages?.map((stage: { id: string; name: string }) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBulkAssign}
|
||||
disabled={!targetStageId || assignMutation.isPending}
|
||||
>
|
||||
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Assign
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,347 +1,347 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ChevronDown, Filter, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||
|
||||
const ALL_STATUSES = [
|
||||
'SUBMITTED',
|
||||
'ELIGIBLE',
|
||||
'ASSIGNED',
|
||||
'SEMIFINALIST',
|
||||
'FINALIST',
|
||||
'REJECTED',
|
||||
] as const
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
SUBMITTED: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
ELIGIBLE: 'bg-blue-100 text-blue-700 hover:bg-blue-200',
|
||||
ASSIGNED: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200',
|
||||
SEMIFINALIST: 'bg-green-100 text-green-700 hover:bg-green-200',
|
||||
FINALIST: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200',
|
||||
REJECTED: 'bg-red-100 text-red-700 hover:bg-red-200',
|
||||
}
|
||||
|
||||
const ISSUE_LABELS: Record<string, string> = {
|
||||
POLLUTION_REDUCTION: 'Pollution Reduction',
|
||||
CLIMATE_MITIGATION: 'Climate Mitigation',
|
||||
TECHNOLOGY_INNOVATION: 'Technology Innovation',
|
||||
SUSTAINABLE_SHIPPING: 'Sustainable Shipping',
|
||||
BLUE_CARBON: 'Blue Carbon',
|
||||
HABITAT_RESTORATION: 'Habitat Restoration',
|
||||
COMMUNITY_CAPACITY: 'Community Capacity',
|
||||
SUSTAINABLE_FISHING: 'Sustainable Fishing',
|
||||
CONSUMER_AWARENESS: 'Consumer Awareness',
|
||||
OCEAN_ACIDIFICATION: 'Ocean Acidification',
|
||||
OTHER: 'Other',
|
||||
}
|
||||
|
||||
export interface ProjectFilters {
|
||||
search: string
|
||||
statuses: string[]
|
||||
stageId: string
|
||||
competitionCategory: string
|
||||
oceanIssue: string
|
||||
country: string
|
||||
wantsMentorship: boolean | undefined
|
||||
hasFiles: boolean | undefined
|
||||
hasAssignments: boolean | undefined
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
countries: string[]
|
||||
categories: Array<{ value: string; count: number }>
|
||||
issues: Array<{ value: string; count: number }>
|
||||
stages?: Array<{ id: string; name: string; programName: string; programYear: number }>
|
||||
}
|
||||
|
||||
interface ProjectFiltersBarProps {
|
||||
filters: ProjectFilters
|
||||
filterOptions: FilterOptions | undefined
|
||||
onChange: (filters: ProjectFilters) => void
|
||||
}
|
||||
|
||||
export function ProjectFiltersBar({
|
||||
filters,
|
||||
filterOptions,
|
||||
onChange,
|
||||
}: ProjectFiltersBarProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const activeFilterCount = [
|
||||
filters.statuses.length > 0,
|
||||
filters.stageId !== '',
|
||||
filters.competitionCategory !== '',
|
||||
filters.oceanIssue !== '',
|
||||
filters.country !== '',
|
||||
filters.wantsMentorship !== undefined,
|
||||
filters.hasFiles !== undefined,
|
||||
filters.hasAssignments !== undefined,
|
||||
].filter(Boolean).length
|
||||
|
||||
const toggleStatus = (status: string) => {
|
||||
const next = filters.statuses.includes(status)
|
||||
? filters.statuses.filter((s) => s !== status)
|
||||
: [...filters.statuses, status]
|
||||
onChange({ ...filters, statuses: next })
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
onChange({
|
||||
search: filters.search,
|
||||
statuses: [],
|
||||
stageId: '',
|
||||
competitionCategory: '',
|
||||
oceanIssue: '',
|
||||
country: '',
|
||||
wantsMentorship: undefined,
|
||||
hasFiles: undefined,
|
||||
hasAssignments: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Card>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2 duration-200">
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
{/* Status toggles */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Status</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ALL_STATUSES.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => toggleStatus(status)}
|
||||
className={cn(
|
||||
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
|
||||
filters.statuses.includes(status)
|
||||
? STATUS_COLORS[status]
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
)}
|
||||
>
|
||||
{status.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Select filters grid */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Stage / Edition</Label>
|
||||
<Select
|
||||
value={filters.stageId || '_all'}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...filters, stageId: v === '_all' ? '' : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All stages" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All stages</SelectItem>
|
||||
{filterOptions?.stages?.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name} ({s.programYear} {s.programName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Category</Label>
|
||||
<Select
|
||||
value={filters.competitionCategory || '_all'}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
...filters,
|
||||
competitionCategory: v === '_all' ? '' : v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All categories</SelectItem>
|
||||
{filterOptions?.categories.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>
|
||||
{c.value.replace('_', ' ')} ({c.count})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Ocean Issue</Label>
|
||||
<Select
|
||||
value={filters.oceanIssue || '_all'}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...filters, oceanIssue: v === '_all' ? '' : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All issues" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All issues</SelectItem>
|
||||
{filterOptions?.issues.map((i) => (
|
||||
<SelectItem key={i.value} value={i.value}>
|
||||
{ISSUE_LABELS[i.value] || i.value} ({i.count})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Country</Label>
|
||||
<Select
|
||||
value={filters.country || '_all'}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...filters, country: v === '_all' ? '' : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All countries" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All countries</SelectItem>
|
||||
{filterOptions?.countries
|
||||
.map((c) => ({
|
||||
code: c,
|
||||
name: getCountryName(c),
|
||||
flag: getCountryFlag(c),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{c.flag}</span>
|
||||
<span>{c.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Boolean toggles */}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="hasFiles"
|
||||
checked={filters.hasFiles === true}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({
|
||||
...filters,
|
||||
hasFiles: checked ? true : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="hasFiles" className="text-sm">
|
||||
Has Documents
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="hasAssignments"
|
||||
checked={filters.hasAssignments === true}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({
|
||||
...filters,
|
||||
hasAssignments: checked ? true : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="hasAssignments" className="text-sm">
|
||||
Has Assignments
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="wantsMentorship"
|
||||
checked={filters.wantsMentorship === true}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({
|
||||
...filters,
|
||||
wantsMentorship: checked ? true : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="wantsMentorship" className="text-sm">
|
||||
Wants Mentorship
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear all */}
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Clear All Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ChevronDown, Filter, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||
|
||||
const ALL_STATUSES = [
|
||||
'SUBMITTED',
|
||||
'ELIGIBLE',
|
||||
'ASSIGNED',
|
||||
'SEMIFINALIST',
|
||||
'FINALIST',
|
||||
'REJECTED',
|
||||
] as const
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
SUBMITTED: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
ELIGIBLE: 'bg-blue-100 text-blue-700 hover:bg-blue-200',
|
||||
ASSIGNED: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200',
|
||||
SEMIFINALIST: 'bg-green-100 text-green-700 hover:bg-green-200',
|
||||
FINALIST: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200',
|
||||
REJECTED: 'bg-red-100 text-red-700 hover:bg-red-200',
|
||||
}
|
||||
|
||||
const ISSUE_LABELS: Record<string, string> = {
|
||||
POLLUTION_REDUCTION: 'Pollution Reduction',
|
||||
CLIMATE_MITIGATION: 'Climate Mitigation',
|
||||
TECHNOLOGY_INNOVATION: 'Technology Innovation',
|
||||
SUSTAINABLE_SHIPPING: 'Sustainable Shipping',
|
||||
BLUE_CARBON: 'Blue Carbon',
|
||||
HABITAT_RESTORATION: 'Habitat Restoration',
|
||||
COMMUNITY_CAPACITY: 'Community Capacity',
|
||||
SUSTAINABLE_FISHING: 'Sustainable Fishing',
|
||||
CONSUMER_AWARENESS: 'Consumer Awareness',
|
||||
OCEAN_ACIDIFICATION: 'Ocean Acidification',
|
||||
OTHER: 'Other',
|
||||
}
|
||||
|
||||
export interface ProjectFilters {
|
||||
search: string
|
||||
statuses: string[]
|
||||
stageId: string
|
||||
competitionCategory: string
|
||||
oceanIssue: string
|
||||
country: string
|
||||
wantsMentorship: boolean | undefined
|
||||
hasFiles: boolean | undefined
|
||||
hasAssignments: boolean | undefined
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
countries: string[]
|
||||
categories: Array<{ value: string; count: number }>
|
||||
issues: Array<{ value: string; count: number }>
|
||||
stages?: Array<{ id: string; name: string; programName: string; programYear: number }>
|
||||
}
|
||||
|
||||
interface ProjectFiltersBarProps {
|
||||
filters: ProjectFilters
|
||||
filterOptions: FilterOptions | undefined
|
||||
onChange: (filters: ProjectFilters) => void
|
||||
}
|
||||
|
||||
export function ProjectFiltersBar({
|
||||
filters,
|
||||
filterOptions,
|
||||
onChange,
|
||||
}: ProjectFiltersBarProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const activeFilterCount = [
|
||||
filters.statuses.length > 0,
|
||||
filters.stageId !== '',
|
||||
filters.competitionCategory !== '',
|
||||
filters.oceanIssue !== '',
|
||||
filters.country !== '',
|
||||
filters.wantsMentorship !== undefined,
|
||||
filters.hasFiles !== undefined,
|
||||
filters.hasAssignments !== undefined,
|
||||
].filter(Boolean).length
|
||||
|
||||
const toggleStatus = (status: string) => {
|
||||
const next = filters.statuses.includes(status)
|
||||
? filters.statuses.filter((s) => s !== status)
|
||||
: [...filters.statuses, status]
|
||||
onChange({ ...filters, statuses: next })
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
onChange({
|
||||
search: filters.search,
|
||||
statuses: [],
|
||||
stageId: '',
|
||||
competitionCategory: '',
|
||||
oceanIssue: '',
|
||||
country: '',
|
||||
wantsMentorship: undefined,
|
||||
hasFiles: undefined,
|
||||
hasAssignments: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Card>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2 duration-200">
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
{/* Status toggles */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Status</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ALL_STATUSES.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => toggleStatus(status)}
|
||||
className={cn(
|
||||
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
|
||||
filters.statuses.includes(status)
|
||||
? STATUS_COLORS[status]
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
)}
|
||||
>
|
||||
{status.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Select filters grid */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Stage / Edition</Label>
|
||||
<Select
|
||||
value={filters.stageId || '_all'}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...filters, stageId: v === '_all' ? '' : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All stages" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All stages</SelectItem>
|
||||
{filterOptions?.stages?.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name} ({s.programYear} {s.programName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Category</Label>
|
||||
<Select
|
||||
value={filters.competitionCategory || '_all'}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
...filters,
|
||||
competitionCategory: v === '_all' ? '' : v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All categories</SelectItem>
|
||||
{filterOptions?.categories.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>
|
||||
{c.value.replace('_', ' ')} ({c.count})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Ocean Issue</Label>
|
||||
<Select
|
||||
value={filters.oceanIssue || '_all'}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...filters, oceanIssue: v === '_all' ? '' : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All issues" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All issues</SelectItem>
|
||||
{filterOptions?.issues.map((i) => (
|
||||
<SelectItem key={i.value} value={i.value}>
|
||||
{ISSUE_LABELS[i.value] || i.value} ({i.count})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Country</Label>
|
||||
<Select
|
||||
value={filters.country || '_all'}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...filters, country: v === '_all' ? '' : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All countries" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All countries</SelectItem>
|
||||
{filterOptions?.countries
|
||||
.map((c) => ({
|
||||
code: c,
|
||||
name: getCountryName(c),
|
||||
flag: getCountryFlag(c),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{c.flag}</span>
|
||||
<span>{c.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Boolean toggles */}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="hasFiles"
|
||||
checked={filters.hasFiles === true}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({
|
||||
...filters,
|
||||
hasFiles: checked ? true : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="hasFiles" className="text-sm">
|
||||
Has Documents
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="hasAssignments"
|
||||
checked={filters.hasAssignments === true}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({
|
||||
...filters,
|
||||
hasAssignments: checked ? true : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="hasAssignments" className="text-sm">
|
||||
Has Assignments
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="wantsMentorship"
|
||||
checked={filters.wantsMentorship === true}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({
|
||||
...filters,
|
||||
wantsMentorship: checked ? true : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="wantsMentorship" className="text-sm">
|
||||
Wants Mentorship
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear all */}
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Clear All Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,352 +1,352 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
|
||||
import type { StepConfig } from '@/components/ui/sidebar-stepper'
|
||||
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
||||
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
||||
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
|
||||
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
|
||||
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
||||
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
export default function NewPipelinePage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = searchParams.get('programId') || currentEdition?.id || ''
|
||||
|
||||
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const initialStateRef = useRef(JSON.stringify(state))
|
||||
|
||||
// Update programId in state when edition context loads
|
||||
useEffect(() => {
|
||||
if (programId && !state.programId) {
|
||||
setState((prev) => ({ ...prev, programId }))
|
||||
}
|
||||
}, [programId, state.programId])
|
||||
|
||||
// Dirty tracking — warn on navigate away
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (JSON.stringify(state) !== initialStateRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [state])
|
||||
|
||||
const updateState = useCallback((updates: Partial<WizardState>) => {
|
||||
setState((prev) => ({ ...prev, ...updates }))
|
||||
}, [])
|
||||
|
||||
// Get stage configs from the main track
|
||||
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
|
||||
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
|
||||
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
|
||||
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
|
||||
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
|
||||
|
||||
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
|
||||
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
|
||||
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
|
||||
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
|
||||
|
||||
const updateStageConfig = useCallback(
|
||||
(stageType: string, configJson: Record<string, unknown>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) => {
|
||||
if (track.kind !== 'MAIN') return track
|
||||
return {
|
||||
...track,
|
||||
stages: track.stages.map((stage) =>
|
||||
stage.stageType === stageType ? { ...stage, configJson } : stage
|
||||
),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateMainTrackStages = useCallback(
|
||||
(stages: WizardState['tracks'][0]['stages']) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) =>
|
||||
track.kind === 'MAIN' ? { ...track, stages } : track
|
||||
),
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Validation
|
||||
const basicsValid = validateBasics(state).valid
|
||||
const tracksValid = validateTracks(state.tracks).valid
|
||||
const allValid = validateAll(state).valid
|
||||
|
||||
// Mutations
|
||||
const createMutation = trpc.pipeline.createStructure.useMutation({
|
||||
onSuccess: (data) => {
|
||||
initialStateRef.current = JSON.stringify(state) // prevent dirty warning
|
||||
toast.success('Pipeline created successfully')
|
||||
router.push(`/admin/rounds/pipeline/${data.pipeline.id}` as Route)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Pipeline published successfully')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = async (publish: boolean) => {
|
||||
const validation = validateAll(state)
|
||||
if (!validation.valid) {
|
||||
toast.error('Please fix validation errors before saving')
|
||||
// Navigate to first section with errors
|
||||
if (!validation.sections.basics.valid) setCurrentStep(0)
|
||||
else if (!validation.sections.tracks.valid) setCurrentStep(2)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await createMutation.mutateAsync({
|
||||
programId: state.programId,
|
||||
name: state.name,
|
||||
slug: state.slug,
|
||||
settingsJson: {
|
||||
...state.settingsJson,
|
||||
notificationConfig: state.notificationConfig,
|
||||
overridePolicy: state.overridePolicy,
|
||||
},
|
||||
tracks: state.tracks.map((t) => ({
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
kind: t.kind,
|
||||
sortOrder: t.sortOrder,
|
||||
routingModeDefault: t.routingModeDefault,
|
||||
decisionMode: t.decisionMode,
|
||||
stages: t.stages.map((s) => ({
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType,
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: s.configJson,
|
||||
})),
|
||||
awardConfig: t.awardConfig,
|
||||
})),
|
||||
autoTransitions: true,
|
||||
})
|
||||
|
||||
if (publish && result.pipeline.id) {
|
||||
await publishMutation.mutateAsync({ id: result.pipeline.id })
|
||||
}
|
||||
}
|
||||
|
||||
const isSaving = createMutation.isPending && !publishMutation.isPending
|
||||
const isSubmitting = publishMutation.isPending
|
||||
|
||||
if (!programId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please select an edition first to create a pipeline.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step configuration
|
||||
const steps: StepConfig[] = [
|
||||
{
|
||||
title: 'Basics',
|
||||
description: 'Pipeline name and program',
|
||||
isValid: basicsValid,
|
||||
},
|
||||
{
|
||||
title: 'Intake',
|
||||
description: 'Submission window & files',
|
||||
isValid: !!intakeStage,
|
||||
},
|
||||
{
|
||||
title: 'Main Track Stages',
|
||||
description: 'Configure pipeline stages',
|
||||
isValid: tracksValid,
|
||||
},
|
||||
{
|
||||
title: 'Screening',
|
||||
description: 'Gate rules and AI screening',
|
||||
isValid: !!filterStage,
|
||||
},
|
||||
{
|
||||
title: 'Evaluation',
|
||||
description: 'Jury assignment strategy',
|
||||
isValid: !!evalStage,
|
||||
},
|
||||
{
|
||||
title: 'Awards',
|
||||
description: 'Special award tracks',
|
||||
isValid: true, // Awards are optional
|
||||
},
|
||||
{
|
||||
title: 'Live Finals',
|
||||
description: 'Voting and reveal settings',
|
||||
isValid: !!liveStage,
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
description: 'Event notifications',
|
||||
isValid: true, // Always valid
|
||||
},
|
||||
{
|
||||
title: 'Review & Create',
|
||||
description: 'Validation summary',
|
||||
isValid: allValid,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure the full pipeline structure for project evaluation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Stepper */}
|
||||
<SidebarStepper
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepChange={setCurrentStep}
|
||||
onSave={() => handleSave(false)}
|
||||
onSubmit={() => handleSave(true)}
|
||||
isSaving={isSaving}
|
||||
isSubmitting={isSubmitting}
|
||||
saveLabel="Save Draft"
|
||||
submitLabel="Save & Publish"
|
||||
canSubmit={allValid}
|
||||
>
|
||||
{/* Step 0: Basics */}
|
||||
<div>
|
||||
<BasicsSection state={state} onChange={updateState} />
|
||||
</div>
|
||||
|
||||
{/* Step 1: Intake */}
|
||||
<div>
|
||||
<IntakeSection
|
||||
config={intakeConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Main Track Stages */}
|
||||
<div>
|
||||
<MainTrackSection
|
||||
stages={mainTrack?.stages ?? []}
|
||||
onChange={updateMainTrackStages}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Screening */}
|
||||
<div>
|
||||
<FilteringSection
|
||||
config={filterConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Evaluation */}
|
||||
<div>
|
||||
<AssignmentSection
|
||||
config={evalConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 5: Awards */}
|
||||
<div>
|
||||
<AwardsSection
|
||||
tracks={state.tracks}
|
||||
onChange={(tracks) => updateState({ tracks })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 6: Live Finals */}
|
||||
<div>
|
||||
<LiveFinalsSection
|
||||
config={liveConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 7: Notifications */}
|
||||
<div>
|
||||
<NotificationsSection
|
||||
config={state.notificationConfig}
|
||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
||||
overridePolicy={state.overridePolicy}
|
||||
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 8: Review & Create */}
|
||||
<div>
|
||||
<ReviewSection state={state} />
|
||||
</div>
|
||||
</SidebarStepper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
|
||||
import type { StepConfig } from '@/components/ui/sidebar-stepper'
|
||||
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
||||
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
||||
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
|
||||
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
|
||||
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
||||
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
export default function NewPipelinePage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = searchParams.get('programId') || currentEdition?.id || ''
|
||||
|
||||
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const initialStateRef = useRef(JSON.stringify(state))
|
||||
|
||||
// Update programId in state when edition context loads
|
||||
useEffect(() => {
|
||||
if (programId && !state.programId) {
|
||||
setState((prev) => ({ ...prev, programId }))
|
||||
}
|
||||
}, [programId, state.programId])
|
||||
|
||||
// Dirty tracking — warn on navigate away
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (JSON.stringify(state) !== initialStateRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [state])
|
||||
|
||||
const updateState = useCallback((updates: Partial<WizardState>) => {
|
||||
setState((prev) => ({ ...prev, ...updates }))
|
||||
}, [])
|
||||
|
||||
// Get stage configs from the main track
|
||||
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
|
||||
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
|
||||
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
|
||||
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
|
||||
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
|
||||
|
||||
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
|
||||
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
|
||||
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
|
||||
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
|
||||
|
||||
const updateStageConfig = useCallback(
|
||||
(stageType: string, configJson: Record<string, unknown>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) => {
|
||||
if (track.kind !== 'MAIN') return track
|
||||
return {
|
||||
...track,
|
||||
stages: track.stages.map((stage) =>
|
||||
stage.stageType === stageType ? { ...stage, configJson } : stage
|
||||
),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateMainTrackStages = useCallback(
|
||||
(stages: WizardState['tracks'][0]['stages']) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) =>
|
||||
track.kind === 'MAIN' ? { ...track, stages } : track
|
||||
),
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Validation
|
||||
const basicsValid = validateBasics(state).valid
|
||||
const tracksValid = validateTracks(state.tracks).valid
|
||||
const allValid = validateAll(state).valid
|
||||
|
||||
// Mutations
|
||||
const createMutation = trpc.pipeline.createStructure.useMutation({
|
||||
onSuccess: (data) => {
|
||||
initialStateRef.current = JSON.stringify(state) // prevent dirty warning
|
||||
toast.success('Pipeline created successfully')
|
||||
router.push(`/admin/rounds/pipeline/${data.pipeline.id}` as Route)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Pipeline published successfully')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = async (publish: boolean) => {
|
||||
const validation = validateAll(state)
|
||||
if (!validation.valid) {
|
||||
toast.error('Please fix validation errors before saving')
|
||||
// Navigate to first section with errors
|
||||
if (!validation.sections.basics.valid) setCurrentStep(0)
|
||||
else if (!validation.sections.tracks.valid) setCurrentStep(2)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await createMutation.mutateAsync({
|
||||
programId: state.programId,
|
||||
name: state.name,
|
||||
slug: state.slug,
|
||||
settingsJson: {
|
||||
...state.settingsJson,
|
||||
notificationConfig: state.notificationConfig,
|
||||
overridePolicy: state.overridePolicy,
|
||||
},
|
||||
tracks: state.tracks.map((t) => ({
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
kind: t.kind,
|
||||
sortOrder: t.sortOrder,
|
||||
routingModeDefault: t.routingModeDefault,
|
||||
decisionMode: t.decisionMode,
|
||||
stages: t.stages.map((s) => ({
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType,
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: s.configJson,
|
||||
})),
|
||||
awardConfig: t.awardConfig,
|
||||
})),
|
||||
autoTransitions: true,
|
||||
})
|
||||
|
||||
if (publish && result.pipeline.id) {
|
||||
await publishMutation.mutateAsync({ id: result.pipeline.id })
|
||||
}
|
||||
}
|
||||
|
||||
const isSaving = createMutation.isPending && !publishMutation.isPending
|
||||
const isSubmitting = publishMutation.isPending
|
||||
|
||||
if (!programId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please select an edition first to create a pipeline.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step configuration
|
||||
const steps: StepConfig[] = [
|
||||
{
|
||||
title: 'Basics',
|
||||
description: 'Pipeline name and program',
|
||||
isValid: basicsValid,
|
||||
},
|
||||
{
|
||||
title: 'Intake',
|
||||
description: 'Submission window & files',
|
||||
isValid: !!intakeStage,
|
||||
},
|
||||
{
|
||||
title: 'Main Track Stages',
|
||||
description: 'Configure pipeline stages',
|
||||
isValid: tracksValid,
|
||||
},
|
||||
{
|
||||
title: 'Screening',
|
||||
description: 'Gate rules and AI screening',
|
||||
isValid: !!filterStage,
|
||||
},
|
||||
{
|
||||
title: 'Evaluation',
|
||||
description: 'Jury assignment strategy',
|
||||
isValid: !!evalStage,
|
||||
},
|
||||
{
|
||||
title: 'Awards',
|
||||
description: 'Special award tracks',
|
||||
isValid: true, // Awards are optional
|
||||
},
|
||||
{
|
||||
title: 'Live Finals',
|
||||
description: 'Voting and reveal settings',
|
||||
isValid: !!liveStage,
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
description: 'Event notifications',
|
||||
isValid: true, // Always valid
|
||||
},
|
||||
{
|
||||
title: 'Review & Create',
|
||||
description: 'Validation summary',
|
||||
isValid: allValid,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure the full pipeline structure for project evaluation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Stepper */}
|
||||
<SidebarStepper
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepChange={setCurrentStep}
|
||||
onSave={() => handleSave(false)}
|
||||
onSubmit={() => handleSave(true)}
|
||||
isSaving={isSaving}
|
||||
isSubmitting={isSubmitting}
|
||||
saveLabel="Save Draft"
|
||||
submitLabel="Save & Publish"
|
||||
canSubmit={allValid}
|
||||
>
|
||||
{/* Step 0: Basics */}
|
||||
<div>
|
||||
<BasicsSection state={state} onChange={updateState} />
|
||||
</div>
|
||||
|
||||
{/* Step 1: Intake */}
|
||||
<div>
|
||||
<IntakeSection
|
||||
config={intakeConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Main Track Stages */}
|
||||
<div>
|
||||
<MainTrackSection
|
||||
stages={mainTrack?.stages ?? []}
|
||||
onChange={updateMainTrackStages}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Screening */}
|
||||
<div>
|
||||
<FilteringSection
|
||||
config={filterConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Evaluation */}
|
||||
<div>
|
||||
<AssignmentSection
|
||||
config={evalConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 5: Awards */}
|
||||
<div>
|
||||
<AwardsSection
|
||||
tracks={state.tracks}
|
||||
onChange={(tracks) => updateState({ tracks })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 6: Live Finals */}
|
||||
<div>
|
||||
<LiveFinalsSection
|
||||
config={liveConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 7: Notifications */}
|
||||
<div>
|
||||
<NotificationsSection
|
||||
config={state.notificationConfig}
|
||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
||||
overridePolicy={state.overridePolicy}
|
||||
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 8: Review & Create */}
|
||||
<div>
|
||||
<ReviewSection state={state} />
|
||||
</div>
|
||||
</SidebarStepper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,554 +1,12 @@
|
||||
'use client'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route as NextRoute } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Route,
|
||||
Play,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { PipelineVisualization } from '@/components/admin/pipeline/pipeline-visualization'
|
||||
|
||||
const stageTypeColors: Record<string, string> = {
|
||||
INTAKE: 'text-blue-600',
|
||||
FILTER: 'text-amber-600',
|
||||
EVALUATION: 'text-purple-600',
|
||||
SELECTION: 'text-rose-600',
|
||||
LIVE_FINAL: 'text-emerald-600',
|
||||
RESULTS: 'text-cyan-600',
|
||||
type AdvancedPipelinePageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
type SelectedItem =
|
||||
| { type: 'stage'; trackId: string; stageId: string }
|
||||
| { type: 'track'; trackId: string }
|
||||
| null
|
||||
|
||||
export default function AdvancedEditorPage() {
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<SelectedItem>(null)
|
||||
const [configEditValue, setConfigEditValue] = useState('')
|
||||
const [simulationProjectIds, setSimulationProjectIds] = useState('')
|
||||
const [showSaveConfirm, setShowSaveConfirm] = useState(false)
|
||||
|
||||
const { data: pipeline, isLoading, refetch } = trpc.pipeline.getDraft.useQuery({
|
||||
id: pipelineId,
|
||||
})
|
||||
|
||||
const updateConfigMutation = trpc.stage.updateConfig.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Stage config saved')
|
||||
refetch()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const simulateMutation = trpc.pipeline.simulate.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Simulation complete: ${data.simulations?.length ?? 0} results`)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const { data: routingRules } = trpc.routing.listRules.useQuery(
|
||||
{ pipelineId },
|
||||
{ enabled: !!pipelineId }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<Skeleton className="col-span-3 h-96" />
|
||||
<Skeleton className="col-span-5 h-96" />
|
||||
<Skeleton className="col-span-4 h-96" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!pipeline) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={'/admin/rounds/pipelines' as NextRoute}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">Pipeline Not Found</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelectStage = (trackId: string, stageId: string) => {
|
||||
setSelectedItem({ type: 'stage', trackId, stageId })
|
||||
const track = pipeline.tracks.find((t) => t.id === trackId)
|
||||
const stage = track?.stages.find((s) => s.id === stageId)
|
||||
setConfigEditValue(
|
||||
JSON.stringify(stage?.configJson ?? {}, null, 2)
|
||||
)
|
||||
}
|
||||
|
||||
const executeSaveConfig = () => {
|
||||
if (selectedItem?.type !== 'stage') return
|
||||
try {
|
||||
const parsed = JSON.parse(configEditValue)
|
||||
updateConfigMutation.mutate({
|
||||
id: selectedItem.stageId,
|
||||
configJson: parsed,
|
||||
})
|
||||
} catch {
|
||||
toast.error('Invalid JSON in config editor')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveConfig = () => {
|
||||
if (selectedItem?.type !== 'stage') return
|
||||
// Validate JSON first
|
||||
try {
|
||||
JSON.parse(configEditValue)
|
||||
} catch {
|
||||
toast.error('Invalid JSON in config editor')
|
||||
return
|
||||
}
|
||||
// If pipeline is active or stage has projects, require confirmation
|
||||
const stage = pipeline?.tracks
|
||||
.flatMap((t) => t.stages)
|
||||
.find((s) => s.id === selectedItem.stageId)
|
||||
const hasProjects = (stage?._count?.projectStageStates ?? 0) > 0
|
||||
const isActive = pipeline?.status === 'ACTIVE'
|
||||
if (isActive || hasProjects) {
|
||||
setShowSaveConfirm(true)
|
||||
} else {
|
||||
executeSaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSimulate = () => {
|
||||
const ids = simulationProjectIds
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
if (ids.length === 0) {
|
||||
toast.error('Enter at least one project ID')
|
||||
return
|
||||
}
|
||||
simulateMutation.mutate({ id: pipelineId, projectIds: ids })
|
||||
}
|
||||
|
||||
const selectedTrack =
|
||||
selectedItem?.type === 'stage'
|
||||
? pipeline.tracks.find((t) => t.id === selectedItem.trackId)
|
||||
: selectedItem?.type === 'track'
|
||||
? pipeline.tracks.find((t) => t.id === selectedItem.trackId)
|
||||
: null
|
||||
|
||||
const selectedStage =
|
||||
selectedItem?.type === 'stage'
|
||||
? selectedTrack?.stages.find((s) => s.id === selectedItem.stageId)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}` as NextRoute}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Advanced Editor</h1>
|
||||
<p className="text-sm text-muted-foreground">{pipeline.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visualization */}
|
||||
<PipelineVisualization tracks={pipeline.tracks} />
|
||||
|
||||
{/* Five Panel Layout */}
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Panel 1 — Track/Stage Tree (left sidebar) */}
|
||||
<div className="col-span-12 lg:col-span-3">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
Structure
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 max-h-[600px] overflow-y-auto">
|
||||
{pipeline.tracks
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((track) => (
|
||||
<div key={track.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1.5 rounded text-sm font-medium hover:bg-muted transition-colors',
|
||||
selectedItem?.type === 'track' &&
|
||||
selectedItem.trackId === track.id
|
||||
? 'bg-muted'
|
||||
: ''
|
||||
)}
|
||||
onClick={() =>
|
||||
setSelectedItem({ type: 'track', trackId: track.id })
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>{track.name}</span>
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1 ml-auto">
|
||||
{track.kind}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
<div className="ml-4 space-y-0.5 mt-0.5">
|
||||
{track.stages
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((stage) => (
|
||||
<button
|
||||
key={stage.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1 rounded text-xs hover:bg-muted transition-colors',
|
||||
selectedItem?.type === 'stage' &&
|
||||
selectedItem.stageId === stage.id
|
||||
? 'bg-muted font-medium'
|
||||
: ''
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelectStage(track.id, stage.id)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono',
|
||||
stageTypeColors[stage.stageType] ?? ''
|
||||
)}
|
||||
>
|
||||
{stage.stageType.slice(0, 3)}
|
||||
</span>
|
||||
<span className="truncate">{stage.name}</span>
|
||||
{stage._count?.projectStageStates > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] h-3.5 px-1 ml-auto"
|
||||
>
|
||||
{stage._count.projectStageStates}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Panel 2 — Stage Config Editor (center) */}
|
||||
<div className="col-span-12 lg:col-span-5">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">
|
||||
{selectedStage
|
||||
? `${selectedStage.name} Config`
|
||||
: selectedTrack
|
||||
? `${selectedTrack.name} Track`
|
||||
: 'Select a stage'}
|
||||
</CardTitle>
|
||||
{selectedStage && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={updateConfigMutation.isPending}
|
||||
onClick={handleSaveConfig}
|
||||
>
|
||||
{updateConfigMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedStage ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{selectedStage.stageType}
|
||||
</Badge>
|
||||
<span className="font-mono">{selectedStage.slug}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={configEditValue}
|
||||
onChange={(e) => setConfigEditValue(e.target.value)}
|
||||
className="font-mono text-xs min-h-[400px]"
|
||||
placeholder="{ }"
|
||||
/>
|
||||
</div>
|
||||
) : selectedTrack ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Kind</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedTrack.kind}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Routing Mode</span>
|
||||
<span className="text-xs font-mono">
|
||||
{selectedTrack.routingMode ?? 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Decision Mode</span>
|
||||
<span className="text-xs font-mono">
|
||||
{selectedTrack.decisionMode ?? 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Stages</span>
|
||||
<span className="font-medium">
|
||||
{selectedTrack.stages.length}
|
||||
</span>
|
||||
</div>
|
||||
{selectedTrack.specialAward && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<p className="text-xs font-medium mb-1">Special Award</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedTrack.specialAward.name} —{' '}
|
||||
{selectedTrack.specialAward.scoringMode}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Select a track or stage from the tree to view or edit its
|
||||
configuration
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Panel 3+4+5 — Routing + Transitions + Simulation (right sidebar) */}
|
||||
<div className="col-span-12 lg:col-span-4 space-y-4">
|
||||
{/* Routing Rules */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Route className="h-4 w-4" />
|
||||
Routing Rules
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{routingRules && routingRules.length > 0 ? (
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{routingRules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-2 text-xs py-1.5 border-b last:border-0"
|
||||
>
|
||||
<Badge
|
||||
variant={rule.isActive ? 'default' : 'secondary'}
|
||||
className="text-[9px] h-4 shrink-0"
|
||||
>
|
||||
P{rule.priority}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{rule.sourceTrack?.name ?? '—'} →{' '}
|
||||
{rule.destinationTrack?.name ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground py-3 text-center">
|
||||
No routing rules configured
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transitions */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Transitions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const allTransitions = pipeline.tracks.flatMap((track) =>
|
||||
track.stages.flatMap((stage) =>
|
||||
stage.transitionsFrom.map((t) => ({
|
||||
id: t.id,
|
||||
fromName: stage.name,
|
||||
toName: t.toStage?.name ?? '?',
|
||||
isDefault: t.isDefault,
|
||||
}))
|
||||
)
|
||||
)
|
||||
return allTransitions.length > 0 ? (
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{allTransitions.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center gap-1 text-xs py-1 border-b last:border-0"
|
||||
>
|
||||
<span className="truncate">{t.fromName}</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="truncate">{t.toName}</span>
|
||||
{t.isDefault && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[8px] h-3.5 ml-auto shrink-0"
|
||||
>
|
||||
default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground py-3 text-center">
|
||||
No transitions defined
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Simulation */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
Simulation
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Test where projects would route
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Project IDs (comma-separated)</Label>
|
||||
<Input
|
||||
value={simulationProjectIds}
|
||||
onChange={(e) => setSimulationProjectIds(e.target.value)}
|
||||
placeholder="id1, id2, id3"
|
||||
className="text-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={simulateMutation.isPending || !simulationProjectIds.trim()}
|
||||
onClick={handleSimulate}
|
||||
>
|
||||
{simulateMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Run Simulation
|
||||
</Button>
|
||||
{simulateMutation.data?.simulations && (
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{simulateMutation.data.simulations.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs py-1 border-b last:border-0"
|
||||
>
|
||||
<span className="font-mono">{r.projectId.slice(0, 8)}</span>
|
||||
<span className="text-muted-foreground"> → </span>
|
||||
<span>{r.targetTrackName ?? 'unrouted'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog for destructive config saves */}
|
||||
<AlertDialog open={showSaveConfirm} onOpenChange={setShowSaveConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Save Stage Configuration?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This stage belongs to an active pipeline or has projects assigned to it.
|
||||
Changing the configuration may affect ongoing evaluations and project processing.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setShowSaveConfirm(false)
|
||||
executeSaveConfig()
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
export default async function AdvancedPipelinePage({
|
||||
params,
|
||||
}: AdvancedPipelinePageProps) {
|
||||
const { id } = await params
|
||||
redirect(`/admin/rounds/pipeline/${id}` as never)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
type EditPipelinePageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function EditPipelinePage({ params }: EditPipelinePageProps) {
|
||||
const { id } = await params
|
||||
// Editing now happens inline on the detail page
|
||||
redirect(`/admin/rounds/pipeline/${id}` as never)
|
||||
}
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
type EditPipelinePageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function EditPipelinePage({ params }: EditPipelinePageProps) {
|
||||
const { id } = await params
|
||||
// Editing now happens inline on the detail page
|
||||
redirect(`/admin/rounds/pipeline/${id}` as never)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,6 @@ import {
|
||||
Calendar,
|
||||
Workflow,
|
||||
Pencil,
|
||||
Settings2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
@@ -233,18 +232,12 @@ export default function PipelineListPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route} className="flex-1">
|
||||
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route} className="w-full">
|
||||
<Button size="sm" variant="outline" className="w-full">
|
||||
<Pencil className="h-3.5 w-3.5 mr-1.5" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/rounds/pipeline/${pipeline.id}/advanced` as Route} className="flex-1">
|
||||
<Button size="sm" variant="outline" className="w-full">
|
||||
<Settings2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Advanced
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
import { Suspense } from 'react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
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'])
|
||||
|
||||
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
||||
const settings = await prisma.systemSettings.findMany({
|
||||
orderBy: [{ category: 'asc' }, { key: 'asc' }],
|
||||
})
|
||||
|
||||
// Convert settings array to key-value map
|
||||
// For secrets, pass a marker but not the actual value
|
||||
// For non-super-admins, filter out infrastructure categories
|
||||
const settingsMap: Record<string, string> = {}
|
||||
settings.forEach((setting) => {
|
||||
if (!isSuperAdmin && SUPER_ADMIN_CATEGORIES.has(setting.category)) {
|
||||
return
|
||||
}
|
||||
if (setting.isSecret && setting.value) {
|
||||
// Pass marker for UI to show "existing" state
|
||||
settingsMap[setting.key] = '********'
|
||||
} else {
|
||||
settingsMap[setting.key] = setting.value
|
||||
}
|
||||
})
|
||||
|
||||
return <SettingsContent initialSettings={settingsMap} isSuperAdmin={isSuperAdmin} />
|
||||
}
|
||||
|
||||
function SettingsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await auth()
|
||||
|
||||
// Only admins (super admin + program admin) can access settings
|
||||
if (session?.user?.role !== 'SUPER_ADMIN' && session?.user?.role !== 'PROGRAM_ADMIN') {
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure platform settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<SettingsSkeleton />}>
|
||||
<SettingsLoader isSuperAdmin={isSuperAdmin} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { Suspense } from 'react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
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'])
|
||||
|
||||
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
||||
const settings = await prisma.systemSettings.findMany({
|
||||
orderBy: [{ category: 'asc' }, { key: 'asc' }],
|
||||
})
|
||||
|
||||
// Convert settings array to key-value map
|
||||
// For secrets, pass a marker but not the actual value
|
||||
// For non-super-admins, filter out infrastructure categories
|
||||
const settingsMap: Record<string, string> = {}
|
||||
settings.forEach((setting) => {
|
||||
if (!isSuperAdmin && SUPER_ADMIN_CATEGORIES.has(setting.category)) {
|
||||
return
|
||||
}
|
||||
if (setting.isSecret && setting.value) {
|
||||
// Pass marker for UI to show "existing" state
|
||||
settingsMap[setting.key] = '********'
|
||||
} else {
|
||||
settingsMap[setting.key] = setting.value
|
||||
}
|
||||
})
|
||||
|
||||
return <SettingsContent initialSettings={settingsMap} isSuperAdmin={isSuperAdmin} />
|
||||
}
|
||||
|
||||
function SettingsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await auth()
|
||||
|
||||
// Only admins (super admin + program admin) can access settings
|
||||
if (session?.user?.role !== 'SUPER_ADMIN' && session?.user?.role !== 'PROGRAM_ADMIN') {
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure platform settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<SettingsSkeleton />}>
|
||||
<SettingsLoader isSuperAdmin={isSuperAdmin} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,72 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, LayoutDashboard } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function AdminError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Admin section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('admin')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this admin page. Please try again or return to the dashboard.'}
|
||||
</p>
|
||||
{!isChunk && (error.message || error.digest) && (
|
||||
<p className="text-xs text-muted-foreground bg-muted rounded px-3 py-2 font-mono break-all">
|
||||
{error.message || `Error ID: ${error.digest}`}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/admin">
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, LayoutDashboard } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function AdminError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Admin section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('admin')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this admin page. Please try again or return to the dashboard.'}
|
||||
</p>
|
||||
{!isChunk && (error.message || error.digest) && (
|
||||
<p className="text-xs text-muted-foreground bg-muted rounded px-3 py-2 font-mono break-all">
|
||||
{error.message || `Error ID: ${error.digest}`}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/admin">
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,213 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
|
||||
import {
|
||||
FileText,
|
||||
Upload,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Video,
|
||||
File,
|
||||
Download,
|
||||
} from 'lucide-react'
|
||||
|
||||
const fileTypeIcons: Record<string, typeof FileText> = {
|
||||
EXEC_SUMMARY: FileText,
|
||||
BUSINESS_PLAN: FileText,
|
||||
PRESENTATION: FileText,
|
||||
VIDEO_PITCH: Video,
|
||||
VIDEO: Video,
|
||||
OTHER: File,
|
||||
SUPPORTING_DOC: File,
|
||||
}
|
||||
|
||||
const fileTypeLabels: Record<string, string> = {
|
||||
EXEC_SUMMARY: 'Executive Summary',
|
||||
BUSINESS_PLAN: 'Business Plan',
|
||||
PRESENTATION: 'Presentation',
|
||||
VIDEO_PITCH: 'Video Pitch',
|
||||
VIDEO: 'Video',
|
||||
OTHER: 'Other Document',
|
||||
SUPPORTING_DOC: 'Supporting Document',
|
||||
}
|
||||
|
||||
export default function ApplicantDocumentsPage() {
|
||||
const { status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
||||
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data?.project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Documents</h1>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
Submit a project first to upload documents.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { project, openStages } = data
|
||||
const isDraft = !project.submittedAt
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<Upload className="h-6 w-6" />
|
||||
Documents
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Upload and manage documents for your project: {project.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Per-stage upload sections */}
|
||||
{openStages.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{openStages.map((stage) => {
|
||||
const now = new Date()
|
||||
const hasDeadline = !!stage.windowCloseAt
|
||||
const deadlinePassed = hasDeadline && now > new Date(stage.windowCloseAt!)
|
||||
const isLate = deadlinePassed
|
||||
|
||||
return (
|
||||
<Card key={stage.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{stage.name}</CardTitle>
|
||||
<CardDescription>
|
||||
Upload documents for this stage
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLate && (
|
||||
<Badge variant="warning" className="gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Late submission
|
||||
</Badge>
|
||||
)}
|
||||
{hasDeadline && !deadlinePassed && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Due {new Date(stage.windowCloseAt!).toLocaleDateString()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RequirementUploadList
|
||||
projectId={project.id}
|
||||
stageId={stage.id}
|
||||
disabled={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploaded files list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Uploaded Documents</CardTitle>
|
||||
<CardDescription>
|
||||
All files associated with your project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.files.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4">
|
||||
No documents uploaded yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{project.files.map((file) => {
|
||||
const Icon = fileTypeIcons[file.fileType] || File
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm">{file.fileName}</p>
|
||||
{fileRecord.isLate && (
|
||||
<Badge variant="warning" className="text-xs gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Late
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{fileTypeLabels[file.fileType] || file.fileType}
|
||||
{' - '}
|
||||
{new Date(file.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* No open stages message */}
|
||||
{openStages.length === 0 && project.files.length === 0 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-6 text-center">
|
||||
<Clock className="h-10 w-10 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground">
|
||||
No stages are currently open for document submissions.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
|
||||
import {
|
||||
FileText,
|
||||
Upload,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Video,
|
||||
File,
|
||||
Download,
|
||||
} from 'lucide-react'
|
||||
|
||||
const fileTypeIcons: Record<string, typeof FileText> = {
|
||||
EXEC_SUMMARY: FileText,
|
||||
BUSINESS_PLAN: FileText,
|
||||
PRESENTATION: FileText,
|
||||
VIDEO_PITCH: Video,
|
||||
VIDEO: Video,
|
||||
OTHER: File,
|
||||
SUPPORTING_DOC: File,
|
||||
}
|
||||
|
||||
const fileTypeLabels: Record<string, string> = {
|
||||
EXEC_SUMMARY: 'Executive Summary',
|
||||
BUSINESS_PLAN: 'Business Plan',
|
||||
PRESENTATION: 'Presentation',
|
||||
VIDEO_PITCH: 'Video Pitch',
|
||||
VIDEO: 'Video',
|
||||
OTHER: 'Other Document',
|
||||
SUPPORTING_DOC: 'Supporting Document',
|
||||
}
|
||||
|
||||
export default function ApplicantDocumentsPage() {
|
||||
const { status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
||||
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data?.project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Documents</h1>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
Submit a project first to upload documents.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { project, openStages } = data
|
||||
const isDraft = !project.submittedAt
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<Upload className="h-6 w-6" />
|
||||
Documents
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Upload and manage documents for your project: {project.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Per-stage upload sections */}
|
||||
{openStages.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{openStages.map((stage) => {
|
||||
const now = new Date()
|
||||
const hasDeadline = !!stage.windowCloseAt
|
||||
const deadlinePassed = hasDeadline && now > new Date(stage.windowCloseAt!)
|
||||
const isLate = deadlinePassed
|
||||
|
||||
return (
|
||||
<Card key={stage.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{stage.name}</CardTitle>
|
||||
<CardDescription>
|
||||
Upload documents for this stage
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLate && (
|
||||
<Badge variant="warning" className="gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Late submission
|
||||
</Badge>
|
||||
)}
|
||||
{hasDeadline && !deadlinePassed && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Due {new Date(stage.windowCloseAt!).toLocaleDateString()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RequirementUploadList
|
||||
projectId={project.id}
|
||||
stageId={stage.id}
|
||||
disabled={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploaded files list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Uploaded Documents</CardTitle>
|
||||
<CardDescription>
|
||||
All files associated with your project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.files.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4">
|
||||
No documents uploaded yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{project.files.map((file) => {
|
||||
const Icon = fileTypeIcons[file.fileType] || File
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm">{file.fileName}</p>
|
||||
{fileRecord.isLate && (
|
||||
<Badge variant="warning" className="text-xs gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Late
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{fileTypeLabels[file.fileType] || file.fileType}
|
||||
{' - '}
|
||||
{new Date(file.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* No open stages message */}
|
||||
{openStages.length === 0 && project.files.length === 0 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-6 text-center">
|
||||
<Clock className="h-10 w-10 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground">
|
||||
No stages are currently open for document submissions.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,138 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import {
|
||||
MessageSquare,
|
||||
UserCircle,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function ApplicantMentorPage() {
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
||||
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
|
||||
undefined,
|
||||
{ enabled: isAuthenticated }
|
||||
)
|
||||
|
||||
const projectId = dashboardData?.project?.id
|
||||
|
||||
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
|
||||
{ projectId: projectId! },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.applicant.getMentorMessages.invalidate({ projectId: projectId! })
|
||||
},
|
||||
})
|
||||
|
||||
if (dashLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Mentor</h1>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
Submit a project first to communicate with your mentor.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mentor = dashboardData?.project?.mentorAssignment?.mentor
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<MessageSquare className="h-6 w-6" />
|
||||
Mentor Communication
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Chat with your assigned mentor
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mentor info */}
|
||||
{mentor ? (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserCircle className="h-10 w-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">{mentor.name || 'Mentor'}</p>
|
||||
<p className="text-sm text-muted-foreground">{mentor.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||
<UserCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
No mentor has been assigned to your project yet.
|
||||
You'll be notified when a mentor is assigned.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Chat */}
|
||||
{mentor && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Messages</CardTitle>
|
||||
<CardDescription>
|
||||
Your conversation history with {mentor.name || 'your mentor'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MentorChat
|
||||
messages={mentorMessages || []}
|
||||
currentUserId={session?.user?.id || ''}
|
||||
onSendMessage={async (message) => {
|
||||
await sendMessage.mutateAsync({ projectId: projectId!, message })
|
||||
}}
|
||||
isLoading={messagesLoading}
|
||||
isSending={sendMessage.isPending}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import {
|
||||
MessageSquare,
|
||||
UserCircle,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function ApplicantMentorPage() {
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
||||
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
|
||||
undefined,
|
||||
{ enabled: isAuthenticated }
|
||||
)
|
||||
|
||||
const projectId = dashboardData?.project?.id
|
||||
|
||||
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
|
||||
{ projectId: projectId! },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.applicant.getMentorMessages.invalidate({ projectId: projectId! })
|
||||
},
|
||||
})
|
||||
|
||||
if (dashLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Mentor</h1>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
Submit a project first to communicate with your mentor.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mentor = dashboardData?.project?.mentorAssignment?.mentor
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<MessageSquare className="h-6 w-6" />
|
||||
Mentor Communication
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Chat with your assigned mentor
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mentor info */}
|
||||
{mentor ? (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserCircle className="h-10 w-10 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">{mentor.name || 'Mentor'}</p>
|
||||
<p className="text-sm text-muted-foreground">{mentor.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||
<UserCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
No mentor has been assigned to your project yet.
|
||||
You'll be notified when a mentor is assigned.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Chat */}
|
||||
{mentor && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Messages</CardTitle>
|
||||
<CardDescription>
|
||||
Your conversation history with {mentor.name || 'your mentor'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MentorChat
|
||||
messages={mentorMessages || []}
|
||||
currentUserId={session?.user?.id || ''}
|
||||
onSendMessage={async (message) => {
|
||||
await sendMessage.mutateAsync({ projectId: projectId!, message })
|
||||
}}
|
||||
isLoading={messagesLoading}
|
||||
isSending={sendMessage.isPending}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,363 +1,363 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Users,
|
||||
Crown,
|
||||
MessageSquare,
|
||||
Upload,
|
||||
ArrowRight,
|
||||
} from 'lucide-react'
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||
DRAFT: 'secondary',
|
||||
SUBMITTED: 'default',
|
||||
UNDER_REVIEW: 'default',
|
||||
ELIGIBLE: 'default',
|
||||
SEMIFINALIST: 'success',
|
||||
FINALIST: 'success',
|
||||
WINNER: 'success',
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
export default function ApplicantDashboardPage() {
|
||||
const { status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
||||
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
if (sessionStatus === 'loading' || isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No project yet
|
||||
if (!data?.project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">My Project</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Your applicant dashboard
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedCard index={0}>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="rounded-2xl bg-muted/60 p-4 mb-4">
|
||||
<FileText className="h-8 w-8 text-muted-foreground/70" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">No Project Yet</h2>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
You haven't submitted a project yet. Check for open application rounds
|
||||
on the MOPC website.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { project, timeline, currentStatus, openStages } = data
|
||||
const isDraft = !project.submittedAt
|
||||
const programYear = project.program?.year
|
||||
const programName = project.program?.name
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
|
||||
{currentStatus && (
|
||||
<Badge variant={statusColors[currentStatus] || 'secondary'}>
|
||||
{currentStatus.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Draft warning */}
|
||||
{isDraft && (
|
||||
<Alert>
|
||||
<Clock className="h-4 w-4" />
|
||||
<AlertTitle>Draft Submission</AlertTitle>
|
||||
<AlertDescription>
|
||||
This submission has not been submitted yet. You can continue editing and submit when ready.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Project details */}
|
||||
<AnimatedCard index={0}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{project.teamName && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
|
||||
<p>{project.teamName}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.description && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Description</p>
|
||||
<p className="whitespace-pre-wrap">{project.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">Additional Information</p>
|
||||
<dl className="space-y-2">
|
||||
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<dt className="text-sm text-muted-foreground capitalize">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</dt>
|
||||
<dd className="text-sm font-medium">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta info row */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground border-t pt-4 mt-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Created {new Date(project.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
{project.submittedAt ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
Submitted {new Date(project.submittedAt).toLocaleDateString()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4 text-orange-500" />
|
||||
Draft
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
{project.files.length} file(s)
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Quick actions */}
|
||||
<AnimatedCard index={1}>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
||||
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
|
||||
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Documents</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{openStages.length > 0 ? `${openStages.length} stage(s) open` : 'View uploads'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
|
||||
<Link href={"/applicant/team" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-purple-500/30 hover:bg-purple-500/5">
|
||||
<div className="rounded-xl bg-purple-500/10 p-2.5 transition-colors group-hover:bg-purple-500/20">
|
||||
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Team</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.teamMembers.length} member(s)
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
|
||||
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
|
||||
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Mentor</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status timeline */}
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusTracker
|
||||
timeline={timeline}
|
||||
currentStatus={currentStatus || 'SUBMITTED'}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Team overview */}
|
||||
<AnimatedCard index={3}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Team
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={"/applicant/team" as Route}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{project.teamMembers.length > 0 ? (
|
||||
project.teamMembers.slice(0, 5).map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
|
||||
{member.role === 'LEAD' ? (
|
||||
<Crown className="h-4 w-4 text-yellow-500" />
|
||||
) : (
|
||||
<span className="text-xs font-medium">
|
||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{member.user.name || member.user.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">
|
||||
No team members yet
|
||||
</p>
|
||||
)}
|
||||
{project.teamMembers.length > 5 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
+{project.teamMembers.length - 5} more
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Key dates */}
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Dates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{project.submittedAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Submitted</span>
|
||||
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last Updated</span>
|
||||
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{openStages.length > 0 && openStages[0].windowCloseAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Deadline</span>
|
||||
<span>{new Date(openStages[0].windowCloseAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Users,
|
||||
Crown,
|
||||
MessageSquare,
|
||||
Upload,
|
||||
ArrowRight,
|
||||
} from 'lucide-react'
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||
DRAFT: 'secondary',
|
||||
SUBMITTED: 'default',
|
||||
UNDER_REVIEW: 'default',
|
||||
ELIGIBLE: 'default',
|
||||
SEMIFINALIST: 'success',
|
||||
FINALIST: 'success',
|
||||
WINNER: 'success',
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
export default function ApplicantDashboardPage() {
|
||||
const { status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
||||
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
if (sessionStatus === 'loading' || isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No project yet
|
||||
if (!data?.project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">My Project</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Your applicant dashboard
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedCard index={0}>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="rounded-2xl bg-muted/60 p-4 mb-4">
|
||||
<FileText className="h-8 w-8 text-muted-foreground/70" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">No Project Yet</h2>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
You haven't submitted a project yet. Check for open application rounds
|
||||
on the MOPC website.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { project, timeline, currentStatus, openStages } = data
|
||||
const isDraft = !project.submittedAt
|
||||
const programYear = project.program?.year
|
||||
const programName = project.program?.name
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
|
||||
{currentStatus && (
|
||||
<Badge variant={statusColors[currentStatus] || 'secondary'}>
|
||||
{currentStatus.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Draft warning */}
|
||||
{isDraft && (
|
||||
<Alert>
|
||||
<Clock className="h-4 w-4" />
|
||||
<AlertTitle>Draft Submission</AlertTitle>
|
||||
<AlertDescription>
|
||||
This submission has not been submitted yet. You can continue editing and submit when ready.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Project details */}
|
||||
<AnimatedCard index={0}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{project.teamName && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
|
||||
<p>{project.teamName}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.description && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Description</p>
|
||||
<p className="whitespace-pre-wrap">{project.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">Additional Information</p>
|
||||
<dl className="space-y-2">
|
||||
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<dt className="text-sm text-muted-foreground capitalize">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</dt>
|
||||
<dd className="text-sm font-medium">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta info row */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground border-t pt-4 mt-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Created {new Date(project.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
{project.submittedAt ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
Submitted {new Date(project.submittedAt).toLocaleDateString()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4 text-orange-500" />
|
||||
Draft
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
{project.files.length} file(s)
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Quick actions */}
|
||||
<AnimatedCard index={1}>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
||||
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
|
||||
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Documents</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{openStages.length > 0 ? `${openStages.length} stage(s) open` : 'View uploads'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
|
||||
<Link href={"/applicant/team" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-purple-500/30 hover:bg-purple-500/5">
|
||||
<div className="rounded-xl bg-purple-500/10 p-2.5 transition-colors group-hover:bg-purple-500/20">
|
||||
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Team</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.teamMembers.length} member(s)
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
|
||||
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
|
||||
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Mentor</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status timeline */}
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusTracker
|
||||
timeline={timeline}
|
||||
currentStatus={currentStatus || 'SUBMITTED'}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Team overview */}
|
||||
<AnimatedCard index={3}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Team
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={"/applicant/team" as Route}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{project.teamMembers.length > 0 ? (
|
||||
project.teamMembers.slice(0, 5).map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
|
||||
{member.role === 'LEAD' ? (
|
||||
<Crown className="h-4 w-4 text-yellow-500" />
|
||||
) : (
|
||||
<span className="text-xs font-medium">
|
||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{member.user.name || member.user.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">
|
||||
No team members yet
|
||||
</p>
|
||||
)}
|
||||
{project.teamMembers.length > 5 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
+{project.teamMembers.length - 5} more
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Key dates */}
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Dates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{project.submittedAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Submitted</span>
|
||||
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last Updated</span>
|
||||
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{openStages.length > 0 && openStages[0].windowCloseAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Deadline</span>
|
||||
<span>{new Date(openStages[0].windowCloseAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,167 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
FileText,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
|
||||
|
||||
export default function StageDocumentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
|
||||
// Get applicant's project via dashboard endpoint
|
||||
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
|
||||
const project = dashboard?.project
|
||||
const projectId = project?.id ?? ''
|
||||
|
||||
const { data: requirements, isLoading: reqLoading } =
|
||||
trpc.stage.getRequirements.useQuery(
|
||||
{ stageId, projectId },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
const isWindowOpen = requirements?.windowStatus?.isOpen ?? false
|
||||
const isLate = requirements?.windowStatus?.isLate ?? false
|
||||
const closeAt = requirements?.windowStatus?.closesAt
|
||||
? new Date(requirements.windowStatus.closesAt)
|
||||
: null
|
||||
|
||||
if (reqLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/applicant/pipeline" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Back to Pipeline</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Documents</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Upload required documents for this stage
|
||||
</p>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={requirements?.deadlineInfo?.windowOpenAt}
|
||||
windowCloseAt={requirements?.deadlineInfo?.windowCloseAt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Deadline info */}
|
||||
{closeAt && isWindowOpen && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">Submission deadline</span>
|
||||
</div>
|
||||
<CountdownTimer deadline={closeAt} label="Closes in" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Late submission warning */}
|
||||
{isLate && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
The submission window has passed. Late submissions may be accepted at the discretion of the administrators.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed */}
|
||||
{!isWindowOpen && !isLate && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">
|
||||
The document submission window for this stage is closed.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* File requirements */}
|
||||
{requirements?.fileRequirements && requirements.fileRequirements.length > 0 ? (
|
||||
<RequirementUploadList
|
||||
projectId={projectId}
|
||||
stageId={stageId}
|
||||
disabled={!isWindowOpen && !isLate}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No document requirements</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
There are no specific document requirements for this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Uploaded files summary */}
|
||||
{requirements?.uploadedFiles && requirements.uploadedFiles.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Uploaded Files</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{requirements.uploadedFiles.map((file: { id: string; fileName: string; size: number; createdAt: string | Date }) => (
|
||||
<div key={file.id} className="flex items-center gap-3 text-sm">
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{file.fileName}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{(file.size / (1024 * 1024)).toFixed(1)}MB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
FileText,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
|
||||
|
||||
export default function StageDocumentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
|
||||
// Get applicant's project via dashboard endpoint
|
||||
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
|
||||
const project = dashboard?.project
|
||||
const projectId = project?.id ?? ''
|
||||
|
||||
const { data: requirements, isLoading: reqLoading } =
|
||||
trpc.stage.getRequirements.useQuery(
|
||||
{ stageId, projectId },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
const isWindowOpen = requirements?.windowStatus?.isOpen ?? false
|
||||
const isLate = requirements?.windowStatus?.isLate ?? false
|
||||
const closeAt = requirements?.windowStatus?.closesAt
|
||||
? new Date(requirements.windowStatus.closesAt)
|
||||
: null
|
||||
|
||||
if (reqLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/applicant/pipeline" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Back to Pipeline</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Documents</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Upload required documents for this stage
|
||||
</p>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={requirements?.deadlineInfo?.windowOpenAt}
|
||||
windowCloseAt={requirements?.deadlineInfo?.windowCloseAt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Deadline info */}
|
||||
{closeAt && isWindowOpen && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">Submission deadline</span>
|
||||
</div>
|
||||
<CountdownTimer deadline={closeAt} label="Closes in" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Late submission warning */}
|
||||
{isLate && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
The submission window has passed. Late submissions may be accepted at the discretion of the administrators.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed */}
|
||||
{!isWindowOpen && !isLate && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">
|
||||
The document submission window for this stage is closed.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* File requirements */}
|
||||
{requirements?.fileRequirements && requirements.fileRequirements.length > 0 ? (
|
||||
<RequirementUploadList
|
||||
projectId={projectId}
|
||||
stageId={stageId}
|
||||
disabled={!isWindowOpen && !isLate}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No document requirements</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
There are no specific document requirements for this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Uploaded files summary */}
|
||||
{requirements?.uploadedFiles && requirements.uploadedFiles.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Uploaded Files</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{requirements.uploadedFiles.map((file: { id: string; fileName: string; size: number; createdAt: string | Date }) => (
|
||||
<div key={file.id} className="flex items-center gap-3 text-sm">
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{file.fileName}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{(file.size / (1024 * 1024)).toFixed(1)}MB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,278 +1,278 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
const stateLabels: Record<string, string> = {
|
||||
PENDING: 'Pending',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
PASSED: 'Passed',
|
||||
REJECTED: 'Not Selected',
|
||||
COMPLETED: 'Completed',
|
||||
WAITING: 'Waiting',
|
||||
}
|
||||
|
||||
const stateColors: Record<string, string> = {
|
||||
PASSED: 'text-emerald-600 bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:bg-emerald-950/30 dark:border-emerald-900',
|
||||
COMPLETED: 'text-emerald-600 bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:bg-emerald-950/30 dark:border-emerald-900',
|
||||
REJECTED: 'text-destructive bg-destructive/5 border-destructive/30',
|
||||
IN_PROGRESS: 'text-blue-600 bg-blue-50 border-blue-200 dark:text-blue-400 dark:bg-blue-950/30 dark:border-blue-900',
|
||||
PENDING: 'text-muted-foreground bg-muted border-muted',
|
||||
WAITING: 'text-amber-600 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/30 dark:border-amber-900',
|
||||
}
|
||||
|
||||
export default function StageStatusPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
|
||||
// Get applicant's project via dashboard endpoint
|
||||
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
|
||||
const project = dashboard?.project
|
||||
const projectId = project?.id ?? ''
|
||||
const programId = project?.program?.id ?? ''
|
||||
|
||||
const { data: pipelineView } =
|
||||
trpc.pipeline.getApplicantView.useQuery(
|
||||
{ programId, projectId },
|
||||
{ enabled: !!programId && !!projectId }
|
||||
)
|
||||
|
||||
const { data: timeline, isLoading } =
|
||||
trpc.stage.getApplicantTimeline.useQuery(
|
||||
{ projectId, pipelineId: pipelineView?.pipelineId ?? '' },
|
||||
{ enabled: !!projectId && !!pipelineView?.pipelineId }
|
||||
)
|
||||
|
||||
// Find the specific stage
|
||||
const stageData = timeline?.find((item) => item.stageId === stageId)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/applicant/pipeline" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Back to Pipeline</span>
|
||||
</div>
|
||||
|
||||
{stageData ? (
|
||||
<>
|
||||
{/* Stage state card */}
|
||||
<Card className={`border ${stateColors[stageData.state] ?? ''}`}>
|
||||
<CardContent className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{stageData.state === 'PASSED' || stageData.state === 'COMPLETED' ? (
|
||||
<CheckCircle2 className="h-12 w-12 text-emerald-600" />
|
||||
) : stageData.state === 'REJECTED' ? (
|
||||
<XCircle className="h-12 w-12 text-destructive" />
|
||||
) : (
|
||||
<Clock className="h-12 w-12 text-blue-600" />
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{stageData.stageName}</h2>
|
||||
<Badge className="mt-2 text-sm">
|
||||
{stateLabels[stageData.state] ?? stageData.state}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Decision details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Stage Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Stage Type</p>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{stageData.stageType.toLowerCase().replace(/_/g, ' ')}
|
||||
</p>
|
||||
</div>
|
||||
{stageData.enteredAt && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Entered</p>
|
||||
<p className="text-sm font-medium">
|
||||
{new Date(stageData.enteredAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{stageData.exitedAt && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Exited</p>
|
||||
<p className="text-sm font-medium">
|
||||
{new Date(stageData.exitedAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Next steps */}
|
||||
{(stageData.state === 'IN_PROGRESS' || stageData.state === 'PENDING') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Next Steps
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{stageData.stageType === 'INTAKE' && (
|
||||
<>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
Make sure all required documents are uploaded before the deadline.
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
You will be notified once reviewers complete their evaluation.
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
{stageData.stageType === 'EVALUATION' && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
Your project is being reviewed by jury members. Results will be shared once evaluation is complete.
|
||||
</li>
|
||||
)}
|
||||
{stageData.stageType === 'LIVE_FINAL' && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
Prepare for the live presentation. Check your email for schedule and logistics details.
|
||||
</li>
|
||||
)}
|
||||
{!['INTAKE', 'EVALUATION', 'LIVE_FINAL'].includes(stageData.stageType) && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
Your project is progressing through this stage. Updates will appear here.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Full timeline */}
|
||||
{timeline && timeline.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Full Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-0">
|
||||
{timeline.map((item, index) => (
|
||||
<div key={item.stageId} className="relative flex gap-4">
|
||||
{index < timeline.length - 1 && (
|
||||
<div className={`absolute left-[11px] top-[24px] h-full w-0.5 ${
|
||||
item.state === 'PASSED' || item.state === 'COMPLETED'
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-muted'
|
||||
}`} />
|
||||
)}
|
||||
<div className="relative z-10 flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<div className={`h-3 w-3 rounded-full ${
|
||||
item.stageId === stageId
|
||||
? 'ring-2 ring-brand-blue ring-offset-2 bg-brand-blue'
|
||||
: item.state === 'PASSED' || item.state === 'COMPLETED'
|
||||
? 'bg-emerald-500'
|
||||
: item.state === 'REJECTED'
|
||||
? 'bg-destructive'
|
||||
: item.isCurrent
|
||||
? 'bg-blue-500'
|
||||
: 'bg-muted-foreground/30'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex-1 pb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-medium ${
|
||||
item.stageId === stageId ? 'text-brand-blue dark:text-brand-teal' : ''
|
||||
}`}>
|
||||
{item.stageName}
|
||||
</p>
|
||||
<Badge
|
||||
variant={
|
||||
item.state === 'PASSED' || item.state === 'COMPLETED'
|
||||
? 'success'
|
||||
: item.state === 'REJECTED'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{stateLabels[item.state] ?? item.state}
|
||||
</Badge>
|
||||
</div>
|
||||
{item.enteredAt && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{new Date(item.enteredAt).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Stage not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Your project has not entered this stage yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
const stateLabels: Record<string, string> = {
|
||||
PENDING: 'Pending',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
PASSED: 'Passed',
|
||||
REJECTED: 'Not Selected',
|
||||
COMPLETED: 'Completed',
|
||||
WAITING: 'Waiting',
|
||||
}
|
||||
|
||||
const stateColors: Record<string, string> = {
|
||||
PASSED: 'text-emerald-600 bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:bg-emerald-950/30 dark:border-emerald-900',
|
||||
COMPLETED: 'text-emerald-600 bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:bg-emerald-950/30 dark:border-emerald-900',
|
||||
REJECTED: 'text-destructive bg-destructive/5 border-destructive/30',
|
||||
IN_PROGRESS: 'text-blue-600 bg-blue-50 border-blue-200 dark:text-blue-400 dark:bg-blue-950/30 dark:border-blue-900',
|
||||
PENDING: 'text-muted-foreground bg-muted border-muted',
|
||||
WAITING: 'text-amber-600 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/30 dark:border-amber-900',
|
||||
}
|
||||
|
||||
export default function StageStatusPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
|
||||
// Get applicant's project via dashboard endpoint
|
||||
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
|
||||
const project = dashboard?.project
|
||||
const projectId = project?.id ?? ''
|
||||
const programId = project?.program?.id ?? ''
|
||||
|
||||
const { data: pipelineView } =
|
||||
trpc.pipeline.getApplicantView.useQuery(
|
||||
{ programId, projectId },
|
||||
{ enabled: !!programId && !!projectId }
|
||||
)
|
||||
|
||||
const { data: timeline, isLoading } =
|
||||
trpc.stage.getApplicantTimeline.useQuery(
|
||||
{ projectId, pipelineId: pipelineView?.pipelineId ?? '' },
|
||||
{ enabled: !!projectId && !!pipelineView?.pipelineId }
|
||||
)
|
||||
|
||||
// Find the specific stage
|
||||
const stageData = timeline?.find((item) => item.stageId === stageId)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/applicant/pipeline" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Back to Pipeline</span>
|
||||
</div>
|
||||
|
||||
{stageData ? (
|
||||
<>
|
||||
{/* Stage state card */}
|
||||
<Card className={`border ${stateColors[stageData.state] ?? ''}`}>
|
||||
<CardContent className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{stageData.state === 'PASSED' || stageData.state === 'COMPLETED' ? (
|
||||
<CheckCircle2 className="h-12 w-12 text-emerald-600" />
|
||||
) : stageData.state === 'REJECTED' ? (
|
||||
<XCircle className="h-12 w-12 text-destructive" />
|
||||
) : (
|
||||
<Clock className="h-12 w-12 text-blue-600" />
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{stageData.stageName}</h2>
|
||||
<Badge className="mt-2 text-sm">
|
||||
{stateLabels[stageData.state] ?? stageData.state}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Decision details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Stage Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Stage Type</p>
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{stageData.stageType.toLowerCase().replace(/_/g, ' ')}
|
||||
</p>
|
||||
</div>
|
||||
{stageData.enteredAt && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Entered</p>
|
||||
<p className="text-sm font-medium">
|
||||
{new Date(stageData.enteredAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{stageData.exitedAt && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Exited</p>
|
||||
<p className="text-sm font-medium">
|
||||
{new Date(stageData.exitedAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Next steps */}
|
||||
{(stageData.state === 'IN_PROGRESS' || stageData.state === 'PENDING') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Next Steps
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{stageData.stageType === 'INTAKE' && (
|
||||
<>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
Make sure all required documents are uploaded before the deadline.
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
You will be notified once reviewers complete their evaluation.
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
{stageData.stageType === 'EVALUATION' && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
Your project is being reviewed by jury members. Results will be shared once evaluation is complete.
|
||||
</li>
|
||||
)}
|
||||
{stageData.stageType === 'LIVE_FINAL' && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
Prepare for the live presentation. Check your email for schedule and logistics details.
|
||||
</li>
|
||||
)}
|
||||
{!['INTAKE', 'EVALUATION', 'LIVE_FINAL'].includes(stageData.stageType) && (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
|
||||
Your project is progressing through this stage. Updates will appear here.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Full timeline */}
|
||||
{timeline && timeline.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Full Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-0">
|
||||
{timeline.map((item, index) => (
|
||||
<div key={item.stageId} className="relative flex gap-4">
|
||||
{index < timeline.length - 1 && (
|
||||
<div className={`absolute left-[11px] top-[24px] h-full w-0.5 ${
|
||||
item.state === 'PASSED' || item.state === 'COMPLETED'
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-muted'
|
||||
}`} />
|
||||
)}
|
||||
<div className="relative z-10 flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<div className={`h-3 w-3 rounded-full ${
|
||||
item.stageId === stageId
|
||||
? 'ring-2 ring-brand-blue ring-offset-2 bg-brand-blue'
|
||||
: item.state === 'PASSED' || item.state === 'COMPLETED'
|
||||
? 'bg-emerald-500'
|
||||
: item.state === 'REJECTED'
|
||||
? 'bg-destructive'
|
||||
: item.isCurrent
|
||||
? 'bg-blue-500'
|
||||
: 'bg-muted-foreground/30'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex-1 pb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-medium ${
|
||||
item.stageId === stageId ? 'text-brand-blue dark:text-brand-teal' : ''
|
||||
}`}>
|
||||
{item.stageName}
|
||||
</p>
|
||||
<Badge
|
||||
variant={
|
||||
item.state === 'PASSED' || item.state === 'COMPLETED'
|
||||
? 'success'
|
||||
: item.state === 'REJECTED'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{stateLabels[item.state] ?? item.state}
|
||||
</Badge>
|
||||
</div>
|
||||
{item.enteredAt && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{new Date(item.enteredAt).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Stage not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Your project has not entered this stage yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,267 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Upload,
|
||||
Users,
|
||||
MessageSquare,
|
||||
ArrowRight,
|
||||
FileText,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { StageTimeline } from '@/components/shared/stage-timeline'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const stateLabels: Record<string, string> = {
|
||||
PENDING: 'Pending',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
PASSED: 'Passed',
|
||||
REJECTED: 'Not Selected',
|
||||
COMPLETED: 'Completed',
|
||||
WAITING: 'Waiting',
|
||||
}
|
||||
|
||||
const stateVariants: Record<string, 'success' | 'destructive' | 'warning' | 'secondary' | 'info'> = {
|
||||
PENDING: 'secondary',
|
||||
IN_PROGRESS: 'info',
|
||||
PASSED: 'success',
|
||||
REJECTED: 'destructive',
|
||||
COMPLETED: 'success',
|
||||
WAITING: 'warning',
|
||||
}
|
||||
|
||||
export default function ApplicantPipelinePage() {
|
||||
// Get applicant's project via dashboard endpoint
|
||||
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
|
||||
|
||||
const project = dashboard?.project
|
||||
const projectId = project?.id ?? ''
|
||||
const programId = project?.program?.id ?? ''
|
||||
|
||||
const { data: pipelineView, isLoading: pipelineLoading } =
|
||||
trpc.pipeline.getApplicantView.useQuery(
|
||||
{ programId, projectId },
|
||||
{ enabled: !!programId && !!projectId }
|
||||
)
|
||||
|
||||
const { data: timeline, isLoading: timelineLoading } =
|
||||
trpc.stage.getApplicantTimeline.useQuery(
|
||||
{ projectId, pipelineId: pipelineView?.pipelineId ?? '' },
|
||||
{ enabled: !!projectId && !!pipelineView?.pipelineId }
|
||||
)
|
||||
|
||||
const isLoading = pipelineLoading || timelineLoading
|
||||
|
||||
if (!project && !isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Layers className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No project found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You don't have a project in the current edition yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Build timeline items for StageTimeline
|
||||
const timelineItems = timeline?.map((item) => ({
|
||||
id: item.stageId,
|
||||
name: item.stageName,
|
||||
stageType: item.stageType,
|
||||
isCurrent: item.isCurrent,
|
||||
state: item.state,
|
||||
enteredAt: item.enteredAt,
|
||||
})) ?? []
|
||||
|
||||
// Find current stage
|
||||
const currentStage = timeline?.find((item) => item.isCurrent)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
|
||||
</div>
|
||||
|
||||
{/* Project title + status */}
|
||||
<Card>
|
||||
<CardContent className="py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{project?.title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{(project as { teamName?: string } | undefined)?.teamName}</p>
|
||||
</div>
|
||||
{currentStage && (
|
||||
<Badge variant={stateVariants[currentStage.state] ?? 'secondary'}>
|
||||
{stateLabels[currentStage.state] ?? currentStage.state}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stage Timeline visualization */}
|
||||
{timelineItems.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Pipeline Progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StageTimeline stages={timelineItems} orientation="horizontal" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current stage details */}
|
||||
{currentStage && (
|
||||
<Card className="border-brand-blue/30 dark:border-brand-teal/30">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Current Stage</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{currentStage.stageName}</h3>
|
||||
<p className="text-sm text-muted-foreground capitalize">
|
||||
{currentStage.stageType.toLowerCase().replace(/_/g, ' ')}
|
||||
</p>
|
||||
</div>
|
||||
{currentStage.enteredAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Entered {new Date(currentStage.enteredAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Decision history */}
|
||||
{timeline && timeline.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Stage History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{timeline.map((item) => (
|
||||
<div
|
||||
key={item.stageId}
|
||||
className="flex items-center justify-between py-2 border-b last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
item.state === 'PASSED' || item.state === 'COMPLETED'
|
||||
? 'bg-emerald-500'
|
||||
: item.state === 'REJECTED'
|
||||
? 'bg-destructive'
|
||||
: item.isCurrent
|
||||
? 'bg-blue-500'
|
||||
: 'bg-muted-foreground'
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{item.stageName}</p>
|
||||
{item.enteredAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(item.enteredAt).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={stateVariants[item.state] ?? 'secondary'} className="text-xs">
|
||||
{stateLabels[item.state] ?? item.state}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{currentStage && (
|
||||
<Link
|
||||
href={`/applicant/pipeline/${currentStage.stageId}/documents` as Route}
|
||||
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-blue-50 p-2 dark:bg-blue-950/40">
|
||||
<Upload className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">Upload Documents</p>
|
||||
<p className="text-xs text-muted-foreground">Submit required files</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={"/applicant/team" as Route}
|
||||
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-teal-50 p-2 dark:bg-teal-950/40">
|
||||
<Users className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">View Team</p>
|
||||
<p className="text-xs text-muted-foreground">Team members</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href={"/applicant/mentor" as Route}
|
||||
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-amber-500/30 hover:bg-amber-50/50 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-amber-50 p-2 dark:bg-amber-950/40">
|
||||
<MessageSquare className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">Contact Mentor</p>
|
||||
<p className="text-xs text-muted-foreground">Send a message</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Upload,
|
||||
Users,
|
||||
MessageSquare,
|
||||
ArrowRight,
|
||||
FileText,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { StageTimeline } from '@/components/shared/stage-timeline'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const stateLabels: Record<string, string> = {
|
||||
PENDING: 'Pending',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
PASSED: 'Passed',
|
||||
REJECTED: 'Not Selected',
|
||||
COMPLETED: 'Completed',
|
||||
WAITING: 'Waiting',
|
||||
}
|
||||
|
||||
const stateVariants: Record<string, 'success' | 'destructive' | 'warning' | 'secondary' | 'info'> = {
|
||||
PENDING: 'secondary',
|
||||
IN_PROGRESS: 'info',
|
||||
PASSED: 'success',
|
||||
REJECTED: 'destructive',
|
||||
COMPLETED: 'success',
|
||||
WAITING: 'warning',
|
||||
}
|
||||
|
||||
export default function ApplicantPipelinePage() {
|
||||
// Get applicant's project via dashboard endpoint
|
||||
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
|
||||
|
||||
const project = dashboard?.project
|
||||
const projectId = project?.id ?? ''
|
||||
const programId = project?.program?.id ?? ''
|
||||
|
||||
const { data: pipelineView, isLoading: pipelineLoading } =
|
||||
trpc.pipeline.getApplicantView.useQuery(
|
||||
{ programId, projectId },
|
||||
{ enabled: !!programId && !!projectId }
|
||||
)
|
||||
|
||||
const { data: timeline, isLoading: timelineLoading } =
|
||||
trpc.stage.getApplicantTimeline.useQuery(
|
||||
{ projectId, pipelineId: pipelineView?.pipelineId ?? '' },
|
||||
{ enabled: !!projectId && !!pipelineView?.pipelineId }
|
||||
)
|
||||
|
||||
const isLoading = pipelineLoading || timelineLoading
|
||||
|
||||
if (!project && !isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Layers className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No project found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You don't have a project in the current edition yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Build timeline items for StageTimeline
|
||||
const timelineItems = timeline?.map((item) => ({
|
||||
id: item.stageId,
|
||||
name: item.stageName,
|
||||
stageType: item.stageType,
|
||||
isCurrent: item.isCurrent,
|
||||
state: item.state,
|
||||
enteredAt: item.enteredAt,
|
||||
})) ?? []
|
||||
|
||||
// Find current stage
|
||||
const currentStage = timeline?.find((item) => item.isCurrent)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
|
||||
</div>
|
||||
|
||||
{/* Project title + status */}
|
||||
<Card>
|
||||
<CardContent className="py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{project?.title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{(project as { teamName?: string } | undefined)?.teamName}</p>
|
||||
</div>
|
||||
{currentStage && (
|
||||
<Badge variant={stateVariants[currentStage.state] ?? 'secondary'}>
|
||||
{stateLabels[currentStage.state] ?? currentStage.state}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stage Timeline visualization */}
|
||||
{timelineItems.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Pipeline Progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StageTimeline stages={timelineItems} orientation="horizontal" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current stage details */}
|
||||
{currentStage && (
|
||||
<Card className="border-brand-blue/30 dark:border-brand-teal/30">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Current Stage</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{currentStage.stageName}</h3>
|
||||
<p className="text-sm text-muted-foreground capitalize">
|
||||
{currentStage.stageType.toLowerCase().replace(/_/g, ' ')}
|
||||
</p>
|
||||
</div>
|
||||
{currentStage.enteredAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Entered {new Date(currentStage.enteredAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Decision history */}
|
||||
{timeline && timeline.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Stage History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{timeline.map((item) => (
|
||||
<div
|
||||
key={item.stageId}
|
||||
className="flex items-center justify-between py-2 border-b last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
item.state === 'PASSED' || item.state === 'COMPLETED'
|
||||
? 'bg-emerald-500'
|
||||
: item.state === 'REJECTED'
|
||||
? 'bg-destructive'
|
||||
: item.isCurrent
|
||||
? 'bg-blue-500'
|
||||
: 'bg-muted-foreground'
|
||||
)} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{item.stageName}</p>
|
||||
{item.enteredAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(item.enteredAt).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={stateVariants[item.state] ?? 'secondary'} className="text-xs">
|
||||
{stateLabels[item.state] ?? item.state}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{currentStage && (
|
||||
<Link
|
||||
href={`/applicant/pipeline/${currentStage.stageId}/documents` as Route}
|
||||
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-blue-50 p-2 dark:bg-blue-950/40">
|
||||
<Upload className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">Upload Documents</p>
|
||||
<p className="text-xs text-muted-foreground">Submit required files</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={"/applicant/team" as Route}
|
||||
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-teal-50 p-2 dark:bg-teal-950/40">
|
||||
<Users className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">View Team</p>
|
||||
<p className="text-xs text-muted-foreground">Team members</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href={"/applicant/mentor" as Route}
|
||||
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-amber-500/30 hover:bg-amber-50/50 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-amber-50 p-2 dark:bg-amber-950/40">
|
||||
<MessageSquare className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">Contact Mentor</p>
|
||||
<p className="text-xs text-muted-foreground">Send a message</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,424 +1,424 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Crown,
|
||||
Mail,
|
||||
Trash2,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
|
||||
const inviteSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
role: z.enum(['MEMBER', 'ADVISOR']),
|
||||
title: z.string().optional(),
|
||||
})
|
||||
|
||||
type InviteFormData = z.infer<typeof inviteSchema>
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
LEAD: 'Team Lead',
|
||||
MEMBER: 'Team Member',
|
||||
ADVISOR: 'Advisor',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
ACTIVE: { label: 'Active', icon: CheckCircle },
|
||||
INVITED: { label: 'Pending', icon: Clock },
|
||||
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
|
||||
}
|
||||
|
||||
export default function ApplicantTeamPage() {
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
const [isInviteOpen, setIsInviteOpen] = useState(false)
|
||||
|
||||
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
|
||||
undefined,
|
||||
{ enabled: isAuthenticated }
|
||||
)
|
||||
|
||||
const projectId = dashboardData?.project?.id
|
||||
|
||||
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
|
||||
{ projectId: projectId! },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.requiresAccountSetup) {
|
||||
toast.success('Invitation email sent to team member')
|
||||
} else {
|
||||
toast.success('Team member added and notified by email')
|
||||
}
|
||||
setIsInviteOpen(false)
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const removeMutation = trpc.applicant.removeTeamMember.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Team member removed')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const form = useForm<InviteFormData>({
|
||||
resolver: zodResolver(inviteSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'MEMBER',
|
||||
title: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onInvite = async (data: InviteFormData) => {
|
||||
if (!projectId) return
|
||||
await inviteMutation.mutateAsync({
|
||||
projectId,
|
||||
...data,
|
||||
})
|
||||
form.reset()
|
||||
}
|
||||
|
||||
const isLoading = dashLoading || teamLoading
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Team</h1>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
Submit a project first to manage your team.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is team lead
|
||||
const currentUserMember = teamData?.teamMembers.find(
|
||||
(tm) => tm.userId === session?.user?.id
|
||||
)
|
||||
const isTeamLead =
|
||||
currentUserMember?.role === 'LEAD' ||
|
||||
teamData?.submittedBy?.id === session?.user?.id
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<Users className="h-6 w-6" />
|
||||
Team Members
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your project team
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isTeamLead && (
|
||||
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite Member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite Team Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send an invitation to join your project team. They will receive an email
|
||||
with instructions to create their account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Jane Doe"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="jane@example.com"
|
||||
{...form.register('email')}
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select
|
||||
value={form.watch('role')}
|
||||
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MEMBER">Team Member</SelectItem>
|
||||
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title (optional)</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="CTO, Designer..."
|
||||
{...form.register('title')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsInviteOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={inviteMutation.isPending}>
|
||||
{inviteMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Send Invitation
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Members List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
|
||||
<CardDescription>
|
||||
Everyone on this list can view and collaborate on this project.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{teamData?.teamMembers.map((member) => {
|
||||
const StatusIcon = statusLabels[member.user.status]?.icon || AlertCircle
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
{member.role === 'LEAD' ? (
|
||||
<Crown className="h-5 w-5 text-yellow-500" />
|
||||
) : (
|
||||
<span className="text-sm font-medium">
|
||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{member.user.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{roleLabels[member.role] || member.role}
|
||||
</Badge>
|
||||
{member.title && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({member.title})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
{member.user.email}
|
||||
<StatusIcon className="h-3 w-3 ml-2" />
|
||||
<span className="text-xs">
|
||||
{statusLabels[member.user.status]?.label || member.user.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove {member.user.name} from the team?
|
||||
They will no longer have access to this project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => removeMutation.mutate({ projectId, userId: member.userId })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{(!teamData?.teamMembers || teamData.teamMembers.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">No team members yet.</p>
|
||||
{isTeamLead && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setIsInviteOpen(true)}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite Your First Team Member
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Documents - visible via applicant documents page */}
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">About Team Access</p>
|
||||
<p className="mt-1">
|
||||
All team members can view project details and status updates.
|
||||
Only the team lead can invite or remove team members.
|
||||
Invited members will receive an email to set up their account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Crown,
|
||||
Mail,
|
||||
Trash2,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
|
||||
const inviteSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
role: z.enum(['MEMBER', 'ADVISOR']),
|
||||
title: z.string().optional(),
|
||||
})
|
||||
|
||||
type InviteFormData = z.infer<typeof inviteSchema>
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
LEAD: 'Team Lead',
|
||||
MEMBER: 'Team Member',
|
||||
ADVISOR: 'Advisor',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
ACTIVE: { label: 'Active', icon: CheckCircle },
|
||||
INVITED: { label: 'Pending', icon: Clock },
|
||||
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
|
||||
}
|
||||
|
||||
export default function ApplicantTeamPage() {
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
const [isInviteOpen, setIsInviteOpen] = useState(false)
|
||||
|
||||
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
|
||||
undefined,
|
||||
{ enabled: isAuthenticated }
|
||||
)
|
||||
|
||||
const projectId = dashboardData?.project?.id
|
||||
|
||||
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
|
||||
{ projectId: projectId! },
|
||||
{ enabled: !!projectId }
|
||||
)
|
||||
|
||||
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.requiresAccountSetup) {
|
||||
toast.success('Invitation email sent to team member')
|
||||
} else {
|
||||
toast.success('Team member added and notified by email')
|
||||
}
|
||||
setIsInviteOpen(false)
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const removeMutation = trpc.applicant.removeTeamMember.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Team member removed')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const form = useForm<InviteFormData>({
|
||||
resolver: zodResolver(inviteSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'MEMBER',
|
||||
title: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onInvite = async (data: InviteFormData) => {
|
||||
if (!projectId) return
|
||||
await inviteMutation.mutateAsync({
|
||||
projectId,
|
||||
...data,
|
||||
})
|
||||
form.reset()
|
||||
}
|
||||
|
||||
const isLoading = dashLoading || teamLoading
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Team</h1>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
Submit a project first to manage your team.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is team lead
|
||||
const currentUserMember = teamData?.teamMembers.find(
|
||||
(tm) => tm.userId === session?.user?.id
|
||||
)
|
||||
const isTeamLead =
|
||||
currentUserMember?.role === 'LEAD' ||
|
||||
teamData?.submittedBy?.id === session?.user?.id
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<Users className="h-6 w-6" />
|
||||
Team Members
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your project team
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isTeamLead && (
|
||||
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite Member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite Team Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send an invitation to join your project team. They will receive an email
|
||||
with instructions to create their account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Jane Doe"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="jane@example.com"
|
||||
{...form.register('email')}
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select
|
||||
value={form.watch('role')}
|
||||
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MEMBER">Team Member</SelectItem>
|
||||
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title (optional)</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="CTO, Designer..."
|
||||
{...form.register('title')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsInviteOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={inviteMutation.isPending}>
|
||||
{inviteMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Send Invitation
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Members List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
|
||||
<CardDescription>
|
||||
Everyone on this list can view and collaborate on this project.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{teamData?.teamMembers.map((member) => {
|
||||
const StatusIcon = statusLabels[member.user.status]?.icon || AlertCircle
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
{member.role === 'LEAD' ? (
|
||||
<Crown className="h-5 w-5 text-yellow-500" />
|
||||
) : (
|
||||
<span className="text-sm font-medium">
|
||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{member.user.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{roleLabels[member.role] || member.role}
|
||||
</Badge>
|
||||
{member.title && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({member.title})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
{member.user.email}
|
||||
<StatusIcon className="h-3 w-3 ml-2" />
|
||||
<span className="text-xs">
|
||||
{statusLabels[member.user.status]?.label || member.user.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove {member.user.name} from the team?
|
||||
They will no longer have access to this project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => removeMutation.mutate({ projectId, userId: member.userId })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{(!teamData?.teamMembers || teamData.teamMembers.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">No team members yet.</p>
|
||||
{isTeamLead && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setIsInviteOpen(true)}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite Your First Team Member
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Documents - visible via applicant documents page */}
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">About Team Access</p>
|
||||
<p className="mt-1">
|
||||
All team members can view project details and status updates.
|
||||
Only the team lead can invite or remove team members.
|
||||
Invited members will receive an email to set up their account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { ApplicantNav } from '@/components/layouts/applicant-nav'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ApplicantLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
if (session.user.role !== 'APPLICANT') {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<ApplicantNav
|
||||
user={{
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
}}
|
||||
/>
|
||||
<main className="container-app py-6 lg:py-8">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { redirect } from 'next/navigation'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { ApplicantNav } from '@/components/layouts/applicant-nav'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ApplicantLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
if (session.user.role !== 'APPLICANT') {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<ApplicantNav
|
||||
user={{
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
}}
|
||||
/>
|
||||
<main className="container-app py-6 lg:py-8">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,251 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
|
||||
|
||||
function AcceptInviteContent() {
|
||||
const [state, setState] = useState<InviteState>('loading')
|
||||
const [errorType, setErrorType] = useState<string | null>(null)
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const token = searchParams.get('token') || ''
|
||||
|
||||
const { data, isLoading, error } = trpc.user.validateInviteToken.useQuery(
|
||||
{ token },
|
||||
{ enabled: !!token, retry: false }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setState('error')
|
||||
setErrorType('MISSING_TOKEN')
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
setState('loading')
|
||||
return
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setState('error')
|
||||
setErrorType('NETWORK_ERROR')
|
||||
return
|
||||
}
|
||||
|
||||
if (data) {
|
||||
if (data.valid) {
|
||||
setState('valid')
|
||||
} else {
|
||||
setState('error')
|
||||
setErrorType(data.error || 'UNKNOWN')
|
||||
}
|
||||
}
|
||||
}, [token, data, isLoading, error])
|
||||
|
||||
const handleAccept = async () => {
|
||||
setState('accepting')
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
inviteToken: token,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setState('error')
|
||||
setErrorType('AUTH_FAILED')
|
||||
} else if (result?.ok) {
|
||||
// Redirect to set-password (middleware will enforce this since mustSetPassword=true)
|
||||
window.location.href = '/set-password'
|
||||
}
|
||||
} catch {
|
||||
setState('error')
|
||||
setErrorType('AUTH_FAILED')
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleLabel = (role: string): string => {
|
||||
switch (role) {
|
||||
case 'JURY_MEMBER': return 'Jury Member'
|
||||
case 'PROGRAM_ADMIN': return 'Program Admin'
|
||||
case 'MENTOR': return 'Mentor'
|
||||
case 'OBSERVER': return 'Observer'
|
||||
case 'APPLICANT': return 'Applicant'
|
||||
default: return role
|
||||
}
|
||||
}
|
||||
|
||||
const getErrorContent = () => {
|
||||
switch (errorType) {
|
||||
case 'MISSING_TOKEN':
|
||||
return {
|
||||
icon: <XCircle className="h-6 w-6 text-red-600" />,
|
||||
title: 'Invalid Link',
|
||||
description: 'This invitation link is incomplete. Please check your email for the correct link.',
|
||||
}
|
||||
case 'INVALID_TOKEN':
|
||||
return {
|
||||
icon: <XCircle className="h-6 w-6 text-red-600" />,
|
||||
title: 'Invalid Invitation',
|
||||
description: 'This invitation link is not valid. It may have already been used or the link is incorrect.',
|
||||
}
|
||||
case 'EXPIRED_TOKEN':
|
||||
return {
|
||||
icon: <Clock className="h-6 w-6 text-amber-600" />,
|
||||
title: 'Invitation Expired',
|
||||
description: 'This invitation has expired. Please contact your administrator to receive a new invitation.',
|
||||
}
|
||||
case 'ALREADY_ACCEPTED':
|
||||
return {
|
||||
icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />,
|
||||
title: 'Already Accepted',
|
||||
description: 'This invitation has already been accepted. You can sign in with your credentials.',
|
||||
}
|
||||
case 'AUTH_FAILED':
|
||||
return {
|
||||
icon: <AlertCircle className="h-6 w-6 text-red-600" />,
|
||||
title: 'Something Went Wrong',
|
||||
description: 'We couldn\'t complete your account setup. The invitation may have expired. Please try again or contact your administrator.',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
icon: <AlertCircle className="h-6 w-6 text-red-600" />,
|
||||
title: 'Something Went Wrong',
|
||||
description: 'An unexpected error occurred. Please try again or contact your administrator.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">Verifying your invitation...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (state === 'error') {
|
||||
const errorContent = getErrorContent()
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
|
||||
{errorContent.icon}
|
||||
</div>
|
||||
<CardTitle className="text-xl">{errorContent.title}</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
{errorContent.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push('/login')}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Valid invitation - show welcome
|
||||
const user = data?.user
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">
|
||||
{user?.name ? `Welcome, ${user.name}!` : 'Welcome!'}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
You've been invited to join the Monaco Ocean Protection Challenge platform
|
||||
{user?.role ? ` as ${/^[aeiou]/i.test(getRoleLabel(user.role)) ? 'an' : 'a'} ${getRoleLabel(user.role)}.` : '.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{user?.email && (
|
||||
<div className="rounded-md bg-muted/50 p-3 text-center">
|
||||
<p className="text-sm text-muted-foreground">Signing in as</p>
|
||||
<p className="font-medium">{user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleAccept}
|
||||
disabled={state === 'accepting'}
|
||||
>
|
||||
{state === 'accepting' ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Setting up your account...
|
||||
</>
|
||||
) : (
|
||||
'Get Started'
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
You'll be asked to set a password after accepting.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading fallback for Suspense
|
||||
function LoadingCard() {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">Loading...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Export with Suspense boundary for useSearchParams
|
||||
export default function AcceptInvitePage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingCard />}>
|
||||
<AcceptInviteContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
|
||||
|
||||
function AcceptInviteContent() {
|
||||
const [state, setState] = useState<InviteState>('loading')
|
||||
const [errorType, setErrorType] = useState<string | null>(null)
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const token = searchParams.get('token') || ''
|
||||
|
||||
const { data, isLoading, error } = trpc.user.validateInviteToken.useQuery(
|
||||
{ token },
|
||||
{ enabled: !!token, retry: false }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setState('error')
|
||||
setErrorType('MISSING_TOKEN')
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
setState('loading')
|
||||
return
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setState('error')
|
||||
setErrorType('NETWORK_ERROR')
|
||||
return
|
||||
}
|
||||
|
||||
if (data) {
|
||||
if (data.valid) {
|
||||
setState('valid')
|
||||
} else {
|
||||
setState('error')
|
||||
setErrorType(data.error || 'UNKNOWN')
|
||||
}
|
||||
}
|
||||
}, [token, data, isLoading, error])
|
||||
|
||||
const handleAccept = async () => {
|
||||
setState('accepting')
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
inviteToken: token,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setState('error')
|
||||
setErrorType('AUTH_FAILED')
|
||||
} else if (result?.ok) {
|
||||
// Redirect to set-password (middleware will enforce this since mustSetPassword=true)
|
||||
window.location.href = '/set-password'
|
||||
}
|
||||
} catch {
|
||||
setState('error')
|
||||
setErrorType('AUTH_FAILED')
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleLabel = (role: string): string => {
|
||||
switch (role) {
|
||||
case 'JURY_MEMBER': return 'Jury Member'
|
||||
case 'PROGRAM_ADMIN': return 'Program Admin'
|
||||
case 'MENTOR': return 'Mentor'
|
||||
case 'OBSERVER': return 'Observer'
|
||||
case 'APPLICANT': return 'Applicant'
|
||||
default: return role
|
||||
}
|
||||
}
|
||||
|
||||
const getErrorContent = () => {
|
||||
switch (errorType) {
|
||||
case 'MISSING_TOKEN':
|
||||
return {
|
||||
icon: <XCircle className="h-6 w-6 text-red-600" />,
|
||||
title: 'Invalid Link',
|
||||
description: 'This invitation link is incomplete. Please check your email for the correct link.',
|
||||
}
|
||||
case 'INVALID_TOKEN':
|
||||
return {
|
||||
icon: <XCircle className="h-6 w-6 text-red-600" />,
|
||||
title: 'Invalid Invitation',
|
||||
description: 'This invitation link is not valid. It may have already been used or the link is incorrect.',
|
||||
}
|
||||
case 'EXPIRED_TOKEN':
|
||||
return {
|
||||
icon: <Clock className="h-6 w-6 text-amber-600" />,
|
||||
title: 'Invitation Expired',
|
||||
description: 'This invitation has expired. Please contact your administrator to receive a new invitation.',
|
||||
}
|
||||
case 'ALREADY_ACCEPTED':
|
||||
return {
|
||||
icon: <CheckCircle2 className="h-6 w-6 text-blue-600" />,
|
||||
title: 'Already Accepted',
|
||||
description: 'This invitation has already been accepted. You can sign in with your credentials.',
|
||||
}
|
||||
case 'AUTH_FAILED':
|
||||
return {
|
||||
icon: <AlertCircle className="h-6 w-6 text-red-600" />,
|
||||
title: 'Something Went Wrong',
|
||||
description: 'We couldn\'t complete your account setup. The invitation may have expired. Please try again or contact your administrator.',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
icon: <AlertCircle className="h-6 w-6 text-red-600" />,
|
||||
title: 'Something Went Wrong',
|
||||
description: 'An unexpected error occurred. Please try again or contact your administrator.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">Verifying your invitation...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (state === 'error') {
|
||||
const errorContent = getErrorContent()
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
|
||||
{errorContent.icon}
|
||||
</div>
|
||||
<CardTitle className="text-xl">{errorContent.title}</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
{errorContent.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push('/login')}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Valid invitation - show welcome
|
||||
const user = data?.user
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">
|
||||
{user?.name ? `Welcome, ${user.name}!` : 'Welcome!'}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
You've been invited to join the Monaco Ocean Protection Challenge platform
|
||||
{user?.role ? ` as ${/^[aeiou]/i.test(getRoleLabel(user.role)) ? 'an' : 'a'} ${getRoleLabel(user.role)}.` : '.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{user?.email && (
|
||||
<div className="rounded-md bg-muted/50 p-3 text-center">
|
||||
<p className="text-sm text-muted-foreground">Signing in as</p>
|
||||
<p className="font-medium">{user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleAccept}
|
||||
disabled={state === 'accepting'}
|
||||
>
|
||||
{state === 'accepting' ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Setting up your account...
|
||||
</>
|
||||
) : (
|
||||
'Get Started'
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
You'll be asked to set a password after accepting.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading fallback for Suspense
|
||||
function LoadingCard() {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">Loading...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Export with Suspense boundary for useSearchParams
|
||||
export default function AcceptInvitePage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingCard />}>
|
||||
<AcceptInviteContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
Configuration: 'There is a problem with the server configuration.',
|
||||
AccessDenied: 'You do not have access to this resource.',
|
||||
Verification: 'The verification link has expired or already been used.',
|
||||
Default: 'An error occurred during authentication.',
|
||||
}
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const error = searchParams.get('error') || 'Default'
|
||||
const message = errorMessages[error] || errorMessages.Default
|
||||
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4">
|
||||
<Logo variant="small" />
|
||||
</div>
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Authentication Error</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">{message}</p>
|
||||
<div className="flex gap-3 justify-center border-t pt-4">
|
||||
<Button asChild>
|
||||
<Link href="/login">Return to Login</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/">Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
Configuration: 'There is a problem with the server configuration.',
|
||||
AccessDenied: 'You do not have access to this resource.',
|
||||
Verification: 'The verification link has expired or already been used.',
|
||||
Default: 'An error occurred during authentication.',
|
||||
}
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const error = searchParams.get('error') || 'Default'
|
||||
const message = errorMessages[error] || errorMessages.Default
|
||||
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4">
|
||||
<Logo variant="small" />
|
||||
</div>
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Authentication Error</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">{message}</p>
|
||||
<div className="flex gap-3 justify-center border-t pt-4">
|
||||
<Button asChild>
|
||||
<Link href="/login">Return to Login</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/">Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,310 +1,310 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
type LoginMode = 'password' | 'magic-link'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [mode, setMode] = useState<LoginMode>('password')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSent, setIsSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const callbackUrl = searchParams.get('callbackUrl') || '/'
|
||||
const errorParam = searchParams.get('error')
|
||||
|
||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
callbackUrl,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setError('Invalid email or password. Please try again.')
|
||||
} else if (result?.ok) {
|
||||
// Use window.location for external redirects or callback URLs
|
||||
window.location.href = callbackUrl
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes('429')) {
|
||||
setError('Too many attempts. Please wait a few minutes before trying again.')
|
||||
} else {
|
||||
setError('An unexpected error occurred. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMagicLink = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get CSRF token first
|
||||
const csrfRes = await fetch('/api/auth/csrf')
|
||||
const { csrfToken } = await csrfRes.json()
|
||||
|
||||
// POST directly to the signin endpoint
|
||||
const res = await fetch('/api/auth/signin/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
csrfToken,
|
||||
email,
|
||||
callbackUrl,
|
||||
}),
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
// 302 redirect means success
|
||||
if (res.type === 'opaqueredirect' || res.status === 302 || res.ok) {
|
||||
setIsSent(true)
|
||||
} else {
|
||||
setError('Failed to send magic link. Please try again.')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes('429')) {
|
||||
setError('Too many attempts. Please wait a few minutes before trying again.')
|
||||
} else {
|
||||
setError('An unexpected error occurred. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Success state after sending magic link
|
||||
if (isSent) {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-50 animate-in zoom-in-50 duration-300">
|
||||
<Mail className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Check your email</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
We've sent a magic link to <strong>{email}</strong>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg border bg-muted/50 p-4 text-sm text-muted-foreground space-y-2">
|
||||
<p>Click the link in the email to sign in. The link will expire in 15 minutes.</p>
|
||||
<p>If you don't see it, check your spam folder.</p>
|
||||
</div>
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setIsSent(false)
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
Send to a different email
|
||||
</Button>
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Having trouble?{' '}
|
||||
<a href="mailto:support@monaco-opc.com" className="text-primary hover:underline">
|
||||
Contact support
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Welcome back</CardTitle>
|
||||
<CardDescription>
|
||||
{mode === 'password'
|
||||
? 'Sign in with your email and password'
|
||||
: 'Sign in with a magic link'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mode === 'password' ? (
|
||||
// Password login form
|
||||
<form onSubmit={handlePasswordLogin} className="space-y-4">
|
||||
{(error || errorParam) && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
{error ||
|
||||
(errorParam === 'Verification'
|
||||
? 'The magic link has expired or is invalid.'
|
||||
: errorParam === 'CredentialsSignin'
|
||||
? 'Invalid email or password.'
|
||||
: 'An error occurred during sign in.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => {
|
||||
setMode('magic-link')
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Sign in
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
// Magic link form
|
||||
<form onSubmit={handleMagicLink} className="space-y-4">
|
||||
{(error || errorParam) && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
{error ||
|
||||
(errorParam === 'Verification'
|
||||
? 'The magic link has expired or is invalid.'
|
||||
: 'An error occurred during sign in.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email-magic">Email address</Label>
|
||||
<Input
|
||||
id="email-magic"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Send magic link
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
We'll send you a secure link to sign in or reset your
|
||||
password.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Toggle between modes */}
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => {
|
||||
setMode(mode === 'password' ? 'magic-link' : 'password')
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
{mode === 'password' ? (
|
||||
<>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Use magic link instead
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="h-4 w-4" />
|
||||
Sign in with password
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
type LoginMode = 'password' | 'magic-link'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [mode, setMode] = useState<LoginMode>('password')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSent, setIsSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const callbackUrl = searchParams.get('callbackUrl') || '/'
|
||||
const errorParam = searchParams.get('error')
|
||||
|
||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
callbackUrl,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setError('Invalid email or password. Please try again.')
|
||||
} else if (result?.ok) {
|
||||
// Use window.location for external redirects or callback URLs
|
||||
window.location.href = callbackUrl
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes('429')) {
|
||||
setError('Too many attempts. Please wait a few minutes before trying again.')
|
||||
} else {
|
||||
setError('An unexpected error occurred. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMagicLink = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get CSRF token first
|
||||
const csrfRes = await fetch('/api/auth/csrf')
|
||||
const { csrfToken } = await csrfRes.json()
|
||||
|
||||
// POST directly to the signin endpoint
|
||||
const res = await fetch('/api/auth/signin/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
csrfToken,
|
||||
email,
|
||||
callbackUrl,
|
||||
}),
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
// 302 redirect means success
|
||||
if (res.type === 'opaqueredirect' || res.status === 302 || res.ok) {
|
||||
setIsSent(true)
|
||||
} else {
|
||||
setError('Failed to send magic link. Please try again.')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes('429')) {
|
||||
setError('Too many attempts. Please wait a few minutes before trying again.')
|
||||
} else {
|
||||
setError('An unexpected error occurred. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Success state after sending magic link
|
||||
if (isSent) {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-50 animate-in zoom-in-50 duration-300">
|
||||
<Mail className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Check your email</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
We've sent a magic link to <strong>{email}</strong>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg border bg-muted/50 p-4 text-sm text-muted-foreground space-y-2">
|
||||
<p>Click the link in the email to sign in. The link will expire in 15 minutes.</p>
|
||||
<p>If you don't see it, check your spam folder.</p>
|
||||
</div>
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setIsSent(false)
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
Send to a different email
|
||||
</Button>
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Having trouble?{' '}
|
||||
<a href="mailto:support@monaco-opc.com" className="text-primary hover:underline">
|
||||
Contact support
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Welcome back</CardTitle>
|
||||
<CardDescription>
|
||||
{mode === 'password'
|
||||
? 'Sign in with your email and password'
|
||||
: 'Sign in with a magic link'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mode === 'password' ? (
|
||||
// Password login form
|
||||
<form onSubmit={handlePasswordLogin} className="space-y-4">
|
||||
{(error || errorParam) && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
{error ||
|
||||
(errorParam === 'Verification'
|
||||
? 'The magic link has expired or is invalid.'
|
||||
: errorParam === 'CredentialsSignin'
|
||||
? 'Invalid email or password.'
|
||||
: 'An error occurred during sign in.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => {
|
||||
setMode('magic-link')
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Sign in
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
// Magic link form
|
||||
<form onSubmit={handleMagicLink} className="space-y-4">
|
||||
{(error || errorParam) && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
{error ||
|
||||
(errorParam === 'Verification'
|
||||
? 'The magic link has expired or is invalid.'
|
||||
: 'An error occurred during sign in.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email-magic">Email address</Label>
|
||||
<Input
|
||||
id="email-magic"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Send magic link
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
We'll send you a secure link to sign in or reset your
|
||||
password.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Toggle between modes */}
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => {
|
||||
setMode(mode === 'password' ? 'magic-link' : 'password')
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
{mode === 'password' ? (
|
||||
<>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Use magic link instead
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="h-4 w-4" />
|
||||
Sign in with password
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,308 +1,308 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
export default function SetPasswordPage() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
|
||||
const router = useRouter()
|
||||
const { data: session, update: updateSession } = useSession()
|
||||
|
||||
const setPasswordMutation = trpc.user.setPassword.useMutation({
|
||||
onSuccess: async () => {
|
||||
setIsSuccess(true)
|
||||
// Update the session to reflect the password has been set
|
||||
await updateSession()
|
||||
// Redirect after a short delay
|
||||
setTimeout(() => {
|
||||
if (session?.user?.role === 'JURY_MEMBER') {
|
||||
router.push('/jury')
|
||||
} else if (session?.user?.role === 'SUPER_ADMIN' || session?.user?.role === 'PROGRAM_ADMIN') {
|
||||
router.push('/admin')
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}, 2000)
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message || 'Failed to set password. Please try again.')
|
||||
setIsLoading(false)
|
||||
},
|
||||
})
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (session === null) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [session, router])
|
||||
|
||||
// Password validation
|
||||
const validatePassword = (pwd: string) => {
|
||||
const errors: string[] = []
|
||||
if (pwd.length < 8) errors.push('At least 8 characters')
|
||||
if (!/[A-Z]/.test(pwd)) errors.push('One uppercase letter')
|
||||
if (!/[a-z]/.test(pwd)) errors.push('One lowercase letter')
|
||||
if (!/[0-9]/.test(pwd)) errors.push('One number')
|
||||
return errors
|
||||
}
|
||||
|
||||
const passwordErrors = validatePassword(password)
|
||||
const isPasswordValid = passwordErrors.length === 0
|
||||
const doPasswordsMatch = password === confirmPassword && password.length > 0
|
||||
|
||||
// Password strength
|
||||
const getPasswordStrength = (pwd: string): { score: number; label: string; color: string } => {
|
||||
let score = 0
|
||||
if (pwd.length >= 8) score++
|
||||
if (pwd.length >= 12) score++
|
||||
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++
|
||||
if (/[0-9]/.test(pwd)) score++
|
||||
if (/[^a-zA-Z0-9]/.test(pwd)) score++
|
||||
|
||||
const normalizedScore = Math.min(4, score)
|
||||
const labels = ['Very Weak', 'Weak', 'Fair', 'Strong', 'Very Strong']
|
||||
const colors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-green-600']
|
||||
|
||||
return {
|
||||
score: normalizedScore,
|
||||
label: labels[normalizedScore],
|
||||
color: colors[normalizedScore],
|
||||
}
|
||||
}
|
||||
|
||||
const strength = getPasswordStrength(password)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!isPasswordValid) {
|
||||
setError('Password does not meet requirements.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!doPasswordsMatch) {
|
||||
setError('Passwords do not match.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setPasswordMutation.mutate({ password, confirmPassword })
|
||||
}
|
||||
|
||||
// Loading state while checking session
|
||||
if (session === undefined) {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Password Set Successfully</CardTitle>
|
||||
<CardDescription>
|
||||
Your password has been set. You can now sign in with your email and
|
||||
password.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Redirecting you to the dashboard...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-white shadow-sm border">
|
||||
<Image src="/images/MOPC-blue-small.png" alt="MOPC" width={32} height={32} className="object-contain" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Set Your Password</CardTitle>
|
||||
<CardDescription>
|
||||
Create a secure password to sign in to your account in the future.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter a secure password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password strength indicator */}
|
||||
{password.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={(strength.score / 4) * 100}
|
||||
className={`h-2 ${strength.color}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{strength.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Requirements checklist */}
|
||||
<div className="grid grid-cols-2 gap-1 text-xs">
|
||||
{[
|
||||
{ label: '8+ characters', met: password.length >= 8 },
|
||||
{ label: 'Uppercase', met: /[A-Z]/.test(password) },
|
||||
{ label: 'Lowercase', met: /[a-z]/.test(password) },
|
||||
{ label: 'Number', met: /[0-9]/.test(password) },
|
||||
].map((req) => (
|
||||
<div
|
||||
key={req.label}
|
||||
className={`flex items-center gap-1 ${
|
||||
req.met ? 'text-green-600' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{req.met ? (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
) : (
|
||||
<div className="h-3 w-3 rounded-full border border-current" />
|
||||
)}
|
||||
{req.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
placeholder="Confirm your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="new-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{confirmPassword.length > 0 && (
|
||||
<p
|
||||
className={`text-xs ${
|
||||
doPasswordsMatch ? 'text-green-600' : 'text-destructive'
|
||||
}`}
|
||||
>
|
||||
{doPasswordsMatch
|
||||
? 'Passwords match'
|
||||
: 'Passwords do not match'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !isPasswordValid || !doPasswordsMatch}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Setting password...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Set Password
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
export default function SetPasswordPage() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
|
||||
const router = useRouter()
|
||||
const { data: session, update: updateSession } = useSession()
|
||||
|
||||
const setPasswordMutation = trpc.user.setPassword.useMutation({
|
||||
onSuccess: async () => {
|
||||
setIsSuccess(true)
|
||||
// Update the session to reflect the password has been set
|
||||
await updateSession()
|
||||
// Redirect after a short delay
|
||||
setTimeout(() => {
|
||||
if (session?.user?.role === 'JURY_MEMBER') {
|
||||
router.push('/jury')
|
||||
} else if (session?.user?.role === 'SUPER_ADMIN' || session?.user?.role === 'PROGRAM_ADMIN') {
|
||||
router.push('/admin')
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}, 2000)
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message || 'Failed to set password. Please try again.')
|
||||
setIsLoading(false)
|
||||
},
|
||||
})
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (session === null) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [session, router])
|
||||
|
||||
// Password validation
|
||||
const validatePassword = (pwd: string) => {
|
||||
const errors: string[] = []
|
||||
if (pwd.length < 8) errors.push('At least 8 characters')
|
||||
if (!/[A-Z]/.test(pwd)) errors.push('One uppercase letter')
|
||||
if (!/[a-z]/.test(pwd)) errors.push('One lowercase letter')
|
||||
if (!/[0-9]/.test(pwd)) errors.push('One number')
|
||||
return errors
|
||||
}
|
||||
|
||||
const passwordErrors = validatePassword(password)
|
||||
const isPasswordValid = passwordErrors.length === 0
|
||||
const doPasswordsMatch = password === confirmPassword && password.length > 0
|
||||
|
||||
// Password strength
|
||||
const getPasswordStrength = (pwd: string): { score: number; label: string; color: string } => {
|
||||
let score = 0
|
||||
if (pwd.length >= 8) score++
|
||||
if (pwd.length >= 12) score++
|
||||
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++
|
||||
if (/[0-9]/.test(pwd)) score++
|
||||
if (/[^a-zA-Z0-9]/.test(pwd)) score++
|
||||
|
||||
const normalizedScore = Math.min(4, score)
|
||||
const labels = ['Very Weak', 'Weak', 'Fair', 'Strong', 'Very Strong']
|
||||
const colors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-green-600']
|
||||
|
||||
return {
|
||||
score: normalizedScore,
|
||||
label: labels[normalizedScore],
|
||||
color: colors[normalizedScore],
|
||||
}
|
||||
}
|
||||
|
||||
const strength = getPasswordStrength(password)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!isPasswordValid) {
|
||||
setError('Password does not meet requirements.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!doPasswordsMatch) {
|
||||
setError('Passwords do not match.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setPasswordMutation.mutate({ password, confirmPassword })
|
||||
}
|
||||
|
||||
// Loading state while checking session
|
||||
if (session === undefined) {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Password Set Successfully</CardTitle>
|
||||
<CardDescription>
|
||||
Your password has been set. You can now sign in with your email and
|
||||
password.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Redirecting you to the dashboard...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-white shadow-sm border">
|
||||
<Image src="/images/MOPC-blue-small.png" alt="MOPC" width={32} height={32} className="object-contain" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Set Your Password</CardTitle>
|
||||
<CardDescription>
|
||||
Create a secure password to sign in to your account in the future.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter a secure password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password strength indicator */}
|
||||
{password.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={(strength.score / 4) * 100}
|
||||
className={`h-2 ${strength.color}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{strength.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Requirements checklist */}
|
||||
<div className="grid grid-cols-2 gap-1 text-xs">
|
||||
{[
|
||||
{ label: '8+ characters', met: password.length >= 8 },
|
||||
{ label: 'Uppercase', met: /[A-Z]/.test(password) },
|
||||
{ label: 'Lowercase', met: /[a-z]/.test(password) },
|
||||
{ label: 'Number', met: /[0-9]/.test(password) },
|
||||
].map((req) => (
|
||||
<div
|
||||
key={req.label}
|
||||
className={`flex items-center gap-1 ${
|
||||
req.met ? 'text-green-600' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{req.met ? (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
) : (
|
||||
<div className="h-3 w-3 rounded-full border border-current" />
|
||||
)}
|
||||
{req.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
placeholder="Confirm your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="new-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{confirmPassword.length > 0 && (
|
||||
<p
|
||||
className={`text-xs ${
|
||||
doPasswordsMatch ? 'text-green-600' : 'text-destructive'
|
||||
}`}
|
||||
>
|
||||
{doPasswordsMatch
|
||||
? 'Passwords match'
|
||||
: 'Passwords do not match'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !isPasswordValid || !doPasswordsMatch}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Setting password...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Set Password
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Mail } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-teal/10">
|
||||
<Mail className="h-6 w-6 text-brand-teal" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Check your email</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
We've sent you a magic link to sign in
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click the link in your email to complete the sign-in process.
|
||||
The link will expire in 15 minutes.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Didn't receive an email? Check your spam folder or try again.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Mail } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-teal/10">
|
||||
<Mail className="h-6 w-6 text-brand-teal" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Check your email</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
We've sent you a magic link to sign in
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click the link in your email to complete the sign-in process.
|
||||
The link will expire in 15 minutes.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Didn't receive an email? Check your spam folder or try again.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Check your email</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
A sign-in link has been sent to your email address. Click the link to
|
||||
complete your sign in.
|
||||
</p>
|
||||
<div className="border-t pt-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/login">Back to login</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Check your email</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
A sign-in link has been sent to your email address. Click the link to
|
||||
complete your sign in.
|
||||
</p>
|
||||
<div className="border-t pt-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/login">Back to login</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, ClipboardList } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function JuryError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Jury section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('jury')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to your assignments.'}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/jury">
|
||||
<ClipboardList className="mr-2 h-4 w-4" />
|
||||
My Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, ClipboardList } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function JuryError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Jury section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('jury')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to your assignments.'}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/jury">
|
||||
<ClipboardList className="mr-2 h-4 w-4" />
|
||||
My Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,327 +1,327 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trophy,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
GripVertical,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function JuryAwardVotingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: awardId } = use(params)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data, isLoading, refetch } =
|
||||
trpc.specialAward.getMyAwardDetail.useQuery({ awardId })
|
||||
const submitVote = trpc.specialAward.submitVote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
||||
},
|
||||
})
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const [rankedIds, setRankedIds] = useState<string[]>([])
|
||||
|
||||
// Initialize from existing votes
|
||||
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
||||
if (data.award.scoringMode === 'PICK_WINNER') {
|
||||
setSelectedProjectId(data.myVotes[0]?.projectId || null)
|
||||
} else if (data.award.scoringMode === 'RANKED') {
|
||||
const sorted = [...data.myVotes]
|
||||
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
|
||||
.map((v) => v.projectId)
|
||||
setRankedIds(sorted)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitPickWinner = async () => {
|
||||
if (!selectedProjectId) return
|
||||
try {
|
||||
await submitVote.mutateAsync({
|
||||
awardId,
|
||||
votes: [{ projectId: selectedProjectId }],
|
||||
})
|
||||
toast.success('Vote submitted')
|
||||
refetch()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to submit vote'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitRanked = async () => {
|
||||
if (rankedIds.length === 0) return
|
||||
try {
|
||||
await submitVote.mutateAsync({
|
||||
awardId,
|
||||
votes: rankedIds.map((projectId, index) => ({
|
||||
projectId,
|
||||
rank: index + 1,
|
||||
})),
|
||||
})
|
||||
toast.success('Rankings submitted')
|
||||
refetch()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to submit rankings'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRanked = (projectId: string) => {
|
||||
if (rankedIds.includes(projectId)) {
|
||||
setRankedIds(rankedIds.filter((id) => id !== projectId))
|
||||
} else {
|
||||
const maxPicks = data?.award.maxRankedPicks || 5
|
||||
if (rankedIds.length < maxPicks) {
|
||||
setRankedIds([...rankedIds, projectId])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const { award, projects, myVotes } = data
|
||||
const hasVoted = myVotes.length > 0
|
||||
const isVotingOpen = award.status === 'VOTING_OPEN'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/awards">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<Trophy className="h-6 w-6 text-amber-500" />
|
||||
{award.name}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge
|
||||
variant={isVotingOpen ? 'default' : 'secondary'}
|
||||
>
|
||||
{award.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
{hasVoted && (
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Voted
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{award.criteriaText && (
|
||||
<p className="text-muted-foreground mt-2">{award.criteriaText}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isVotingOpen ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Voting is not open</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check back when voting opens for this award
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : award.scoringMode === 'PICK_WINNER' ? (
|
||||
/* PICK_WINNER Mode */
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select one project as the winner
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{projects.map((project) => (
|
||||
<Card
|
||||
key={project.id}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all',
|
||||
selectedProjectId === project.id
|
||||
? 'ring-2 ring-primary bg-primary/5'
|
||||
: 'hover:bg-muted/50'
|
||||
)}
|
||||
onClick={() => setSelectedProjectId(project.id)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{project.title}</CardTitle>
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.competitionCategory.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.country}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmitPickWinner}
|
||||
disabled={!selectedProjectId || submitVote.isPending}
|
||||
>
|
||||
{submitVote.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : award.scoringMode === 'RANKED' ? (
|
||||
/* RANKED Mode */
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select and rank your top {award.maxRankedPicks || 5} projects. Click
|
||||
to add/remove, drag to reorder.
|
||||
</p>
|
||||
|
||||
{/* Selected rankings */}
|
||||
{rankedIds.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Your Rankings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{rankedIds.map((id, index) => {
|
||||
const project = projects.find((p) => p.id === id)
|
||||
if (!project) return null
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<span className="font-bold text-lg w-8 text-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{project.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.teamName}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleRanked(id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Available projects */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{projects
|
||||
.filter((p) => !rankedIds.includes(p.id))
|
||||
.map((project) => (
|
||||
<Card
|
||||
key={project.id}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => toggleRanked(project.id)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.competitionCategory.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.country}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmitRanked}
|
||||
disabled={rankedIds.length === 0 || submitVote.isPending}
|
||||
>
|
||||
{submitVote.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{hasVoted ? 'Update Rankings' : 'Submit Rankings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* SCORED Mode — redirect to evaluation */
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Scored Award</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This award uses the evaluation system. Check your evaluation
|
||||
assignments.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trophy,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
GripVertical,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function JuryAwardVotingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: awardId } = use(params)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data, isLoading, refetch } =
|
||||
trpc.specialAward.getMyAwardDetail.useQuery({ awardId })
|
||||
const submitVote = trpc.specialAward.submitVote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
||||
},
|
||||
})
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const [rankedIds, setRankedIds] = useState<string[]>([])
|
||||
|
||||
// Initialize from existing votes
|
||||
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
||||
if (data.award.scoringMode === 'PICK_WINNER') {
|
||||
setSelectedProjectId(data.myVotes[0]?.projectId || null)
|
||||
} else if (data.award.scoringMode === 'RANKED') {
|
||||
const sorted = [...data.myVotes]
|
||||
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
|
||||
.map((v) => v.projectId)
|
||||
setRankedIds(sorted)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitPickWinner = async () => {
|
||||
if (!selectedProjectId) return
|
||||
try {
|
||||
await submitVote.mutateAsync({
|
||||
awardId,
|
||||
votes: [{ projectId: selectedProjectId }],
|
||||
})
|
||||
toast.success('Vote submitted')
|
||||
refetch()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to submit vote'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitRanked = async () => {
|
||||
if (rankedIds.length === 0) return
|
||||
try {
|
||||
await submitVote.mutateAsync({
|
||||
awardId,
|
||||
votes: rankedIds.map((projectId, index) => ({
|
||||
projectId,
|
||||
rank: index + 1,
|
||||
})),
|
||||
})
|
||||
toast.success('Rankings submitted')
|
||||
refetch()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to submit rankings'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRanked = (projectId: string) => {
|
||||
if (rankedIds.includes(projectId)) {
|
||||
setRankedIds(rankedIds.filter((id) => id !== projectId))
|
||||
} else {
|
||||
const maxPicks = data?.award.maxRankedPicks || 5
|
||||
if (rankedIds.length < maxPicks) {
|
||||
setRankedIds([...rankedIds, projectId])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const { award, projects, myVotes } = data
|
||||
const hasVoted = myVotes.length > 0
|
||||
const isVotingOpen = award.status === 'VOTING_OPEN'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/awards">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<Trophy className="h-6 w-6 text-amber-500" />
|
||||
{award.name}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge
|
||||
variant={isVotingOpen ? 'default' : 'secondary'}
|
||||
>
|
||||
{award.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
{hasVoted && (
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Voted
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{award.criteriaText && (
|
||||
<p className="text-muted-foreground mt-2">{award.criteriaText}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isVotingOpen ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Voting is not open</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check back when voting opens for this award
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : award.scoringMode === 'PICK_WINNER' ? (
|
||||
/* PICK_WINNER Mode */
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select one project as the winner
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{projects.map((project) => (
|
||||
<Card
|
||||
key={project.id}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all',
|
||||
selectedProjectId === project.id
|
||||
? 'ring-2 ring-primary bg-primary/5'
|
||||
: 'hover:bg-muted/50'
|
||||
)}
|
||||
onClick={() => setSelectedProjectId(project.id)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{project.title}</CardTitle>
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.competitionCategory.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.country}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmitPickWinner}
|
||||
disabled={!selectedProjectId || submitVote.isPending}
|
||||
>
|
||||
{submitVote.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : award.scoringMode === 'RANKED' ? (
|
||||
/* RANKED Mode */
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select and rank your top {award.maxRankedPicks || 5} projects. Click
|
||||
to add/remove, drag to reorder.
|
||||
</p>
|
||||
|
||||
{/* Selected rankings */}
|
||||
{rankedIds.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Your Rankings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{rankedIds.map((id, index) => {
|
||||
const project = projects.find((p) => p.id === id)
|
||||
if (!project) return null
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<span className="font-bold text-lg w-8 text-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{project.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.teamName}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleRanked(id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Available projects */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{projects
|
||||
.filter((p) => !rankedIds.includes(p.id))
|
||||
.map((project) => (
|
||||
<Card
|
||||
key={project.id}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => toggleRanked(project.id)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.competitionCategory.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.country}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmitRanked}
|
||||
disabled={rankedIds.length === 0 || submitVote.isPending}
|
||||
>
|
||||
{submitVote.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{hasVoted ? 'Update Rankings' : 'Submit Rankings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* SCORED Mode — redirect to evaluation */
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Scored Award</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This award uses the evaluation system. Check your evaluation
|
||||
assignments.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,368 +1,368 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowLeft,
|
||||
FileEdit,
|
||||
Eye,
|
||||
ShieldAlert,
|
||||
AlertCircle,
|
||||
ClipboardList,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Type for assignment with included relations from stageAssignment.myAssignments
|
||||
type AssignmentWithRelations = {
|
||||
id: string
|
||||
projectId: string
|
||||
stageId: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
tags: string[]
|
||||
description: string | null
|
||||
}
|
||||
evaluation?: {
|
||||
id: string
|
||||
status: string
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest?: {
|
||||
id: string
|
||||
hasConflict: boolean
|
||||
conflictType: string | null
|
||||
reviewAction: string | null
|
||||
} | null
|
||||
stage?: {
|
||||
id: string
|
||||
name: string
|
||||
track: {
|
||||
name: string
|
||||
pipeline: { id: string; name: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAssignmentStatus(assignment: {
|
||||
evaluation?: { status: string } | null
|
||||
conflictOfInterest?: { id: string } | null
|
||||
}) {
|
||||
if (assignment.conflictOfInterest) return 'COI'
|
||||
if (!assignment.evaluation) return 'NOT_STARTED'
|
||||
return assignment.evaluation.status
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
)
|
||||
case 'DRAFT':
|
||||
return (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
In Progress
|
||||
</Badge>
|
||||
)
|
||||
case 'COI':
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<ShieldAlert className="mr-1 h-3 w-3" />
|
||||
COI Declared
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Not Started
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function StageAssignmentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
|
||||
const { data: stageInfo, isLoading: stageLoading } =
|
||||
trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
const { data: rawAssignments, isLoading: assignmentsLoading } =
|
||||
trpc.stageAssignment.myAssignments.useQuery({ stageId })
|
||||
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
|
||||
|
||||
const { data: windowStatus } =
|
||||
trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
const isLoading = stageLoading || assignmentsLoading
|
||||
|
||||
const totalAssignments = assignments?.length ?? 0
|
||||
const completedCount = assignments?.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
).length ?? 0
|
||||
const coiCount = assignments?.filter((a) => a.conflictOfInterest).length ?? 0
|
||||
const pendingCount = totalAssignments - completedCount - coiCount
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/jury/stages" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{stageInfo?.name ?? 'Stage Assignments'}
|
||||
</h1>
|
||||
{stageInfo && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{stageInfo.track.name} · {stageInfo.track.pipeline.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold">{totalAssignments}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-600">{completedCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Completed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-amber-600">{pendingCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Pending</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-red-600">{coiCount}</p>
|
||||
<p className="text-xs text-muted-foreground">COI Declared</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Assignments table */}
|
||||
{assignments && assignments.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((assignment) => {
|
||||
const status = getAssignmentStatus(assignment)
|
||||
return (
|
||||
<TableRow key={assignment.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
|
||||
className="hover:text-brand-blue dark:hover:text-brand-teal transition-colors"
|
||||
>
|
||||
{assignment.project.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{assignment.project.country ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{status === 'SUBMITTED' ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
) : status === 'COI' ? (
|
||||
<Button variant="ghost" size="sm" disabled>
|
||||
<ShieldAlert className="mr-1 h-3 w-3" />
|
||||
COI
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
asChild
|
||||
disabled={!isWindowOpen}
|
||||
className={cn(
|
||||
'bg-brand-blue hover:bg-brand-blue-light',
|
||||
!isWindowOpen && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
|
||||
<FileEdit className="mr-1 h-3 w-3" />
|
||||
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list */}
|
||||
<div className="md:hidden divide-y">
|
||||
{assignments.map((assignment) => {
|
||||
const status = getAssignmentStatus(assignment)
|
||||
return (
|
||||
<div key={assignment.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
|
||||
className="font-medium text-sm hover:text-brand-blue transition-colors"
|
||||
>
|
||||
{assignment.project.title}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{assignment.project.teamName}
|
||||
{assignment.project.country && ` · ${assignment.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
{status === 'SUBMITTED' ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
|
||||
View Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
) : status !== 'COI' && isWindowOpen ? (
|
||||
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
|
||||
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ClipboardList className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No assignments in this stage</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Assignments will appear here once an administrator assigns projects to you.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed notice */}
|
||||
{!isWindowOpen && totalAssignments > 0 && completedCount < totalAssignments && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
{windowStatus?.reason ?? 'The evaluation window for this stage is currently closed.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowLeft,
|
||||
FileEdit,
|
||||
Eye,
|
||||
ShieldAlert,
|
||||
AlertCircle,
|
||||
ClipboardList,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Type for assignment with included relations from stageAssignment.myAssignments
|
||||
type AssignmentWithRelations = {
|
||||
id: string
|
||||
projectId: string
|
||||
stageId: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
tags: string[]
|
||||
description: string | null
|
||||
}
|
||||
evaluation?: {
|
||||
id: string
|
||||
status: string
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest?: {
|
||||
id: string
|
||||
hasConflict: boolean
|
||||
conflictType: string | null
|
||||
reviewAction: string | null
|
||||
} | null
|
||||
stage?: {
|
||||
id: string
|
||||
name: string
|
||||
track: {
|
||||
name: string
|
||||
pipeline: { id: string; name: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAssignmentStatus(assignment: {
|
||||
evaluation?: { status: string } | null
|
||||
conflictOfInterest?: { id: string } | null
|
||||
}) {
|
||||
if (assignment.conflictOfInterest) return 'COI'
|
||||
if (!assignment.evaluation) return 'NOT_STARTED'
|
||||
return assignment.evaluation.status
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
)
|
||||
case 'DRAFT':
|
||||
return (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
In Progress
|
||||
</Badge>
|
||||
)
|
||||
case 'COI':
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<ShieldAlert className="mr-1 h-3 w-3" />
|
||||
COI Declared
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Not Started
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function StageAssignmentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
|
||||
const { data: stageInfo, isLoading: stageLoading } =
|
||||
trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
const { data: rawAssignments, isLoading: assignmentsLoading } =
|
||||
trpc.stageAssignment.myAssignments.useQuery({ stageId })
|
||||
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
|
||||
|
||||
const { data: windowStatus } =
|
||||
trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
const isLoading = stageLoading || assignmentsLoading
|
||||
|
||||
const totalAssignments = assignments?.length ?? 0
|
||||
const completedCount = assignments?.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
).length ?? 0
|
||||
const coiCount = assignments?.filter((a) => a.conflictOfInterest).length ?? 0
|
||||
const pendingCount = totalAssignments - completedCount - coiCount
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/jury/stages" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{stageInfo?.name ?? 'Stage Assignments'}
|
||||
</h1>
|
||||
{stageInfo && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{stageInfo.track.name} · {stageInfo.track.pipeline.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold">{totalAssignments}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-600">{completedCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Completed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-amber-600">{pendingCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Pending</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-2xl font-bold text-red-600">{coiCount}</p>
|
||||
<p className="text-xs text-muted-foreground">COI Declared</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Assignments table */}
|
||||
{assignments && assignments.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((assignment) => {
|
||||
const status = getAssignmentStatus(assignment)
|
||||
return (
|
||||
<TableRow key={assignment.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
|
||||
className="hover:text-brand-blue dark:hover:text-brand-teal transition-colors"
|
||||
>
|
||||
{assignment.project.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{assignment.project.country ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{status === 'SUBMITTED' ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
) : status === 'COI' ? (
|
||||
<Button variant="ghost" size="sm" disabled>
|
||||
<ShieldAlert className="mr-1 h-3 w-3" />
|
||||
COI
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
asChild
|
||||
disabled={!isWindowOpen}
|
||||
className={cn(
|
||||
'bg-brand-blue hover:bg-brand-blue-light',
|
||||
!isWindowOpen && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
|
||||
<FileEdit className="mr-1 h-3 w-3" />
|
||||
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list */}
|
||||
<div className="md:hidden divide-y">
|
||||
{assignments.map((assignment) => {
|
||||
const status = getAssignmentStatus(assignment)
|
||||
return (
|
||||
<div key={assignment.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
|
||||
className="font-medium text-sm hover:text-brand-blue transition-colors"
|
||||
>
|
||||
{assignment.project.title}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{assignment.project.teamName}
|
||||
{assignment.project.country && ` · ${assignment.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
{status === 'SUBMITTED' ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
|
||||
View Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
) : status !== 'COI' && isWindowOpen ? (
|
||||
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
|
||||
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ClipboardList className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No assignments in this stage</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Assignments will appear here once an administrator assigns projects to you.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed notice */}
|
||||
{!isWindowOpen && totalAssignments > 0 && completedCount < totalAssignments && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
{windowStatus?.reason ?? 'The evaluation window for this stage is currently closed.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,311 +1,311 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
ArrowLeft,
|
||||
GitCompare,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Type for assignment with included relations from stageAssignment.myAssignments
|
||||
type AssignmentWithRelations = {
|
||||
id: string
|
||||
projectId: string
|
||||
stageId: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
tags: string[]
|
||||
description: string | null
|
||||
}
|
||||
evaluation?: {
|
||||
id: string
|
||||
status: string
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest?: {
|
||||
id: string
|
||||
hasConflict: boolean
|
||||
conflictType: string | null
|
||||
reviewAction: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export default function StageComparePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: rawAssignments, isLoading: assignmentsLoading } =
|
||||
trpc.stageAssignment.myAssignments.useQuery({ stageId })
|
||||
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: evaluations } =
|
||||
trpc.evaluation.listStageEvaluations.useQuery({ stageId })
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
|
||||
const criteria = stageForm?.criteriaJson?.filter(
|
||||
(c: { type?: string }) => c.type !== 'section_header'
|
||||
) ?? []
|
||||
|
||||
// Map evaluations by project ID
|
||||
const evalByProject = useMemo(() => {
|
||||
const map = new Map<string, (typeof evaluations extends (infer T)[] | undefined ? T : never)>()
|
||||
evaluations?.forEach((e) => {
|
||||
if (e.assignment?.projectId) {
|
||||
map.set(e.assignment.projectId, e)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [evaluations])
|
||||
|
||||
const toggleProject = (projectId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId)
|
||||
} else if (next.size < 4) {
|
||||
next.add(projectId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectedAssignments = assignments?.filter((a) =>
|
||||
selectedIds.has(a.project.id)
|
||||
) ?? []
|
||||
|
||||
if (assignmentsLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const submittedAssignments = assignments?.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
) ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<GitCompare className="h-6 w-6" />
|
||||
Compare Projects
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Select 2-4 evaluated projects to compare side-by-side
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{submittedAssignments.length < 2 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Not enough evaluations</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You need at least 2 submitted evaluations to compare projects.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Project selector */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Select Projects ({selectedIds.size}/4)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{submittedAssignments.map((assignment) => {
|
||||
const isSelected = selectedIds.has(assignment.project.id)
|
||||
const eval_ = evalByProject.get(assignment.project.id)
|
||||
return (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
isSelected
|
||||
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
|
||||
: 'hover:bg-muted/50',
|
||||
selectedIds.size >= 4 && !isSelected && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onClick={() => toggleProject(assignment.project.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={selectedIds.size >= 4 && !isSelected}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{assignment.project.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</p>
|
||||
</div>
|
||||
{eval_?.globalScore != null && (
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
<Star className="mr-1 h-3 w-3 text-amber-500" />
|
||||
{eval_.globalScore.toFixed(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comparison table */}
|
||||
{selectedAssignments.length >= 2 && (
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[140px]">Criterion</TableHead>
|
||||
{selectedAssignments.map((a) => (
|
||||
<TableHead key={a.id} className="text-center min-w-[120px]">
|
||||
<div className="truncate max-w-[120px]" title={a.project.title}>
|
||||
{a.project.title}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* Criterion rows */}
|
||||
{criteria.map((criterion: { id: string; label: string; type?: string; scale?: string | number }) => (
|
||||
<TableRow key={criterion.id}>
|
||||
<TableCell className="font-medium text-sm">
|
||||
{criterion.label}
|
||||
</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
const scores = eval_?.criterionScoresJson as Record<string, number | string | boolean> | null
|
||||
const score = scores?.[criterion.id]
|
||||
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
{criterion.type === 'boolean' ? (
|
||||
score ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600 mx-auto" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
) : criterion.type === 'text' ? (
|
||||
<span className="text-xs truncate max-w-[100px] block">
|
||||
{String(score ?? '—')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="tabular-nums font-semibold">
|
||||
{typeof score === 'number' ? score.toFixed(1) : '—'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* Global score row */}
|
||||
<TableRow className="bg-muted/50 font-semibold">
|
||||
<TableCell>Global Score</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
<span className="tabular-nums text-lg">
|
||||
{eval_?.globalScore?.toFixed(1) ?? '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
|
||||
{/* Decision row */}
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Decision</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
{eval_?.binaryDecision != null ? (
|
||||
<Badge variant={eval_.binaryDecision ? 'success' : 'destructive'}>
|
||||
{eval_.binaryDecision ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
ArrowLeft,
|
||||
GitCompare,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Type for assignment with included relations from stageAssignment.myAssignments
|
||||
type AssignmentWithRelations = {
|
||||
id: string
|
||||
projectId: string
|
||||
stageId: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
tags: string[]
|
||||
description: string | null
|
||||
}
|
||||
evaluation?: {
|
||||
id: string
|
||||
status: string
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest?: {
|
||||
id: string
|
||||
hasConflict: boolean
|
||||
conflictType: string | null
|
||||
reviewAction: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export default function StageComparePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: rawAssignments, isLoading: assignmentsLoading } =
|
||||
trpc.stageAssignment.myAssignments.useQuery({ stageId })
|
||||
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: evaluations } =
|
||||
trpc.evaluation.listStageEvaluations.useQuery({ stageId })
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
|
||||
const criteria = stageForm?.criteriaJson?.filter(
|
||||
(c: { type?: string }) => c.type !== 'section_header'
|
||||
) ?? []
|
||||
|
||||
// Map evaluations by project ID
|
||||
const evalByProject = useMemo(() => {
|
||||
const map = new Map<string, (typeof evaluations extends (infer T)[] | undefined ? T : never)>()
|
||||
evaluations?.forEach((e) => {
|
||||
if (e.assignment?.projectId) {
|
||||
map.set(e.assignment.projectId, e)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [evaluations])
|
||||
|
||||
const toggleProject = (projectId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId)
|
||||
} else if (next.size < 4) {
|
||||
next.add(projectId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectedAssignments = assignments?.filter((a) =>
|
||||
selectedIds.has(a.project.id)
|
||||
) ?? []
|
||||
|
||||
if (assignmentsLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const submittedAssignments = assignments?.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
) ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<GitCompare className="h-6 w-6" />
|
||||
Compare Projects
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Select 2-4 evaluated projects to compare side-by-side
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{submittedAssignments.length < 2 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Not enough evaluations</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You need at least 2 submitted evaluations to compare projects.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Project selector */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Select Projects ({selectedIds.size}/4)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{submittedAssignments.map((assignment) => {
|
||||
const isSelected = selectedIds.has(assignment.project.id)
|
||||
const eval_ = evalByProject.get(assignment.project.id)
|
||||
return (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
isSelected
|
||||
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
|
||||
: 'hover:bg-muted/50',
|
||||
selectedIds.size >= 4 && !isSelected && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onClick={() => toggleProject(assignment.project.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={selectedIds.size >= 4 && !isSelected}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{assignment.project.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</p>
|
||||
</div>
|
||||
{eval_?.globalScore != null && (
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
<Star className="mr-1 h-3 w-3 text-amber-500" />
|
||||
{eval_.globalScore.toFixed(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comparison table */}
|
||||
{selectedAssignments.length >= 2 && (
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[140px]">Criterion</TableHead>
|
||||
{selectedAssignments.map((a) => (
|
||||
<TableHead key={a.id} className="text-center min-w-[120px]">
|
||||
<div className="truncate max-w-[120px]" title={a.project.title}>
|
||||
{a.project.title}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* Criterion rows */}
|
||||
{criteria.map((criterion: { id: string; label: string; type?: string; scale?: string | number }) => (
|
||||
<TableRow key={criterion.id}>
|
||||
<TableCell className="font-medium text-sm">
|
||||
{criterion.label}
|
||||
</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
const scores = eval_?.criterionScoresJson as Record<string, number | string | boolean> | null
|
||||
const score = scores?.[criterion.id]
|
||||
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
{criterion.type === 'boolean' ? (
|
||||
score ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600 mx-auto" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
) : criterion.type === 'text' ? (
|
||||
<span className="text-xs truncate max-w-[100px] block">
|
||||
{String(score ?? '—')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="tabular-nums font-semibold">
|
||||
{typeof score === 'number' ? score.toFixed(1) : '—'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* Global score row */}
|
||||
<TableRow className="bg-muted/50 font-semibold">
|
||||
<TableCell>Global Score</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
<span className="tabular-nums text-lg">
|
||||
{eval_?.globalScore?.toFixed(1) ?? '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
|
||||
{/* Decision row */}
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Decision</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
{eval_?.binaryDecision != null ? (
|
||||
<Badge variant={eval_.binaryDecision ? 'success' : 'destructive'}>
|
||||
{eval_.binaryDecision ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,269 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function StageJuryLivePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [hasVoted, setHasVoted] = useState(false)
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
// Get live cursor for this stage
|
||||
const { data: cursorData } = trpc.live.getCursor.useQuery(
|
||||
{ stageId },
|
||||
{ enabled: !!stageId }
|
||||
)
|
||||
|
||||
const sessionId = cursorData?.sessionId ?? null
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
// Reset vote state when active project changes
|
||||
const activeProjectId = activeProject?.id
|
||||
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
|
||||
|
||||
if (activeProjectId && activeProjectId !== lastVotedProjectId && hasVoted) {
|
||||
setHasVoted(false)
|
||||
setSelectedScore(null)
|
||||
}
|
||||
|
||||
const castVoteMutation = trpc.live.castStageVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Vote submitted!')
|
||||
setHasVoted(true)
|
||||
setSelectedScore(null)
|
||||
setLastVotedProjectId(activeProjectId ?? null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleVote = () => {
|
||||
if (!sessionId || !activeProject || selectedScore === null) return
|
||||
castVoteMutation.mutate({
|
||||
sessionId,
|
||||
projectId: activeProject.id,
|
||||
score: selectedScore,
|
||||
})
|
||||
}
|
||||
|
||||
if (!cursorData && !stageInfo) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No live session active</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A live presentation session has not been started for this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header with connection status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Live Voting</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
)}
|
||||
{!isConnected && (
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{sseError}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Paused overlay */}
|
||||
{isPaused ? (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Pause className="h-12 w-12 text-amber-600 mb-3" />
|
||||
<p className="text-lg font-semibold">Session Paused</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The session administrator has paused voting. Please wait...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : activeProject ? (
|
||||
<>
|
||||
{/* Active project card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Voting controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500" />
|
||||
Cast Your Vote
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasVoted ? (
|
||||
<div className="flex flex-col items-center py-6 text-center">
|
||||
<CheckCircle2 className="h-12 w-12 text-emerald-600 mb-3" />
|
||||
<p className="font-semibold">Vote submitted!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Waiting for the next project...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-5 gap-2 sm:grid-cols-10">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-12 text-lg font-bold tabular-nums',
|
||||
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light'
|
||||
)}
|
||||
onClick={() => setSelectedScore(score)}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-brand-blue hover:bg-brand-blue-light"
|
||||
size="lg"
|
||||
disabled={selectedScore === null || castVoteMutation.isPending}
|
||||
onClick={handleVote}
|
||||
>
|
||||
{castVoteMutation.isPending ? 'Submitting...' : 'Submit Vote'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Star className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Waiting for next project...</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The session administrator will advance to the next project.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function StageJuryLivePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [hasVoted, setHasVoted] = useState(false)
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
// Get live cursor for this stage
|
||||
const { data: cursorData } = trpc.live.getCursor.useQuery(
|
||||
{ stageId },
|
||||
{ enabled: !!stageId }
|
||||
)
|
||||
|
||||
const sessionId = cursorData?.sessionId ?? null
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
// Reset vote state when active project changes
|
||||
const activeProjectId = activeProject?.id
|
||||
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
|
||||
|
||||
if (activeProjectId && activeProjectId !== lastVotedProjectId && hasVoted) {
|
||||
setHasVoted(false)
|
||||
setSelectedScore(null)
|
||||
}
|
||||
|
||||
const castVoteMutation = trpc.live.castStageVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Vote submitted!')
|
||||
setHasVoted(true)
|
||||
setSelectedScore(null)
|
||||
setLastVotedProjectId(activeProjectId ?? null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleVote = () => {
|
||||
if (!sessionId || !activeProject || selectedScore === null) return
|
||||
castVoteMutation.mutate({
|
||||
sessionId,
|
||||
projectId: activeProject.id,
|
||||
score: selectedScore,
|
||||
})
|
||||
}
|
||||
|
||||
if (!cursorData && !stageInfo) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No live session active</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A live presentation session has not been started for this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header with connection status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Live Voting</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
)}
|
||||
{!isConnected && (
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{sseError}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Paused overlay */}
|
||||
{isPaused ? (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Pause className="h-12 w-12 text-amber-600 mb-3" />
|
||||
<p className="text-lg font-semibold">Session Paused</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The session administrator has paused voting. Please wait...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : activeProject ? (
|
||||
<>
|
||||
{/* Active project card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Voting controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500" />
|
||||
Cast Your Vote
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasVoted ? (
|
||||
<div className="flex flex-col items-center py-6 text-center">
|
||||
<CheckCircle2 className="h-12 w-12 text-emerald-600 mb-3" />
|
||||
<p className="font-semibold">Vote submitted!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Waiting for the next project...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-5 gap-2 sm:grid-cols-10">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-12 text-lg font-bold tabular-nums',
|
||||
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light'
|
||||
)}
|
||||
onClick={() => setSelectedScore(score)}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-brand-blue hover:bg-brand-blue-light"
|
||||
size="lg"
|
||||
disabled={selectedScore === null || castVoteMutation.isPending}
|
||||
onClick={handleVote}
|
||||
>
|
||||
{castVoteMutation.isPending ? 'Submitting...' : 'Submit Vote'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Star className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Waiting for next project...</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The session administrator will advance to the next project.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,199 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, AlertCircle } from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
|
||||
|
||||
export default function StageEvaluatePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
// Fetch assignment details
|
||||
const { data: assignment, isLoading: assignmentLoading } =
|
||||
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
|
||||
|
||||
// Fetch stage info for breadcrumb
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
// Fetch or create evaluation draft
|
||||
const startEval = trpc.evaluation.startStage.useMutation()
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
// State for the evaluation returned by the mutation
|
||||
const [evaluation, setEvaluation] = useState<{
|
||||
id: string
|
||||
status: string
|
||||
criterionScoresJson?: unknown
|
||||
globalScore?: number | null
|
||||
binaryDecision?: boolean | null
|
||||
feedbackText?: string | null
|
||||
} | null>(null)
|
||||
|
||||
// Start evaluation on first load if we have the assignment
|
||||
useEffect(() => {
|
||||
if (assignment && !evaluation && !startEval.isPending && (windowStatus?.isOpen ?? false)) {
|
||||
startEval.mutate(
|
||||
{ assignmentId: assignment.id, stageId },
|
||||
{ onSuccess: (data) => setEvaluation(data) }
|
||||
)
|
||||
}
|
||||
}, [assignment?.id, windowStatus?.isOpen])
|
||||
|
||||
const isLoading = assignmentLoading || startEval.isPending
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
|
||||
<p className="font-medium text-destructive">Assignment not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You don't have an assignment for this project in this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
const criteria = stageForm?.criteriaJson ?? []
|
||||
|
||||
// Get COI status from assignment
|
||||
const coiStatus = {
|
||||
hasConflict: !!assignment.conflictOfInterest,
|
||||
declared: !!assignment.conflictOfInterest,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project title + stage window */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{assignment.project.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{assignment.project.teamName}
|
||||
{assignment.project.country && ` · ${assignment.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grace period notice */}
|
||||
{windowStatus?.hasGracePeriod && windowStatus?.graceExpiresAt && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
You are in a grace period. Please submit your evaluation before{' '}
|
||||
{new Date(windowStatus.graceExpiresAt).toLocaleString()}.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed notice */}
|
||||
{!isWindowOpen && !windowStatus?.hasGracePeriod && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">
|
||||
{windowStatus?.reason ?? 'The evaluation window for this stage is closed.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Project files */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={projectId}
|
||||
fileCount={assignment.project.files?.length ?? 0}
|
||||
stageId={stageId}
|
||||
/>
|
||||
|
||||
{/* Evaluation form */}
|
||||
{isWindowOpen || windowStatus?.hasGracePeriod ? (
|
||||
<EvaluationFormWithCOI
|
||||
assignmentId={assignment.id}
|
||||
evaluationId={evaluation?.id ?? null}
|
||||
projectTitle={assignment.project.title}
|
||||
criteria={criteria as Array<{ id: string; label: string; description?: string; type?: 'numeric' | 'text' | 'boolean' | 'section_header'; scale?: number; weight?: number; required?: boolean }>}
|
||||
initialData={
|
||||
evaluation
|
||||
? {
|
||||
criterionScoresJson:
|
||||
evaluation.criterionScoresJson as Record<string, number | string | boolean> | null,
|
||||
globalScore: evaluation.globalScore ?? null,
|
||||
binaryDecision: evaluation.binaryDecision ?? null,
|
||||
feedbackText: evaluation.feedbackText ?? null,
|
||||
status: evaluation.status,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isVotingOpen={isWindowOpen || !!windowStatus?.hasGracePeriod}
|
||||
deadline={
|
||||
windowStatus?.graceExpiresAt
|
||||
? new Date(windowStatus.graceExpiresAt)
|
||||
: stageInfo?.windowCloseAt
|
||||
? new Date(stageInfo.windowCloseAt)
|
||||
: null
|
||||
}
|
||||
coiStatus={coiStatus}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, AlertCircle } from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
|
||||
|
||||
export default function StageEvaluatePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
// Fetch assignment details
|
||||
const { data: assignment, isLoading: assignmentLoading } =
|
||||
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
|
||||
|
||||
// Fetch stage info for breadcrumb
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
// Fetch or create evaluation draft
|
||||
const startEval = trpc.evaluation.startStage.useMutation()
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
// State for the evaluation returned by the mutation
|
||||
const [evaluation, setEvaluation] = useState<{
|
||||
id: string
|
||||
status: string
|
||||
criterionScoresJson?: unknown
|
||||
globalScore?: number | null
|
||||
binaryDecision?: boolean | null
|
||||
feedbackText?: string | null
|
||||
} | null>(null)
|
||||
|
||||
// Start evaluation on first load if we have the assignment
|
||||
useEffect(() => {
|
||||
if (assignment && !evaluation && !startEval.isPending && (windowStatus?.isOpen ?? false)) {
|
||||
startEval.mutate(
|
||||
{ assignmentId: assignment.id, stageId },
|
||||
{ onSuccess: (data) => setEvaluation(data) }
|
||||
)
|
||||
}
|
||||
}, [assignment?.id, windowStatus?.isOpen])
|
||||
|
||||
const isLoading = assignmentLoading || startEval.isPending
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
|
||||
<p className="font-medium text-destructive">Assignment not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You don't have an assignment for this project in this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
const criteria = stageForm?.criteriaJson ?? []
|
||||
|
||||
// Get COI status from assignment
|
||||
const coiStatus = {
|
||||
hasConflict: !!assignment.conflictOfInterest,
|
||||
declared: !!assignment.conflictOfInterest,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project title + stage window */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{assignment.project.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{assignment.project.teamName}
|
||||
{assignment.project.country && ` · ${assignment.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grace period notice */}
|
||||
{windowStatus?.hasGracePeriod && windowStatus?.graceExpiresAt && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
You are in a grace period. Please submit your evaluation before{' '}
|
||||
{new Date(windowStatus.graceExpiresAt).toLocaleString()}.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Window closed notice */}
|
||||
{!isWindowOpen && !windowStatus?.hasGracePeriod && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">
|
||||
{windowStatus?.reason ?? 'The evaluation window for this stage is closed.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Project files */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={projectId}
|
||||
fileCount={assignment.project.files?.length ?? 0}
|
||||
stageId={stageId}
|
||||
/>
|
||||
|
||||
{/* Evaluation form */}
|
||||
{isWindowOpen || windowStatus?.hasGracePeriod ? (
|
||||
<EvaluationFormWithCOI
|
||||
assignmentId={assignment.id}
|
||||
evaluationId={evaluation?.id ?? null}
|
||||
projectTitle={assignment.project.title}
|
||||
criteria={criteria as Array<{ id: string; label: string; description?: string; type?: 'numeric' | 'text' | 'boolean' | 'section_header'; scale?: number; weight?: number; required?: boolean }>}
|
||||
initialData={
|
||||
evaluation
|
||||
? {
|
||||
criterionScoresJson:
|
||||
evaluation.criterionScoresJson as Record<string, number | string | boolean> | null,
|
||||
globalScore: evaluation.globalScore ?? null,
|
||||
binaryDecision: evaluation.binaryDecision ?? null,
|
||||
feedbackText: evaluation.feedbackText ?? null,
|
||||
status: evaluation.status,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isVotingOpen={isWindowOpen || !!windowStatus?.hasGracePeriod}
|
||||
deadline={
|
||||
windowStatus?.graceExpiresAt
|
||||
? new Date(windowStatus.graceExpiresAt)
|
||||
: stageInfo?.windowCloseAt
|
||||
? new Date(stageInfo.windowCloseAt)
|
||||
: null
|
||||
}
|
||||
coiStatus={coiStatus}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,235 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Star,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
|
||||
export default function ViewStageEvaluationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
const { data: evaluations, isLoading } =
|
||||
trpc.evaluation.listStageEvaluations.useQuery({ stageId, projectId })
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
|
||||
const evaluation = evaluations?.[0] // Most recent evaluation
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!evaluation) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No evaluation found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You haven't submitted an evaluation for this project yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const criterionScores = evaluation.criterionScoresJson as Record<string, number | string | boolean> | null
|
||||
const criteria = (stageForm?.criteriaJson as Array<{ id: string; label: string; type?: string; scale?: number }>) ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{evaluation.assignment?.project?.title ?? 'Evaluation'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Submitted evaluation — read only
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success" className="self-start">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Submission info */}
|
||||
{evaluation.submittedAt && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-2 py-3">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Submitted on{' '}
|
||||
{new Date(evaluation.submittedAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Criterion scores */}
|
||||
{criteria.length > 0 && criterionScores && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Criterion Scores</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{criteria.map((criterion) => {
|
||||
const score = criterionScores[criterion.id]
|
||||
if (criterion.type === 'section_header') {
|
||||
return (
|
||||
<div key={criterion.id} className="pt-2">
|
||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
|
||||
{criterion.label}
|
||||
</h3>
|
||||
<Separator className="mt-2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={criterion.id} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm font-medium">{criterion.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{criterion.type === 'boolean' ? (
|
||||
<Badge variant={score ? 'success' : 'secondary'}>
|
||||
{score ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
) : criterion.type === 'text' ? (
|
||||
<span className="text-sm text-muted-foreground max-w-[200px] truncate">
|
||||
{String(score ?? '—')}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-amber-500" />
|
||||
<span className="font-semibold tabular-nums">
|
||||
{typeof score === 'number' ? score.toFixed(1) : '—'}
|
||||
</span>
|
||||
{criterion.scale && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {criterion.scale}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Global score + Decision */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500" />
|
||||
Global Score
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-4xl font-bold tabular-nums">
|
||||
{evaluation.globalScore?.toFixed(1) ?? '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{evaluation.binaryDecision !== null && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Decision</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge
|
||||
variant={evaluation.binaryDecision ? 'success' : 'destructive'}
|
||||
className="text-lg px-4 py-2"
|
||||
>
|
||||
{evaluation.binaryDecision ? 'Recommend' : 'Do Not Recommend'}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{evaluation.feedbackText && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Feedback
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p className="whitespace-pre-wrap">{evaluation.feedbackText}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Star,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
|
||||
export default function ViewStageEvaluationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
const { data: evaluations, isLoading } =
|
||||
trpc.evaluation.listStageEvaluations.useQuery({ stageId, projectId })
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
|
||||
const evaluation = evaluations?.[0] // Most recent evaluation
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!evaluation) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No evaluation found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You haven't submitted an evaluation for this project yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const criterionScores = evaluation.criterionScoresJson as Record<string, number | string | boolean> | null
|
||||
const criteria = (stageForm?.criteriaJson as Array<{ id: string; label: string; type?: string; scale?: number }>) ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{evaluation.assignment?.project?.title ?? 'Evaluation'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Submitted evaluation — read only
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success" className="self-start">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Submission info */}
|
||||
{evaluation.submittedAt && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-2 py-3">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Submitted on{' '}
|
||||
{new Date(evaluation.submittedAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Criterion scores */}
|
||||
{criteria.length > 0 && criterionScores && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Criterion Scores</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{criteria.map((criterion) => {
|
||||
const score = criterionScores[criterion.id]
|
||||
if (criterion.type === 'section_header') {
|
||||
return (
|
||||
<div key={criterion.id} className="pt-2">
|
||||
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
|
||||
{criterion.label}
|
||||
</h3>
|
||||
<Separator className="mt-2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={criterion.id} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm font-medium">{criterion.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{criterion.type === 'boolean' ? (
|
||||
<Badge variant={score ? 'success' : 'secondary'}>
|
||||
{score ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
) : criterion.type === 'text' ? (
|
||||
<span className="text-sm text-muted-foreground max-w-[200px] truncate">
|
||||
{String(score ?? '—')}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-amber-500" />
|
||||
<span className="font-semibold tabular-nums">
|
||||
{typeof score === 'number' ? score.toFixed(1) : '—'}
|
||||
</span>
|
||||
{criterion.scale && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {criterion.scale}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Global score + Decision */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500" />
|
||||
Global Score
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-4xl font-bold tabular-nums">
|
||||
{evaluation.globalScore?.toFixed(1) ?? '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{evaluation.binaryDecision !== null && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Decision</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge
|
||||
variant={evaluation.binaryDecision ? 'success' : 'destructive'}
|
||||
className="text-lg px-4 py-2"
|
||||
>
|
||||
{evaluation.binaryDecision ? 'Recommend' : 'Do Not Recommend'}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{evaluation.feedbackText && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Feedback
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p className="whitespace-pre-wrap">{evaluation.feedbackText}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,217 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileEdit,
|
||||
Eye,
|
||||
Users,
|
||||
MapPin,
|
||||
Tag,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
|
||||
function EvalStatusCard({
|
||||
status,
|
||||
stageId,
|
||||
projectId,
|
||||
isWindowOpen,
|
||||
}: {
|
||||
status: string
|
||||
stageId: string
|
||||
projectId: string
|
||||
isWindowOpen: boolean
|
||||
}) {
|
||||
const isSubmitted = status === 'SUBMITTED'
|
||||
const isDraft = status === 'DRAFT'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Evaluation Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={
|
||||
isSubmitted ? 'success' : isDraft ? 'warning' : 'secondary'
|
||||
}
|
||||
>
|
||||
{isSubmitted ? 'Submitted' : isDraft ? 'In Progress' : 'Not Started'}
|
||||
</Badge>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{isSubmitted ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluation` as Route}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
) : isWindowOpen ? (
|
||||
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluate` as Route}>
|
||||
<FileEdit className="mr-1 h-3 w-3" />
|
||||
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Window Closed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StageProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
const { data: assignment, isLoading: assignmentLoading } =
|
||||
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
|
||||
if (assignmentLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
|
||||
<p className="font-medium text-destructive">Assignment not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const project = assignment.project
|
||||
const evalStatus = assignment.evaluation?.status ?? 'NOT_STARTED'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{project.title}</h1>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
|
||||
{project.teamName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{project.teamName}
|
||||
</span>
|
||||
)}
|
||||
{project.country && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{project.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project description */}
|
||||
{project.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Description</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
{project.tags.map((tag: string) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Evaluation status */}
|
||||
<EvalStatusCard
|
||||
status={evalStatus}
|
||||
stageId={stageId}
|
||||
projectId={projectId}
|
||||
isWindowOpen={isWindowOpen}
|
||||
/>
|
||||
|
||||
{/* Project files */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={projectId}
|
||||
fileCount={project.files?.length ?? 0}
|
||||
stageId={stageId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileEdit,
|
||||
Eye,
|
||||
Users,
|
||||
MapPin,
|
||||
Tag,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
|
||||
function EvalStatusCard({
|
||||
status,
|
||||
stageId,
|
||||
projectId,
|
||||
isWindowOpen,
|
||||
}: {
|
||||
status: string
|
||||
stageId: string
|
||||
projectId: string
|
||||
isWindowOpen: boolean
|
||||
}) {
|
||||
const isSubmitted = status === 'SUBMITTED'
|
||||
const isDraft = status === 'DRAFT'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Evaluation Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={
|
||||
isSubmitted ? 'success' : isDraft ? 'warning' : 'secondary'
|
||||
}
|
||||
>
|
||||
{isSubmitted ? 'Submitted' : isDraft ? 'In Progress' : 'Not Started'}
|
||||
</Badge>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{isSubmitted ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluation` as Route}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
) : isWindowOpen ? (
|
||||
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluate` as Route}>
|
||||
<FileEdit className="mr-1 h-3 w-3" />
|
||||
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Window Closed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StageProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
const { data: assignment, isLoading: assignmentLoading } =
|
||||
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
|
||||
if (assignmentLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
|
||||
<p className="font-medium text-destructive">Assignment not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const project = assignment.project
|
||||
const evalStatus = assignment.evaluation?.status ?? 'NOT_STARTED'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{project.title}</h1>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
|
||||
{project.teamName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{project.teamName}
|
||||
</span>
|
||||
)}
|
||||
{project.country && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{project.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project description */}
|
||||
{project.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Description</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
{project.tags.map((tag: string) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Evaluation status */}
|
||||
<EvalStatusCard
|
||||
status={evalStatus}
|
||||
stageId={stageId}
|
||||
projectId={projectId}
|
||||
isWindowOpen={isWindowOpen}
|
||||
/>
|
||||
|
||||
{/* Project files */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={projectId}
|
||||
fileCount={project.files?.length ?? 0}
|
||||
stageId={stageId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,247 +1,247 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Target,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function JuryStagesDashboard() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id ?? ''
|
||||
|
||||
const { data: stages, isLoading: stagesLoading } =
|
||||
trpc.stageAssignment.myStages.useQuery(
|
||||
{ programId },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
const totalAssignments = stages?.reduce((sum, s) => sum + s.stats.total, 0) ?? 0
|
||||
const totalCompleted = stages?.reduce((sum, s) => sum + s.stats.completed, 0) ?? 0
|
||||
const totalInProgress = stages?.reduce((sum, s) => sum + s.stats.inProgress, 0) ?? 0
|
||||
const totalPending = totalAssignments - totalCompleted - totalInProgress
|
||||
const completionRate = totalAssignments > 0
|
||||
? Math.round((totalCompleted / totalAssignments) * 100)
|
||||
: 0
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Total Assignments',
|
||||
value: totalAssignments,
|
||||
icon: ClipboardList,
|
||||
accentColor: 'border-l-blue-500',
|
||||
iconBg: 'bg-blue-50 dark:bg-blue-950/40',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
value: totalCompleted,
|
||||
icon: CheckCircle2,
|
||||
accentColor: 'border-l-emerald-500',
|
||||
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
{
|
||||
label: 'In Progress',
|
||||
value: totalInProgress,
|
||||
icon: Clock,
|
||||
accentColor: 'border-l-amber-500',
|
||||
iconBg: 'bg-amber-50 dark:bg-amber-950/40',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
{
|
||||
label: 'Pending',
|
||||
value: totalPending,
|
||||
icon: Target,
|
||||
accentColor: 'border-l-slate-400',
|
||||
iconBg: 'bg-slate-50 dark:bg-slate-800/50',
|
||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
||||
},
|
||||
]
|
||||
|
||||
if (stagesLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i} className="border-l-4 border-l-muted">
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<Skeleton className="h-11 w-11 rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="py-5">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stages || stages.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3">
|
||||
<Layers className="h-8 w-8 text-brand-teal/60" />
|
||||
</div>
|
||||
<p className="text-lg font-semibold">No stage assignments yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
||||
Your stage-based assignments will appear here once an administrator assigns projects to you.
|
||||
</p>
|
||||
<Button variant="outline" asChild className="mt-4">
|
||||
<Link href="/jury">
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{stats.map((stat) => (
|
||||
<Card
|
||||
key={stat.label}
|
||||
className={cn('border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', stat.accentColor)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<div className={cn('rounded-xl p-3', stat.iconBg)}>
|
||||
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
|
||||
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Card className="border-l-4 border-l-brand-teal">
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
|
||||
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal">
|
||||
{completionRate}%
|
||||
</p>
|
||||
<Progress value={completionRate} className="h-1.5 mt-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Stage cards */}
|
||||
<div className="space-y-3">
|
||||
{stages.map((stage) => {
|
||||
const stageProgress = stage.stats.total > 0
|
||||
? Math.round((stage.stats.completed / stage.stats.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Card key={stage.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg truncate">{stage.name}</h3>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stage.windowOpenAt}
|
||||
windowCloseAt={stage.windowCloseAt}
|
||||
status={stage.status}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{stage.track.name} · {stage.track.pipeline.name}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{stage.stats.completed}/{stage.stats.total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={stageProgress} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:flex-col sm:items-end">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{stage.stats.completed > 0 && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{stage.stats.completed} done
|
||||
</Badge>
|
||||
)}
|
||||
{stage.stats.inProgress > 0 && (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
{stage.stats.inProgress} in progress
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stage.id}/assignments` as Route}>
|
||||
View Assignments
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Target,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function JuryStagesDashboard() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id ?? ''
|
||||
|
||||
const { data: stages, isLoading: stagesLoading } =
|
||||
trpc.stageAssignment.myStages.useQuery(
|
||||
{ programId },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
const totalAssignments = stages?.reduce((sum, s) => sum + s.stats.total, 0) ?? 0
|
||||
const totalCompleted = stages?.reduce((sum, s) => sum + s.stats.completed, 0) ?? 0
|
||||
const totalInProgress = stages?.reduce((sum, s) => sum + s.stats.inProgress, 0) ?? 0
|
||||
const totalPending = totalAssignments - totalCompleted - totalInProgress
|
||||
const completionRate = totalAssignments > 0
|
||||
? Math.round((totalCompleted / totalAssignments) * 100)
|
||||
: 0
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Total Assignments',
|
||||
value: totalAssignments,
|
||||
icon: ClipboardList,
|
||||
accentColor: 'border-l-blue-500',
|
||||
iconBg: 'bg-blue-50 dark:bg-blue-950/40',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
value: totalCompleted,
|
||||
icon: CheckCircle2,
|
||||
accentColor: 'border-l-emerald-500',
|
||||
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
{
|
||||
label: 'In Progress',
|
||||
value: totalInProgress,
|
||||
icon: Clock,
|
||||
accentColor: 'border-l-amber-500',
|
||||
iconBg: 'bg-amber-50 dark:bg-amber-950/40',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
{
|
||||
label: 'Pending',
|
||||
value: totalPending,
|
||||
icon: Target,
|
||||
accentColor: 'border-l-slate-400',
|
||||
iconBg: 'bg-slate-50 dark:bg-slate-800/50',
|
||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
||||
},
|
||||
]
|
||||
|
||||
if (stagesLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i} className="border-l-4 border-l-muted">
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<Skeleton className="h-11 w-11 rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="py-5">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stages || stages.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3">
|
||||
<Layers className="h-8 w-8 text-brand-teal/60" />
|
||||
</div>
|
||||
<p className="text-lg font-semibold">No stage assignments yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
||||
Your stage-based assignments will appear here once an administrator assigns projects to you.
|
||||
</p>
|
||||
<Button variant="outline" asChild className="mt-4">
|
||||
<Link href="/jury">
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{stats.map((stat) => (
|
||||
<Card
|
||||
key={stat.label}
|
||||
className={cn('border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', stat.accentColor)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<div className={cn('rounded-xl p-3', stat.iconBg)}>
|
||||
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
|
||||
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Card className="border-l-4 border-l-brand-teal">
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
|
||||
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal">
|
||||
{completionRate}%
|
||||
</p>
|
||||
<Progress value={completionRate} className="h-1.5 mt-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Stage cards */}
|
||||
<div className="space-y-3">
|
||||
{stages.map((stage) => {
|
||||
const stageProgress = stage.stats.total > 0
|
||||
? Math.round((stage.stats.completed / stage.stats.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Card key={stage.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg truncate">{stage.name}</h3>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stage.windowOpenAt}
|
||||
windowCloseAt={stage.windowCloseAt}
|
||||
status={stage.status}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{stage.track.name} · {stage.track.pipeline.name}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{stage.stats.completed}/{stage.stats.total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={stageProgress} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:flex-col sm:items-end">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{stage.stats.completed > 0 && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{stage.stats.completed} done
|
||||
</Badge>
|
||||
)}
|
||||
{stage.stats.inProgress > 0 && (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
{stage.stats.inProgress} in progress
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stage.id}/assignments` as Route}>
|
||||
View Assignments
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, Users } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function MentorError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Mentor section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('mentor')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to your mentee dashboard.'}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={'/mentor' as Route}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, Users } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function MentorError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Mentor section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('mentor')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to your mentee dashboard.'}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={'/mentor' as Route}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,398 +1,398 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Users,
|
||||
Briefcase,
|
||||
ArrowRight,
|
||||
Mail,
|
||||
MapPin,
|
||||
GraduationCap,
|
||||
Waves,
|
||||
Crown,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
// Status badge colors
|
||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
SUBMITTED: 'secondary',
|
||||
ELIGIBLE: 'default',
|
||||
ASSIGNED: 'default',
|
||||
SEMIFINALIST: 'default',
|
||||
FINALIST: 'default',
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
// Completion status display
|
||||
const completionBadge: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
|
||||
in_progress: { label: 'In Progress', variant: 'secondary' },
|
||||
completed: { label: 'Completed', variant: 'default' },
|
||||
paused: { label: 'Paused', variant: 'outline' },
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64 mt-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="grid gap-4">
|
||||
<Skeleton className="h-40" />
|
||||
<Skeleton className="h-40" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MentorDashboard() {
|
||||
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
|
||||
const projects = assignments || []
|
||||
const completedCount = projects.filter((a) => a.completionStatus === 'completed').length
|
||||
const inProgressCount = projects.filter((a) => a.completionStatus === 'in_progress').length
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
return projects.filter(a => {
|
||||
const matchesSearch = !search ||
|
||||
a.project.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
a.project.teamName?.toLowerCase().includes(search.toLowerCase())
|
||||
const matchesStatus = statusFilter === 'all' || a.completionStatus === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
}, [projects, search, statusFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Mentor Dashboard
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage your assigned mentee projects
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Assigned Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{projects.length}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Projects you are mentoring</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<Briefcase className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Completed</p>
|
||||
<p className="text-2xl font-bold mt-1">{completedCount}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{projects.length > 0 && (
|
||||
<Progress
|
||||
value={(completedCount / projects.length) * 100}
|
||||
className="h-1.5 flex-1"
|
||||
gradient
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{projects.length > 0 ? Math.round((completedCount / projects.length) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Team Members</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{projects.reduce(
|
||||
(acc, a) => acc + (a.project.teamMembers?.length || 0),
|
||||
0
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Across all assigned projects</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
<Users className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={'/mentor/projects' as Route}>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Messages
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Projects List */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">Your Mentees</h2>
|
||||
|
||||
{/* Search and Filter */}
|
||||
{projects.length > 0 && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search projects..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-4">
|
||||
<Users className="h-8 w-8 text-brand-teal" />
|
||||
</div>
|
||||
<p className="mt-4 font-medium">No assigned projects yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You will see your mentee projects here once they are assigned to
|
||||
you.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No projects match your search criteria
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => { setSearch(''); setStatusFilter('all') }}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{filteredProjects.map((assignment, index) => {
|
||||
const project = assignment.project
|
||||
const teamLead = project.teamMembers?.find(
|
||||
(m) => m.role === 'LEAD'
|
||||
)
|
||||
const badge = completionBadge[assignment.completionStatus] || completionBadge.in_progress
|
||||
|
||||
return (
|
||||
<AnimatedCard key={assignment.id} index={index}>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{project.program?.year} Edition
|
||||
</span>
|
||||
{project.program && (
|
||||
<>
|
||||
<span>-</span>
|
||||
<span>{project.program.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2 flex-wrap">
|
||||
{project.title}
|
||||
{project.status && (
|
||||
<Badge
|
||||
variant={statusColors[project.status] || 'secondary'}
|
||||
>
|
||||
{project.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={badge.variant}>
|
||||
{assignment.completionStatus === 'completed' && (
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{assignment.completionStatus === 'in_progress' && (
|
||||
<Circle className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{assignment.completionStatus === 'paused' && (
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{badge.label}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
{project.teamName && (
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/mentor/projects/${project.id}` as Route}>
|
||||
View Details
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Category badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<GraduationCap className="h-3 w-3" />
|
||||
{project.competitionCategory === 'STARTUP'
|
||||
? 'Start-up'
|
||||
: 'Business Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
{project.oceanIssue && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Waves className="h-3 w-3" />
|
||||
{project.oceanIssue.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{project.country}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description preview */}
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Team Lead Info */}
|
||||
{teamLead && (
|
||||
<div className="flex items-center gap-3 pt-2 border-t">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">
|
||||
<Crown className="h-4 w-4 text-yellow-500" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{teamLead.user.name || 'Unnamed'}{' '}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
(Team Lead)
|
||||
</span>
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:${teamLead.user.email}`}
|
||||
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
<Mail className="h-3 w-3" />
|
||||
{teamLead.user.email}
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{project.teamMembers?.length || 0} team member
|
||||
{(project.teamMembers?.length || 0) !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignment date + last viewed */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Assigned {formatDateOnly(assignment.assignedAt)}</span>
|
||||
{assignment.lastViewedAt && (
|
||||
<span>Last viewed {formatDateOnly(assignment.lastViewedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Users,
|
||||
Briefcase,
|
||||
ArrowRight,
|
||||
Mail,
|
||||
MapPin,
|
||||
GraduationCap,
|
||||
Waves,
|
||||
Crown,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
// Status badge colors
|
||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
SUBMITTED: 'secondary',
|
||||
ELIGIBLE: 'default',
|
||||
ASSIGNED: 'default',
|
||||
SEMIFINALIST: 'default',
|
||||
FINALIST: 'default',
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
// Completion status display
|
||||
const completionBadge: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
|
||||
in_progress: { label: 'In Progress', variant: 'secondary' },
|
||||
completed: { label: 'Completed', variant: 'default' },
|
||||
paused: { label: 'Paused', variant: 'outline' },
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64 mt-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="grid gap-4">
|
||||
<Skeleton className="h-40" />
|
||||
<Skeleton className="h-40" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MentorDashboard() {
|
||||
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
|
||||
const projects = assignments || []
|
||||
const completedCount = projects.filter((a) => a.completionStatus === 'completed').length
|
||||
const inProgressCount = projects.filter((a) => a.completionStatus === 'in_progress').length
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
return projects.filter(a => {
|
||||
const matchesSearch = !search ||
|
||||
a.project.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
a.project.teamName?.toLowerCase().includes(search.toLowerCase())
|
||||
const matchesStatus = statusFilter === 'all' || a.completionStatus === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
}, [projects, search, statusFilter])
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Mentor Dashboard
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage your assigned mentee projects
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Assigned Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{projects.length}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Projects you are mentoring</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<Briefcase className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Completed</p>
|
||||
<p className="text-2xl font-bold mt-1">{completedCount}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{projects.length > 0 && (
|
||||
<Progress
|
||||
value={(completedCount / projects.length) * 100}
|
||||
className="h-1.5 flex-1"
|
||||
gradient
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{projects.length > 0 ? Math.round((completedCount / projects.length) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Team Members</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{projects.reduce(
|
||||
(acc, a) => acc + (a.project.teamMembers?.length || 0),
|
||||
0
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Across all assigned projects</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
<Users className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={'/mentor/projects' as Route}>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Messages
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Projects List */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4">Your Mentees</h2>
|
||||
|
||||
{/* Search and Filter */}
|
||||
{projects.length > 0 && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search projects..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-4">
|
||||
<Users className="h-8 w-8 text-brand-teal" />
|
||||
</div>
|
||||
<p className="mt-4 font-medium">No assigned projects yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You will see your mentee projects here once they are assigned to
|
||||
you.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No projects match your search criteria
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => { setSearch(''); setStatusFilter('all') }}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{filteredProjects.map((assignment, index) => {
|
||||
const project = assignment.project
|
||||
const teamLead = project.teamMembers?.find(
|
||||
(m) => m.role === 'LEAD'
|
||||
)
|
||||
const badge = completionBadge[assignment.completionStatus] || completionBadge.in_progress
|
||||
|
||||
return (
|
||||
<AnimatedCard key={assignment.id} index={index}>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{project.program?.year} Edition
|
||||
</span>
|
||||
{project.program && (
|
||||
<>
|
||||
<span>-</span>
|
||||
<span>{project.program.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2 flex-wrap">
|
||||
{project.title}
|
||||
{project.status && (
|
||||
<Badge
|
||||
variant={statusColors[project.status] || 'secondary'}
|
||||
>
|
||||
{project.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={badge.variant}>
|
||||
{assignment.completionStatus === 'completed' && (
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{assignment.completionStatus === 'in_progress' && (
|
||||
<Circle className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{assignment.completionStatus === 'paused' && (
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{badge.label}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
{project.teamName && (
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/mentor/projects/${project.id}` as Route}>
|
||||
View Details
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Category badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<GraduationCap className="h-3 w-3" />
|
||||
{project.competitionCategory === 'STARTUP'
|
||||
? 'Start-up'
|
||||
: 'Business Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
{project.oceanIssue && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Waves className="h-3 w-3" />
|
||||
{project.oceanIssue.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{project.country}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description preview */}
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Team Lead Info */}
|
||||
{teamLead && (
|
||||
<div className="flex items-center gap-3 pt-2 border-t">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">
|
||||
<Crown className="h-4 w-4 text-yellow-500" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{teamLead.user.name || 'Unnamed'}{' '}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
(Team Lead)
|
||||
</span>
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:${teamLead.user.email}`}
|
||||
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
<Mail className="h-3 w-3" />
|
||||
{teamLead.user.email}
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{project.teamMembers?.length || 0} team member
|
||||
{(project.teamMembers?.length || 0) !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignment date + last viewed */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Assigned {formatDateOnly(assignment.assignedAt)}</span>
|
||||
{assignment.lastViewedAt && (
|
||||
<span>Last viewed {formatDateOnly(assignment.lastViewedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,198 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Users,
|
||||
ArrowRight,
|
||||
Mail,
|
||||
MapPin,
|
||||
GraduationCap,
|
||||
Waves,
|
||||
Crown,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
// Status badge colors
|
||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
SUBMITTED: 'secondary',
|
||||
ELIGIBLE: 'default',
|
||||
ASSIGNED: 'default',
|
||||
SEMIFINALIST: 'default',
|
||||
FINALIST: 'default',
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
function ProjectsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-4 w-48 mt-2" />
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<Skeleton className="h-48" />
|
||||
<Skeleton className="h-48" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MentorProjectsPage() {
|
||||
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
|
||||
|
||||
if (isLoading) {
|
||||
return <ProjectsSkeleton />
|
||||
}
|
||||
|
||||
const projects = assignments || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">My Mentees</h1>
|
||||
<p className="text-muted-foreground">
|
||||
All projects assigned to you for mentorship
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Projects List */}
|
||||
{projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Users className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-4 font-medium">No assigned projects yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You will see your mentee projects here once they are assigned to you.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{projects.map((assignment) => {
|
||||
const project = assignment.project
|
||||
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
|
||||
|
||||
return (
|
||||
<Card key={assignment.id}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{project.program?.year} Edition
|
||||
</span>
|
||||
{project.program && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{project.program.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{project.title}
|
||||
{project.status && (
|
||||
<Badge variant={statusColors[project.status] || 'secondary'}>
|
||||
{project.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{project.teamName && (
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/mentor/projects/${project.id}` as Route}>
|
||||
View Details
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Category badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<GraduationCap className="h-3 w-3" />
|
||||
{project.competitionCategory === 'STARTUP'
|
||||
? 'Start-up'
|
||||
: 'Business Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
{project.oceanIssue && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Waves className="h-3 w-3" />
|
||||
{project.oceanIssue.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{project.country}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description preview */}
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Team Lead Info */}
|
||||
{teamLead && (
|
||||
<div className="flex items-center gap-3 pt-2 border-t">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-yellow-100">
|
||||
<Crown className="h-4 w-4 text-yellow-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{teamLead.user.name || 'Unnamed'}{' '}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
(Team Lead)
|
||||
</span>
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:${teamLead.user.email}`}
|
||||
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
<Mail className="h-3 w-3" />
|
||||
{teamLead.user.email}
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{project.teamMembers?.length || 0} team member
|
||||
{(project.teamMembers?.length || 0) !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignment date */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Assigned {formatDateOnly(assignment.assignedAt)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Users,
|
||||
ArrowRight,
|
||||
Mail,
|
||||
MapPin,
|
||||
GraduationCap,
|
||||
Waves,
|
||||
Crown,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
// Status badge colors
|
||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
SUBMITTED: 'secondary',
|
||||
ELIGIBLE: 'default',
|
||||
ASSIGNED: 'default',
|
||||
SEMIFINALIST: 'default',
|
||||
FINALIST: 'default',
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
function ProjectsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-4 w-48 mt-2" />
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<Skeleton className="h-48" />
|
||||
<Skeleton className="h-48" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MentorProjectsPage() {
|
||||
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
|
||||
|
||||
if (isLoading) {
|
||||
return <ProjectsSkeleton />
|
||||
}
|
||||
|
||||
const projects = assignments || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">My Mentees</h1>
|
||||
<p className="text-muted-foreground">
|
||||
All projects assigned to you for mentorship
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Projects List */}
|
||||
{projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Users className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-4 font-medium">No assigned projects yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You will see your mentee projects here once they are assigned to you.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{projects.map((assignment) => {
|
||||
const project = assignment.project
|
||||
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
|
||||
|
||||
return (
|
||||
<Card key={assignment.id}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{project.program?.year} Edition
|
||||
</span>
|
||||
{project.program && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{project.program.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{project.title}
|
||||
{project.status && (
|
||||
<Badge variant={statusColors[project.status] || 'secondary'}>
|
||||
{project.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{project.teamName && (
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/mentor/projects/${project.id}` as Route}>
|
||||
View Details
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Category badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<GraduationCap className="h-3 w-3" />
|
||||
{project.competitionCategory === 'STARTUP'
|
||||
? 'Start-up'
|
||||
: 'Business Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
{project.oceanIssue && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Waves className="h-3 w-3" />
|
||||
{project.oceanIssue.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
{project.country && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{project.country}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description preview */}
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Team Lead Info */}
|
||||
{teamLead && (
|
||||
<div className="flex items-center gap-3 pt-2 border-t">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-yellow-100">
|
||||
<Crown className="h-4 w-4 text-yellow-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{teamLead.user.name || 'Unnamed'}{' '}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
(Team Lead)
|
||||
</span>
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:${teamLead.user.email}`}
|
||||
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
<Mail className="h-3 w-3" />
|
||||
{teamLead.user.email}
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{project.teamMembers?.length || 0} team member
|
||||
{(project.teamMembers?.length || 0) !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignment date */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Assigned {formatDateOnly(assignment.assignedAt)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, Eye } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function ObserverError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Observer section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('observer')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to the observer dashboard.'}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/observer">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, Eye } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function ObserverError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Observer section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('observer')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to the observer dashboard.'}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/observer">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { ObserverDashboardContent } from '@/components/observer/observer-dashboard-content'
|
||||
|
||||
export const metadata: Metadata = { title: 'Observer Dashboard' }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ObserverDashboardPage() {
|
||||
const session = await auth()
|
||||
|
||||
return <ObserverDashboardContent userName={session?.user?.name || undefined} />
|
||||
}
|
||||
import type { Metadata } from 'next'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { ObserverDashboardContent } from '@/components/observer/observer-dashboard-content'
|
||||
|
||||
export const metadata: Metadata = { title: 'Observer Dashboard' }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ObserverDashboardPage() {
|
||||
const session = await auth()
|
||||
|
||||
return <ObserverDashboardContent userName={session?.user?.name || undefined} />
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,65 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
|
||||
import { Loader2, AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function StageApplyPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const slug = params.slug as string
|
||||
|
||||
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
|
||||
{ slug, mode: 'stage' },
|
||||
{ retry: false }
|
||||
)
|
||||
|
||||
const submitMutation = trpc.application.submit.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !config || config.mode !== 'stage') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
|
||||
<p className="text-muted-foreground mb-6">{error?.message ?? 'Not found'}</p>
|
||||
<Button variant="outline" onClick={() => router.push('/')}>Return Home</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ApplyWizardDynamic
|
||||
mode="stage"
|
||||
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
|
||||
programName={config.program.name}
|
||||
programYear={config.program.year}
|
||||
stageId={config.stage.id}
|
||||
isOpen={config.stage.isOpen}
|
||||
submissionDeadline={config.stage.submissionEndDate}
|
||||
onSubmit={async (data) => {
|
||||
await submitMutation.mutateAsync({
|
||||
mode: 'stage',
|
||||
stageId: config.stage.id,
|
||||
data: data as any,
|
||||
})
|
||||
}}
|
||||
isSubmitting={submitMutation.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
|
||||
import { Loader2, AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function StageApplyPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const slug = params.slug as string
|
||||
|
||||
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
|
||||
{ slug, mode: 'stage' },
|
||||
{ retry: false }
|
||||
)
|
||||
|
||||
const submitMutation = trpc.application.submit.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !config || config.mode !== 'stage') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
|
||||
<p className="text-muted-foreground mb-6">{error?.message ?? 'Not found'}</p>
|
||||
<Button variant="outline" onClick={() => router.push('/')}>Return Home</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ApplyWizardDynamic
|
||||
mode="stage"
|
||||
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
|
||||
programName={config.program.name}
|
||||
programYear={config.program.year}
|
||||
stageId={config.stage.id}
|
||||
isOpen={config.stage.isOpen}
|
||||
submissionDeadline={config.stage.submissionEndDate}
|
||||
onSubmit={async (data) => {
|
||||
await submitMutation.mutateAsync({
|
||||
mode: 'stage',
|
||||
stageId: config.stage.id,
|
||||
data: data as any,
|
||||
})
|
||||
}}
|
||||
isSubmitting={submitMutation.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
|
||||
import { Loader2, AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function EditionApplyPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const programSlug = params.programSlug as string
|
||||
|
||||
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
|
||||
{ slug: programSlug, mode: 'edition' },
|
||||
{ retry: false }
|
||||
)
|
||||
|
||||
const submitMutation = trpc.application.submit.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !config || config.mode !== 'edition') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
|
||||
<p className="text-muted-foreground mb-6">{error?.message ?? 'Not found'}</p>
|
||||
<Button variant="outline" onClick={() => router.push('/')}>Return Home</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ApplyWizardDynamic
|
||||
mode="edition"
|
||||
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
|
||||
programName={config.program.name}
|
||||
programYear={config.program.year}
|
||||
programId={config.program.id}
|
||||
isOpen={config.program.isOpen}
|
||||
submissionDeadline={config.program.submissionEndDate}
|
||||
onSubmit={async (data) => {
|
||||
await submitMutation.mutateAsync({
|
||||
mode: 'edition',
|
||||
programId: config.program.id,
|
||||
data: data as any,
|
||||
})
|
||||
}}
|
||||
isSubmitting={submitMutation.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
|
||||
import { Loader2, AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function EditionApplyPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const programSlug = params.programSlug as string
|
||||
|
||||
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
|
||||
{ slug: programSlug, mode: 'edition' },
|
||||
{ retry: false }
|
||||
)
|
||||
|
||||
const submitMutation = trpc.application.submit.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !config || config.mode !== 'edition') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
|
||||
<p className="text-muted-foreground mb-6">{error?.message ?? 'Not found'}</p>
|
||||
<Button variant="outline" onClick={() => router.push('/')}>Return Home</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ApplyWizardDynamic
|
||||
mode="edition"
|
||||
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
|
||||
programName={config.program.name}
|
||||
programYear={config.program.year}
|
||||
programId={config.program.id}
|
||||
isOpen={config.program.isOpen}
|
||||
submissionDeadline={config.program.submissionEndDate}
|
||||
onSubmit={async (data) => {
|
||||
await submitMutation.mutateAsync({
|
||||
mode: 'edition',
|
||||
programId: config.program.id,
|
||||
data: data as any,
|
||||
})
|
||||
}}
|
||||
isSubmitting={submitMutation.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,386 +1,386 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Mail, Lock, Monitor, Smartphone } from 'lucide-react'
|
||||
|
||||
type Step = 'verify' | 'change' | 'success'
|
||||
|
||||
const MAIL_DOMAIN = 'monaco-opc.com'
|
||||
const MAIL_SERVER = 'mail.monaco-opc.com'
|
||||
|
||||
export default function ChangeEmailPasswordPage() {
|
||||
const [step, setStep] = useState<Step>('verify')
|
||||
const [email, setEmail] = useState('')
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
|
||||
const [showNewPassword, setShowNewPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleVerify(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
setError(`Email must be an @${MAIL_DOMAIN} address.`)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/email/verify-credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailLower, password: currentPassword }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (res.status === 429) {
|
||||
setError(data.error || 'Too many attempts. Please try again later.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.valid) {
|
||||
setError(data.error || 'Invalid email or password.')
|
||||
return
|
||||
}
|
||||
|
||||
setStep('change')
|
||||
} catch {
|
||||
setError('Connection error. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(newPassword)) {
|
||||
setError('Password must contain at least one uppercase letter, one lowercase letter, and one number.')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/email/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: email.toLowerCase().trim(),
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to change password.')
|
||||
return
|
||||
}
|
||||
|
||||
setStep('success')
|
||||
} catch {
|
||||
setError('Connection error. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-lg">
|
||||
<div className="mb-8 text-center">
|
||||
<Mail className="mx-auto h-12 w-12 text-[#053d57] mb-4" />
|
||||
<h1 className="text-heading font-semibold text-[#053d57]">Email Account</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Change your @{MAIL_DOMAIN} email password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{step === 'verify' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verify Your Identity</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email address and current password to continue.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleVerify} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={`yourname@${MAIL_DOMAIN}`}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">Current Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="current-password"
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showCurrentPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === 'change' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Set New Password</CardTitle>
|
||||
<CardDescription>
|
||||
Choose a new password for <strong>{email.toLowerCase().trim()}</strong>.
|
||||
Must be at least 8 characters with uppercase, lowercase, and a number.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="new-password"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep('verify')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setError('')
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Changing Password...
|
||||
</>
|
||||
) : (
|
||||
'Change Password'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<CheckCircle2 className="h-12 w-12 text-green-600" />
|
||||
<h2 className="text-xl font-semibold">Password Changed Successfully</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Your password for <strong>{email.toLowerCase().trim()}</strong> has been updated.
|
||||
Use your new password to sign in to your email.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5" />
|
||||
Mail Client Setup
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Use these settings to add your email account to any mail app.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wide text-muted-foreground">
|
||||
Incoming Mail (IMAP)
|
||||
</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Server</dt>
|
||||
<dd className="font-mono font-medium">{MAIL_SERVER}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Port</dt>
|
||||
<dd className="font-mono font-medium">993</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Security</dt>
|
||||
<dd className="font-mono font-medium">SSL/TLS</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Username</dt>
|
||||
<dd className="font-mono font-medium text-xs break-all">{email.toLowerCase().trim()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wide text-muted-foreground">
|
||||
Outgoing Mail (SMTP)
|
||||
</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Server</dt>
|
||||
<dd className="font-mono font-medium">{MAIL_SERVER}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Port</dt>
|
||||
<dd className="font-mono font-medium">587</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Security</dt>
|
||||
<dd className="font-mono font-medium">STARTTLS</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Username</dt>
|
||||
<dd className="font-mono font-medium text-xs break-all">{email.toLowerCase().trim()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
<Smartphone className="h-4 w-4" />
|
||||
Mobile Apps
|
||||
</h3>
|
||||
<ul className="text-sm space-y-1 text-muted-foreground">
|
||||
<li><strong>iPhone/iPad:</strong> Settings > Mail > Accounts > Add Account > Other</li>
|
||||
<li><strong>Gmail App:</strong> Settings > Add Account > Other</li>
|
||||
<li><strong>Outlook App:</strong> Settings > Add Email Account</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4" />
|
||||
Desktop Apps
|
||||
</h3>
|
||||
<ul className="text-sm space-y-1 text-muted-foreground">
|
||||
<li><strong>Apple Mail:</strong> Mail > Add Account > Other Mail Account</li>
|
||||
<li><strong>Outlook:</strong> File > Add Account</li>
|
||||
<li><strong>Thunderbird:</strong> Account Settings > Account Actions > Add Mail Account</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Mail, Lock, Monitor, Smartphone } from 'lucide-react'
|
||||
|
||||
type Step = 'verify' | 'change' | 'success'
|
||||
|
||||
const MAIL_DOMAIN = 'monaco-opc.com'
|
||||
const MAIL_SERVER = 'mail.monaco-opc.com'
|
||||
|
||||
export default function ChangeEmailPasswordPage() {
|
||||
const [step, setStep] = useState<Step>('verify')
|
||||
const [email, setEmail] = useState('')
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
|
||||
const [showNewPassword, setShowNewPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleVerify(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
setError(`Email must be an @${MAIL_DOMAIN} address.`)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/email/verify-credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailLower, password: currentPassword }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (res.status === 429) {
|
||||
setError(data.error || 'Too many attempts. Please try again later.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.valid) {
|
||||
setError(data.error || 'Invalid email or password.')
|
||||
return
|
||||
}
|
||||
|
||||
setStep('change')
|
||||
} catch {
|
||||
setError('Connection error. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(newPassword)) {
|
||||
setError('Password must contain at least one uppercase letter, one lowercase letter, and one number.')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/email/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: email.toLowerCase().trim(),
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Failed to change password.')
|
||||
return
|
||||
}
|
||||
|
||||
setStep('success')
|
||||
} catch {
|
||||
setError('Connection error. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-lg">
|
||||
<div className="mb-8 text-center">
|
||||
<Mail className="mx-auto h-12 w-12 text-[#053d57] mb-4" />
|
||||
<h1 className="text-heading font-semibold text-[#053d57]">Email Account</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Change your @{MAIL_DOMAIN} email password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{step === 'verify' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verify Your Identity</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email address and current password to continue.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleVerify} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={`yourname@${MAIL_DOMAIN}`}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">Current Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="current-password"
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showCurrentPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === 'change' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Set New Password</CardTitle>
|
||||
<CardDescription>
|
||||
Choose a new password for <strong>{email.toLowerCase().trim()}</strong>.
|
||||
Must be at least 8 characters with uppercase, lowercase, and a number.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="new-password"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep('verify')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setError('')
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Changing Password...
|
||||
</>
|
||||
) : (
|
||||
'Change Password'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
<CheckCircle2 className="h-12 w-12 text-green-600" />
|
||||
<h2 className="text-xl font-semibold">Password Changed Successfully</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Your password for <strong>{email.toLowerCase().trim()}</strong> has been updated.
|
||||
Use your new password to sign in to your email.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5" />
|
||||
Mail Client Setup
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Use these settings to add your email account to any mail app.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wide text-muted-foreground">
|
||||
Incoming Mail (IMAP)
|
||||
</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Server</dt>
|
||||
<dd className="font-mono font-medium">{MAIL_SERVER}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Port</dt>
|
||||
<dd className="font-mono font-medium">993</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Security</dt>
|
||||
<dd className="font-mono font-medium">SSL/TLS</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Username</dt>
|
||||
<dd className="font-mono font-medium text-xs break-all">{email.toLowerCase().trim()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h3 className="font-semibold text-sm uppercase tracking-wide text-muted-foreground">
|
||||
Outgoing Mail (SMTP)
|
||||
</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Server</dt>
|
||||
<dd className="font-mono font-medium">{MAIL_SERVER}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Port</dt>
|
||||
<dd className="font-mono font-medium">587</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Security</dt>
|
||||
<dd className="font-mono font-medium">STARTTLS</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Username</dt>
|
||||
<dd className="font-mono font-medium text-xs break-all">{email.toLowerCase().trim()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
<Smartphone className="h-4 w-4" />
|
||||
Mobile Apps
|
||||
</h3>
|
||||
<ul className="text-sm space-y-1 text-muted-foreground">
|
||||
<li><strong>iPhone/iPad:</strong> Settings > Mail > Accounts > Add Account > Other</li>
|
||||
<li><strong>Gmail App:</strong> Settings > Add Account > Other</li>
|
||||
<li><strong>Outlook App:</strong> Settings > Add Email Account</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4" />
|
||||
Desktop Apps
|
||||
</h3>
|
||||
<ul className="text-sm space-y-1 text-muted-foreground">
|
||||
<li><strong>Apple Mail:</strong> Mail > Add Account > Other Mail Account</li>
|
||||
<li><strong>Outlook:</strong> File > Add Account</li>
|
||||
<li><strong>Thunderbird:</strong> Account Settings > Account Actions > Add Mail Account</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function PublicError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Public section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('public')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to the home page.'}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
Home
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, Home } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function PublicError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Public section error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('public')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An error occurred while loading this page. Please try again or return to the home page.'}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
{isChunk ? (
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
Home
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isChunk && error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,329 +1,329 @@
|
||||
'use client'
|
||||
|
||||
import { use, useCallback, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Trophy, Star, Clock, AlertCircle, Zap, Wifi, WifiOff } from 'lucide-react'
|
||||
import { useLiveVotingSSE, type VoteUpdate } from '@/hooks/use-live-voting-sse'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}
|
||||
|
||||
interface PublicSession {
|
||||
id: string
|
||||
status: string
|
||||
currentProjectId: string | null
|
||||
votingEndsAt: string | null
|
||||
presentationSettings: Record<string, unknown> | null
|
||||
allowAudienceVotes: boolean
|
||||
}
|
||||
|
||||
interface PublicProject {
|
||||
id: string | undefined
|
||||
title: string | undefined
|
||||
teamName: string | null | undefined
|
||||
averageScore: number
|
||||
voteCount: number
|
||||
}
|
||||
|
||||
function PublicScoresContent({ sessionId }: { sessionId: string }) {
|
||||
// Track SSE-based score updates keyed by projectId
|
||||
const [liveScores, setLiveScores] = useState<Record<string, { avg: number; count: number }>>({})
|
||||
|
||||
// Use public (no-auth) endpoint with reduced polling since SSE handles real-time
|
||||
const { data, isLoading, refetch } = trpc.liveVoting.getPublicResults.useQuery(
|
||||
{ sessionId },
|
||||
{ refetchInterval: 10000 }
|
||||
)
|
||||
|
||||
// SSE for real-time updates
|
||||
const onVoteUpdate = useCallback((update: VoteUpdate) => {
|
||||
setLiveScores((prev) => ({
|
||||
...prev,
|
||||
[update.projectId]: {
|
||||
avg: update.averageScore ?? 0,
|
||||
count: update.totalVotes,
|
||||
},
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const onSessionStatus = useCallback(() => {
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const onProjectChange = useCallback(() => {
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const { isConnected } = useLiveVotingSSE(sessionId, {
|
||||
onVoteUpdate,
|
||||
onSessionStatus,
|
||||
onProjectChange,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <PublicScoresSkeleton />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Session Not Found</AlertTitle>
|
||||
<AlertDescription>
|
||||
This voting session does not exist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const session = data.session as PublicSession
|
||||
const projects = data.projects as PublicProject[]
|
||||
|
||||
const isCompleted = session.status === 'COMPLETED'
|
||||
const isVoting = session.status === 'IN_PROGRESS'
|
||||
|
||||
// Merge live SSE scores with fetched data
|
||||
const projectsWithLive = projects.map((project) => {
|
||||
const live = project.id ? liveScores[project.id] : null
|
||||
return {
|
||||
...project,
|
||||
averageScore: live ? live.avg : (project.averageScore || 0),
|
||||
voteCount: live ? live.count : (project.voteCount || 0),
|
||||
}
|
||||
})
|
||||
|
||||
// Sort projects by score for leaderboard
|
||||
const sortedProjects = [...projectsWithLive].sort(
|
||||
(a, b) => (b.averageScore || 0) - (a.averageScore || 0)
|
||||
)
|
||||
|
||||
// Find max score for progress bars
|
||||
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore || 0), 1)
|
||||
|
||||
// Get presentation settings
|
||||
const presentationSettings = session.presentationSettings
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#053d57] to-[#557f8c] p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center text-white">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Zap className="h-8 w-8" />
|
||||
<h1 className="text-3xl font-bold">Live Scores</h1>
|
||||
{isConnected ? (
|
||||
<Wifi className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<WifiOff className="h-4 w-4 text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={isVoting ? 'default' : isCompleted ? 'secondary' : 'outline'}
|
||||
className="mt-2"
|
||||
>
|
||||
{isVoting ? 'LIVE' : isCompleted ? 'COMPLETED' : session.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Current project highlight */}
|
||||
{isVoting && session.currentProjectId && (
|
||||
<Card className="border-2 border-green-500 bg-green-500/10 animate-pulse">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<Clock className="h-5 w-5" />
|
||||
<span className="font-medium">Now Voting</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xl font-semibold text-white">
|
||||
{projects.find((p) => p?.id === session.currentProjectId)?.title}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Leaderboard */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-yellow-500" />
|
||||
Rankings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedProjects.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
No scores yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sortedProjects.map((project, index) => {
|
||||
if (!project) return null
|
||||
const isCurrent = project.id === session.currentProjectId
|
||||
const scoreFormat = presentationSettings?.scoreDisplayFormat as string || 'bar'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className={`rounded-lg p-4 transition-all duration-300 ${
|
||||
isCurrent
|
||||
? 'bg-green-500/10 border border-green-500'
|
||||
: 'bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Rank */}
|
||||
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
{index === 0 ? (
|
||||
<Trophy className="h-4 w-4 text-yellow-500" />
|
||||
) : index === 1 ? (
|
||||
<span className="font-bold text-gray-400">2</span>
|
||||
) : index === 2 ? (
|
||||
<span className="font-bold text-amber-600">3</span>
|
||||
) : (
|
||||
<span className="font-bold text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{project.title}</p>
|
||||
{project.teamName && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="shrink-0 text-right">
|
||||
{scoreFormat === 'radial' ? (
|
||||
<div className="relative w-14 h-14">
|
||||
<svg viewBox="0 0 36 36" className="w-14 h-14 -rotate-90">
|
||||
<path
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-muted/30"
|
||||
/>
|
||||
<path
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeDasharray={`${((project.averageScore || 0) / 10) * 100}, 100`}
|
||||
className="text-primary"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
|
||||
{project.averageScore?.toFixed(1) || '--'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-xl font-bold">
|
||||
{project.averageScore?.toFixed(1) || '--'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.voteCount} votes
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score bar - shown for 'bar' format */}
|
||||
{scoreFormat !== 'number' && scoreFormat !== 'radial' && (
|
||||
<div className="mt-3">
|
||||
<Progress
|
||||
value={
|
||||
project.averageScore
|
||||
? (project.averageScore / maxScore) * 100
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audience voting info */}
|
||||
{session.allowAudienceVotes && isVoting && (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
Audience voting is enabled for this session
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-green-400" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3 text-red-400" />
|
||||
)}
|
||||
<p className="text-center text-white/60 text-sm">
|
||||
Scores update in real-time
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PublicScoresSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#053d57] to-[#557f8c] p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="text-center">
|
||||
<Skeleton className="h-10 w-48 mx-auto" />
|
||||
<Skeleton className="h-4 w-64 mx-auto mt-2" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PublicScoresPage({ params }: PageProps) {
|
||||
const { sessionId } = use(params)
|
||||
|
||||
return <PublicScoresContent sessionId={sessionId} />
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useCallback, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Trophy, Star, Clock, AlertCircle, Zap, Wifi, WifiOff } from 'lucide-react'
|
||||
import { useLiveVotingSSE, type VoteUpdate } from '@/hooks/use-live-voting-sse'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}
|
||||
|
||||
interface PublicSession {
|
||||
id: string
|
||||
status: string
|
||||
currentProjectId: string | null
|
||||
votingEndsAt: string | null
|
||||
presentationSettings: Record<string, unknown> | null
|
||||
allowAudienceVotes: boolean
|
||||
}
|
||||
|
||||
interface PublicProject {
|
||||
id: string | undefined
|
||||
title: string | undefined
|
||||
teamName: string | null | undefined
|
||||
averageScore: number
|
||||
voteCount: number
|
||||
}
|
||||
|
||||
function PublicScoresContent({ sessionId }: { sessionId: string }) {
|
||||
// Track SSE-based score updates keyed by projectId
|
||||
const [liveScores, setLiveScores] = useState<Record<string, { avg: number; count: number }>>({})
|
||||
|
||||
// Use public (no-auth) endpoint with reduced polling since SSE handles real-time
|
||||
const { data, isLoading, refetch } = trpc.liveVoting.getPublicResults.useQuery(
|
||||
{ sessionId },
|
||||
{ refetchInterval: 10000 }
|
||||
)
|
||||
|
||||
// SSE for real-time updates
|
||||
const onVoteUpdate = useCallback((update: VoteUpdate) => {
|
||||
setLiveScores((prev) => ({
|
||||
...prev,
|
||||
[update.projectId]: {
|
||||
avg: update.averageScore ?? 0,
|
||||
count: update.totalVotes,
|
||||
},
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const onSessionStatus = useCallback(() => {
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const onProjectChange = useCallback(() => {
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const { isConnected } = useLiveVotingSSE(sessionId, {
|
||||
onVoteUpdate,
|
||||
onSessionStatus,
|
||||
onProjectChange,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <PublicScoresSkeleton />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Session Not Found</AlertTitle>
|
||||
<AlertDescription>
|
||||
This voting session does not exist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const session = data.session as PublicSession
|
||||
const projects = data.projects as PublicProject[]
|
||||
|
||||
const isCompleted = session.status === 'COMPLETED'
|
||||
const isVoting = session.status === 'IN_PROGRESS'
|
||||
|
||||
// Merge live SSE scores with fetched data
|
||||
const projectsWithLive = projects.map((project) => {
|
||||
const live = project.id ? liveScores[project.id] : null
|
||||
return {
|
||||
...project,
|
||||
averageScore: live ? live.avg : (project.averageScore || 0),
|
||||
voteCount: live ? live.count : (project.voteCount || 0),
|
||||
}
|
||||
})
|
||||
|
||||
// Sort projects by score for leaderboard
|
||||
const sortedProjects = [...projectsWithLive].sort(
|
||||
(a, b) => (b.averageScore || 0) - (a.averageScore || 0)
|
||||
)
|
||||
|
||||
// Find max score for progress bars
|
||||
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore || 0), 1)
|
||||
|
||||
// Get presentation settings
|
||||
const presentationSettings = session.presentationSettings
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#053d57] to-[#557f8c] p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center text-white">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Zap className="h-8 w-8" />
|
||||
<h1 className="text-3xl font-bold">Live Scores</h1>
|
||||
{isConnected ? (
|
||||
<Wifi className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<WifiOff className="h-4 w-4 text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={isVoting ? 'default' : isCompleted ? 'secondary' : 'outline'}
|
||||
className="mt-2"
|
||||
>
|
||||
{isVoting ? 'LIVE' : isCompleted ? 'COMPLETED' : session.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Current project highlight */}
|
||||
{isVoting && session.currentProjectId && (
|
||||
<Card className="border-2 border-green-500 bg-green-500/10 animate-pulse">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<Clock className="h-5 w-5" />
|
||||
<span className="font-medium">Now Voting</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xl font-semibold text-white">
|
||||
{projects.find((p) => p?.id === session.currentProjectId)?.title}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Leaderboard */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-yellow-500" />
|
||||
Rankings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedProjects.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
No scores yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sortedProjects.map((project, index) => {
|
||||
if (!project) return null
|
||||
const isCurrent = project.id === session.currentProjectId
|
||||
const scoreFormat = presentationSettings?.scoreDisplayFormat as string || 'bar'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className={`rounded-lg p-4 transition-all duration-300 ${
|
||||
isCurrent
|
||||
? 'bg-green-500/10 border border-green-500'
|
||||
: 'bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Rank */}
|
||||
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
{index === 0 ? (
|
||||
<Trophy className="h-4 w-4 text-yellow-500" />
|
||||
) : index === 1 ? (
|
||||
<span className="font-bold text-gray-400">2</span>
|
||||
) : index === 2 ? (
|
||||
<span className="font-bold text-amber-600">3</span>
|
||||
) : (
|
||||
<span className="font-bold text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{project.title}</p>
|
||||
{project.teamName && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="shrink-0 text-right">
|
||||
{scoreFormat === 'radial' ? (
|
||||
<div className="relative w-14 h-14">
|
||||
<svg viewBox="0 0 36 36" className="w-14 h-14 -rotate-90">
|
||||
<path
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-muted/30"
|
||||
/>
|
||||
<path
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeDasharray={`${((project.averageScore || 0) / 10) * 100}, 100`}
|
||||
className="text-primary"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
|
||||
{project.averageScore?.toFixed(1) || '--'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-xl font-bold">
|
||||
{project.averageScore?.toFixed(1) || '--'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.voteCount} votes
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score bar - shown for 'bar' format */}
|
||||
{scoreFormat !== 'number' && scoreFormat !== 'radial' && (
|
||||
<div className="mt-3">
|
||||
<Progress
|
||||
value={
|
||||
project.averageScore
|
||||
? (project.averageScore / maxScore) * 100
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audience voting info */}
|
||||
{session.allowAudienceVotes && isVoting && (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardContent className="py-4 text-center">
|
||||
<p className="text-sm font-medium">
|
||||
Audience voting is enabled for this session
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-green-400" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3 text-red-400" />
|
||||
)}
|
||||
<p className="text-center text-white/60 text-sm">
|
||||
Scores update in real-time
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PublicScoresSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#053d57] to-[#557f8c] p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="text-center">
|
||||
<Skeleton className="h-10 w-48 mx-auto" />
|
||||
<Skeleton className="h-4 w-64 mx-auto mt-2" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PublicScoresPage({ params }: PageProps) {
|
||||
const { sessionId } = use(params)
|
||||
|
||||
return <PublicScoresContent sessionId={sessionId} />
|
||||
}
|
||||
|
||||
@@ -1,267 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useCallback } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Trophy,
|
||||
Star,
|
||||
RefreshCw,
|
||||
Waves,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
export default function StageScoreboardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}) {
|
||||
const { sessionId } = use(params)
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
// Fetch audience context for stage info and cohort data
|
||||
const { data: context } = trpc.live.getAudienceContext.useQuery(
|
||||
{ sessionId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
|
||||
const stageInfo = context?.stageInfo
|
||||
|
||||
// Fetch scores by querying cohort projects + their votes
|
||||
// We use getAudienceContext.openCohorts to get project IDs, then aggregate
|
||||
const openCohorts = context?.openCohorts ?? []
|
||||
const allProjectIds = openCohorts.flatMap(
|
||||
(c: { projectIds?: string[] }) => c.projectIds ?? []
|
||||
)
|
||||
const uniqueProjectIds = [...new Set(allProjectIds)]
|
||||
|
||||
// For live scores, we poll the audience context and compute from the cursor data
|
||||
// The getAudienceContext returns projects with vote data when available
|
||||
const projectScores = (context as Record<string, unknown>)?.projectScores as
|
||||
| Array<{
|
||||
projectId: string
|
||||
title: string
|
||||
teamName?: string | null
|
||||
averageScore: number
|
||||
voteCount: number
|
||||
}>
|
||||
| undefined
|
||||
|
||||
// Sort projects by average score descending
|
||||
const sortedProjects = [...(projectScores ?? [])].sort(
|
||||
(a, b) => b.averageScore - a.averageScore
|
||||
)
|
||||
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore), 1)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/10 to-background">
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Waves className="h-10 w-10 text-brand-blue" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-brand-blue dark:text-brand-teal">
|
||||
MOPC Live Scores
|
||||
</h1>
|
||||
{stageInfo && (
|
||||
<p className="text-sm text-muted-foreground">{stageInfo.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Live
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="destructive">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paused state */}
|
||||
{isPaused && (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex items-center justify-center gap-3 py-6">
|
||||
<Pause className="h-8 w-8 text-amber-600" />
|
||||
<p className="text-lg font-semibold text-amber-700 dark:text-amber-300">
|
||||
Session Paused
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current project highlight */}
|
||||
{activeProject && !isPaused && (
|
||||
<Card className="overflow-hidden border-2 border-brand-blue/30 dark:border-brand-teal/30">
|
||||
<div className="h-1.5 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="flex items-center justify-center gap-2 text-brand-teal text-xs uppercase tracking-wide mb-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Now Presenting
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent className="text-center">
|
||||
<p className="text-sm">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Leaderboard / Rankings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-amber-500" />
|
||||
Rankings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedProjects.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-muted-foreground">
|
||||
{uniqueProjectIds.length === 0
|
||||
? 'Waiting for presentations to begin...'
|
||||
: 'No scores yet. Votes will appear here in real-time.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedProjects.map((project, index) => {
|
||||
const isCurrent = project.projectId === activeProject?.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.projectId}
|
||||
className={`rounded-lg p-4 transition-all duration-300 ${
|
||||
isCurrent
|
||||
? 'bg-brand-blue/5 border border-brand-blue/30 dark:bg-brand-teal/5 dark:border-brand-teal/30'
|
||||
: 'bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Rank */}
|
||||
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
{index === 0 ? (
|
||||
<Trophy className="h-4 w-4 text-amber-500" />
|
||||
) : index === 1 ? (
|
||||
<span className="font-bold text-gray-400">2</span>
|
||||
) : index === 2 ? (
|
||||
<span className="font-bold text-amber-600">3</span>
|
||||
) : (
|
||||
<span className="font-bold text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{project.title}</p>
|
||||
{project.teamName && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-xl font-bold tabular-nums">
|
||||
{project.averageScore?.toFixed(1) || '--'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.voteCount} vote{project.voteCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3">
|
||||
<Progress
|
||||
value={
|
||||
project.averageScore
|
||||
? (project.averageScore / maxScore) * 100
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Waiting state */}
|
||||
{!activeProject && !isPaused && sortedProjects.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Trophy className="h-16 w-16 text-amber-500/30 mb-4" />
|
||||
<p className="text-xl font-semibold">Waiting for presentations</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Scores will appear here as projects are presented.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* SSE error */}
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3 text-sm text-destructive">
|
||||
{sseError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Monaco Ocean Protection Challenge · Live Scoreboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState, useCallback } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Trophy,
|
||||
Star,
|
||||
RefreshCw,
|
||||
Waves,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
export default function StageScoreboardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}) {
|
||||
const { sessionId } = use(params)
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
// Fetch audience context for stage info and cohort data
|
||||
const { data: context } = trpc.live.getAudienceContext.useQuery(
|
||||
{ sessionId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
|
||||
const stageInfo = context?.stageInfo
|
||||
|
||||
// Fetch scores by querying cohort projects + their votes
|
||||
// We use getAudienceContext.openCohorts to get project IDs, then aggregate
|
||||
const openCohorts = context?.openCohorts ?? []
|
||||
const allProjectIds = openCohorts.flatMap(
|
||||
(c: { projectIds?: string[] }) => c.projectIds ?? []
|
||||
)
|
||||
const uniqueProjectIds = [...new Set(allProjectIds)]
|
||||
|
||||
// For live scores, we poll the audience context and compute from the cursor data
|
||||
// The getAudienceContext returns projects with vote data when available
|
||||
const projectScores = (context as Record<string, unknown>)?.projectScores as
|
||||
| Array<{
|
||||
projectId: string
|
||||
title: string
|
||||
teamName?: string | null
|
||||
averageScore: number
|
||||
voteCount: number
|
||||
}>
|
||||
| undefined
|
||||
|
||||
// Sort projects by average score descending
|
||||
const sortedProjects = [...(projectScores ?? [])].sort(
|
||||
(a, b) => b.averageScore - a.averageScore
|
||||
)
|
||||
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore), 1)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/10 to-background">
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Waves className="h-10 w-10 text-brand-blue" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-brand-blue dark:text-brand-teal">
|
||||
MOPC Live Scores
|
||||
</h1>
|
||||
{stageInfo && (
|
||||
<p className="text-sm text-muted-foreground">{stageInfo.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Live
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="destructive">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paused state */}
|
||||
{isPaused && (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex items-center justify-center gap-3 py-6">
|
||||
<Pause className="h-8 w-8 text-amber-600" />
|
||||
<p className="text-lg font-semibold text-amber-700 dark:text-amber-300">
|
||||
Session Paused
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current project highlight */}
|
||||
{activeProject && !isPaused && (
|
||||
<Card className="overflow-hidden border-2 border-brand-blue/30 dark:border-brand-teal/30">
|
||||
<div className="h-1.5 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="flex items-center justify-center gap-2 text-brand-teal text-xs uppercase tracking-wide mb-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Now Presenting
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent className="text-center">
|
||||
<p className="text-sm">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Leaderboard / Rankings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-amber-500" />
|
||||
Rankings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedProjects.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-muted-foreground">
|
||||
{uniqueProjectIds.length === 0
|
||||
? 'Waiting for presentations to begin...'
|
||||
: 'No scores yet. Votes will appear here in real-time.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sortedProjects.map((project, index) => {
|
||||
const isCurrent = project.projectId === activeProject?.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.projectId}
|
||||
className={`rounded-lg p-4 transition-all duration-300 ${
|
||||
isCurrent
|
||||
? 'bg-brand-blue/5 border border-brand-blue/30 dark:bg-brand-teal/5 dark:border-brand-teal/30'
|
||||
: 'bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Rank */}
|
||||
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
{index === 0 ? (
|
||||
<Trophy className="h-4 w-4 text-amber-500" />
|
||||
) : index === 1 ? (
|
||||
<span className="font-bold text-gray-400">2</span>
|
||||
) : index === 2 ? (
|
||||
<span className="font-bold text-amber-600">3</span>
|
||||
) : (
|
||||
<span className="font-bold text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{project.title}</p>
|
||||
{project.teamName && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{project.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-xl font-bold tabular-nums">
|
||||
{project.averageScore?.toFixed(1) || '--'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.voteCount} vote{project.voteCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3">
|
||||
<Progress
|
||||
value={
|
||||
project.averageScore
|
||||
? (project.averageScore / maxScore) * 100
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Waiting state */}
|
||||
{!activeProject && !isPaused && sortedProjects.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Trophy className="h-16 w-16 text-amber-500/30 mb-4" />
|
||||
<p className="text-xl font-semibold">Waiting for presentations</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Scores will appear here as projects are presented.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* SSE error */}
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3 text-sm text-destructive">
|
||||
{sseError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Monaco Ocean Protection Challenge · Live Scoreboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,404 +1,404 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Download,
|
||||
Video,
|
||||
File,
|
||||
Users,
|
||||
Crown,
|
||||
MessageSquare,
|
||||
} from 'lucide-react'
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||
DRAFT: 'secondary',
|
||||
SUBMITTED: 'default',
|
||||
UNDER_REVIEW: 'default',
|
||||
ELIGIBLE: 'default',
|
||||
SEMIFINALIST: 'success',
|
||||
FINALIST: 'success',
|
||||
WINNER: 'success',
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
const fileTypeIcons: Record<string, typeof FileText> = {
|
||||
EXEC_SUMMARY: FileText,
|
||||
BUSINESS_PLAN: FileText,
|
||||
PRESENTATION: FileText,
|
||||
VIDEO_PITCH: Video,
|
||||
VIDEO: Video,
|
||||
OTHER: File,
|
||||
SUPPORTING_DOC: File,
|
||||
}
|
||||
|
||||
const fileTypeLabels: Record<string, string> = {
|
||||
EXEC_SUMMARY: 'Executive Summary',
|
||||
BUSINESS_PLAN: 'Business Plan',
|
||||
PRESENTATION: 'Presentation',
|
||||
VIDEO_PITCH: 'Video Pitch',
|
||||
VIDEO: 'Video',
|
||||
OTHER: 'Other Document',
|
||||
SUPPORTING_DOC: 'Supporting Document',
|
||||
}
|
||||
|
||||
export function SubmissionDetailClient() {
|
||||
const params = useParams()
|
||||
const { data: session } = useSession()
|
||||
const projectId = params.id as string
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
const { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: !!session?.user }
|
||||
)
|
||||
|
||||
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: !!session?.user && activeTab === 'mentor' }
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.applicant.getMentorMessages.invalidate({ projectId })
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !statusData) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
{error?.message || 'Submission not found'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/my-submission">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to My Submissions
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { project, timeline, currentStatus } = statusData
|
||||
const isDraft = !project.submittedAt
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/my-submission">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to My Submissions
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
|
||||
<Badge variant={statusColors[currentStatus] || 'secondary'}>
|
||||
{currentStatus.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{project.program?.year ? `${project.program.year} Edition` : ''}{project.program?.name ? ` - ${project.program.name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Draft warning */}
|
||||
{isDraft && (
|
||||
<Alert>
|
||||
<Clock className="h-4 w-4" />
|
||||
<AlertTitle>Draft Submission</AlertTitle>
|
||||
<AlertDescription>
|
||||
This submission has not been submitted yet. You can continue editing and submit when ready.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documents</TabsTrigger>
|
||||
<TabsTrigger value="mentor" className="gap-1.5">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Mentor
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Details Tab */}
|
||||
<TabsContent value="details">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Project details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{project.teamName && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
|
||||
<p>{project.teamName}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.description && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Description</p>
|
||||
<p className="whitespace-pre-wrap">{project.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Metadata */}
|
||||
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Additional Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-3">
|
||||
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-muted-foreground capitalize">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</dt>
|
||||
<dd className="text-sm">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status timeline */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusTracker
|
||||
timeline={timeline}
|
||||
currentStatus={currentStatus}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Dates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{project.submittedAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Submitted</span>
|
||||
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last Updated</span>
|
||||
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Members */}
|
||||
{'teamMembers' in project && project.teamMembers && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Team
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/my-submission/${projectId}/team` as Route}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
|
||||
{member.role === 'LEAD' ? (
|
||||
<Crown className="h-4 w-4 text-yellow-500" />
|
||||
) : (
|
||||
<span className="text-xs font-medium">
|
||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{member.user.name || member.user.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Documents Tab */}
|
||||
<TabsContent value="documents">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Uploaded Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Documents submitted with your application
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.files.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4">
|
||||
No documents uploaded
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{project.files.map((file) => {
|
||||
const Icon = fileTypeIcons[file.fileType] || File
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{file.fileName}</p>
|
||||
{fileRecord.isLate && (
|
||||
<Badge variant="warning" className="text-xs gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Submitted late
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fileTypeLabels[file.fileType] || file.fileType}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" disabled>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Mentor Tab */}
|
||||
<TabsContent value="mentor">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Mentor Communication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Chat with your assigned mentor
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MentorChat
|
||||
messages={mentorMessages || []}
|
||||
currentUserId={session?.user?.id || ''}
|
||||
onSendMessage={async (message) => {
|
||||
await sendMessage.mutateAsync({ projectId, message })
|
||||
}}
|
||||
isLoading={messagesLoading}
|
||||
isSending={sendMessage.isPending}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Download,
|
||||
Video,
|
||||
File,
|
||||
Users,
|
||||
Crown,
|
||||
MessageSquare,
|
||||
} from 'lucide-react'
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||
DRAFT: 'secondary',
|
||||
SUBMITTED: 'default',
|
||||
UNDER_REVIEW: 'default',
|
||||
ELIGIBLE: 'default',
|
||||
SEMIFINALIST: 'success',
|
||||
FINALIST: 'success',
|
||||
WINNER: 'success',
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
const fileTypeIcons: Record<string, typeof FileText> = {
|
||||
EXEC_SUMMARY: FileText,
|
||||
BUSINESS_PLAN: FileText,
|
||||
PRESENTATION: FileText,
|
||||
VIDEO_PITCH: Video,
|
||||
VIDEO: Video,
|
||||
OTHER: File,
|
||||
SUPPORTING_DOC: File,
|
||||
}
|
||||
|
||||
const fileTypeLabels: Record<string, string> = {
|
||||
EXEC_SUMMARY: 'Executive Summary',
|
||||
BUSINESS_PLAN: 'Business Plan',
|
||||
PRESENTATION: 'Presentation',
|
||||
VIDEO_PITCH: 'Video Pitch',
|
||||
VIDEO: 'Video',
|
||||
OTHER: 'Other Document',
|
||||
SUPPORTING_DOC: 'Supporting Document',
|
||||
}
|
||||
|
||||
export function SubmissionDetailClient() {
|
||||
const params = useParams()
|
||||
const { data: session } = useSession()
|
||||
const projectId = params.id as string
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
|
||||
const { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: !!session?.user }
|
||||
)
|
||||
|
||||
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: !!session?.user && activeTab === 'mentor' }
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.applicant.getMentorMessages.invalidate({ projectId })
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !statusData) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
{error?.message || 'Submission not found'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/my-submission">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to My Submissions
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { project, timeline, currentStatus } = statusData
|
||||
const isDraft = !project.submittedAt
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/my-submission">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to My Submissions
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
|
||||
<Badge variant={statusColors[currentStatus] || 'secondary'}>
|
||||
{currentStatus.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{project.program?.year ? `${project.program.year} Edition` : ''}{project.program?.name ? ` - ${project.program.name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Draft warning */}
|
||||
{isDraft && (
|
||||
<Alert>
|
||||
<Clock className="h-4 w-4" />
|
||||
<AlertTitle>Draft Submission</AlertTitle>
|
||||
<AlertDescription>
|
||||
This submission has not been submitted yet. You can continue editing and submit when ready.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documents</TabsTrigger>
|
||||
<TabsTrigger value="mentor" className="gap-1.5">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Mentor
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Details Tab */}
|
||||
<TabsContent value="details">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Project details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{project.teamName && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
|
||||
<p>{project.teamName}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.description && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Description</p>
|
||||
<p className="whitespace-pre-wrap">{project.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Metadata */}
|
||||
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Additional Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-3">
|
||||
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-muted-foreground capitalize">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</dt>
|
||||
<dd className="text-sm">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status timeline */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusTracker
|
||||
timeline={timeline}
|
||||
currentStatus={currentStatus}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Dates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{project.submittedAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Submitted</span>
|
||||
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last Updated</span>
|
||||
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Members */}
|
||||
{'teamMembers' in project && project.teamMembers && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Team
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/my-submission/${projectId}/team` as Route}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
|
||||
{member.role === 'LEAD' ? (
|
||||
<Crown className="h-4 w-4 text-yellow-500" />
|
||||
) : (
|
||||
<span className="text-xs font-medium">
|
||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{member.user.name || member.user.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Documents Tab */}
|
||||
<TabsContent value="documents">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Uploaded Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Documents submitted with your application
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.files.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4">
|
||||
No documents uploaded
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{project.files.map((file) => {
|
||||
const Icon = fileTypeIcons[file.fileType] || File
|
||||
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{file.fileName}</p>
|
||||
{fileRecord.isLate && (
|
||||
<Badge variant="warning" className="text-xs gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Submitted late
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fileTypeLabels[file.fileType] || file.fileType}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" disabled>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Mentor Tab */}
|
||||
<TabsContent value="mentor">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Mentor Communication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Chat with your assigned mentor
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MentorChat
|
||||
messages={mentorMessages || []}
|
||||
currentUserId={session?.user?.id || ''}
|
||||
onSendMessage={async (message) => {
|
||||
await sendMessage.mutateAsync({ projectId, message })
|
||||
}}
|
||||
isLoading={messagesLoading}
|
||||
isSending={sendMessage.isPending}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,432 +1,432 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Crown,
|
||||
Mail,
|
||||
Trash2,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
LogIn,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
const inviteSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
role: z.enum(['MEMBER', 'ADVISOR']),
|
||||
title: z.string().optional(),
|
||||
})
|
||||
|
||||
type InviteFormData = z.infer<typeof inviteSchema>
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
LEAD: 'Team Lead',
|
||||
MEMBER: 'Team Member',
|
||||
ADVISOR: 'Advisor',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
ACTIVE: { label: 'Active', icon: CheckCircle },
|
||||
INVITED: { label: 'Pending', icon: Clock },
|
||||
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
|
||||
}
|
||||
|
||||
export default function TeamManagementPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const projectId = params.id as string
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
|
||||
const [isInviteOpen, setIsInviteOpen] = useState(false)
|
||||
|
||||
const { data: teamData, isLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: sessionStatus === 'authenticated' && session?.user?.role === 'APPLICANT' }
|
||||
)
|
||||
|
||||
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.requiresAccountSetup) {
|
||||
toast.success('Invitation email sent to team member')
|
||||
} else {
|
||||
toast.success('Team member added and notified by email')
|
||||
}
|
||||
setIsInviteOpen(false)
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const removeMutation = trpc.applicant.removeTeamMember.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Team member removed')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const form = useForm<InviteFormData>({
|
||||
resolver: zodResolver(inviteSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'MEMBER',
|
||||
title: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onInvite = async (data: InviteFormData) => {
|
||||
await inviteMutation.mutateAsync({
|
||||
projectId,
|
||||
...data,
|
||||
})
|
||||
form.reset()
|
||||
}
|
||||
|
||||
// Not authenticated
|
||||
if (sessionStatus === 'unauthenticated') {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<LogIn className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Sign In Required</h2>
|
||||
<p className="text-muted-foreground text-center mb-6">
|
||||
Please sign in to manage your team.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/login">Sign In</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading
|
||||
if (sessionStatus === 'loading' || isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is team lead
|
||||
const currentUserMember = teamData?.teamMembers.find(
|
||||
(tm) => tm.userId === session?.user?.id
|
||||
)
|
||||
const isTeamLead =
|
||||
currentUserMember?.role === 'LEAD' ||
|
||||
teamData?.submittedBy?.id === session?.user?.id
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/my-submission/${projectId}`}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<Users className="h-6 w-6" />
|
||||
Team Members
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your project team
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTeamLead && (
|
||||
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite Member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite Team Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send an invitation to join your project team. They will receive an email
|
||||
with instructions to create their account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Jane Doe"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="jane@example.com"
|
||||
{...form.register('email')}
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select
|
||||
value={form.watch('role')}
|
||||
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MEMBER">Team Member</SelectItem>
|
||||
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title (optional)</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="CTO, Designer..."
|
||||
{...form.register('title')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsInviteOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={inviteMutation.isPending}>
|
||||
{inviteMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Send Invitation
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Members List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
|
||||
<CardDescription>
|
||||
Everyone on this list can view and collaborate on this project.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{teamData?.teamMembers.map((member) => {
|
||||
const StatusIcon = statusLabels[member.user.status]?.icon || AlertCircle
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
{member.role === 'LEAD' ? (
|
||||
<Crown className="h-5 w-5 text-yellow-500" />
|
||||
) : (
|
||||
<span className="text-sm font-medium">
|
||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{member.user.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{roleLabels[member.role] || member.role}
|
||||
</Badge>
|
||||
{member.title && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({member.title})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
{member.user.email}
|
||||
<StatusIcon className="h-3 w-3 ml-2" />
|
||||
<span className="text-xs">
|
||||
{statusLabels[member.user.status]?.label || member.user.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove {member.user.name} from the team?
|
||||
They will no longer have access to this project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => removeMutation.mutate({ projectId, userId: member.userId })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{(!teamData?.teamMembers || teamData.teamMembers.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">No team members yet.</p>
|
||||
{isTeamLead && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setIsInviteOpen(true)}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite Your First Team Member
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Documents - available via documents page */}
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">About Team Access</p>
|
||||
<p className="mt-1">
|
||||
All team members can view project details and status updates.
|
||||
Only the team lead can invite or remove team members.
|
||||
Invited members will receive an email to set up their account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Crown,
|
||||
Mail,
|
||||
Trash2,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
LogIn,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
const inviteSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
role: z.enum(['MEMBER', 'ADVISOR']),
|
||||
title: z.string().optional(),
|
||||
})
|
||||
|
||||
type InviteFormData = z.infer<typeof inviteSchema>
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
LEAD: 'Team Lead',
|
||||
MEMBER: 'Team Member',
|
||||
ADVISOR: 'Advisor',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
ACTIVE: { label: 'Active', icon: CheckCircle },
|
||||
INVITED: { label: 'Pending', icon: Clock },
|
||||
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
|
||||
}
|
||||
|
||||
export default function TeamManagementPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const projectId = params.id as string
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
|
||||
const [isInviteOpen, setIsInviteOpen] = useState(false)
|
||||
|
||||
const { data: teamData, isLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: sessionStatus === 'authenticated' && session?.user?.role === 'APPLICANT' }
|
||||
)
|
||||
|
||||
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.requiresAccountSetup) {
|
||||
toast.success('Invitation email sent to team member')
|
||||
} else {
|
||||
toast.success('Team member added and notified by email')
|
||||
}
|
||||
setIsInviteOpen(false)
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const removeMutation = trpc.applicant.removeTeamMember.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Team member removed')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const form = useForm<InviteFormData>({
|
||||
resolver: zodResolver(inviteSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'MEMBER',
|
||||
title: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onInvite = async (data: InviteFormData) => {
|
||||
await inviteMutation.mutateAsync({
|
||||
projectId,
|
||||
...data,
|
||||
})
|
||||
form.reset()
|
||||
}
|
||||
|
||||
// Not authenticated
|
||||
if (sessionStatus === 'unauthenticated') {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<LogIn className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Sign In Required</h2>
|
||||
<p className="text-muted-foreground text-center mb-6">
|
||||
Please sign in to manage your team.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/login">Sign In</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading
|
||||
if (sessionStatus === 'loading' || isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is team lead
|
||||
const currentUserMember = teamData?.teamMembers.find(
|
||||
(tm) => tm.userId === session?.user?.id
|
||||
)
|
||||
const isTeamLead =
|
||||
currentUserMember?.role === 'LEAD' ||
|
||||
teamData?.submittedBy?.id === session?.user?.id
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/my-submission/${projectId}`}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<Users className="h-6 w-6" />
|
||||
Team Members
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your project team
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTeamLead && (
|
||||
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite Member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite Team Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send an invitation to join your project team. They will receive an email
|
||||
with instructions to create their account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Jane Doe"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="jane@example.com"
|
||||
{...form.register('email')}
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select
|
||||
value={form.watch('role')}
|
||||
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MEMBER">Team Member</SelectItem>
|
||||
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title (optional)</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="CTO, Designer..."
|
||||
{...form.register('title')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsInviteOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={inviteMutation.isPending}>
|
||||
{inviteMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Send Invitation
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Members List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
|
||||
<CardDescription>
|
||||
Everyone on this list can view and collaborate on this project.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{teamData?.teamMembers.map((member) => {
|
||||
const StatusIcon = statusLabels[member.user.status]?.icon || AlertCircle
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
{member.role === 'LEAD' ? (
|
||||
<Crown className="h-5 w-5 text-yellow-500" />
|
||||
) : (
|
||||
<span className="text-sm font-medium">
|
||||
{member.user.name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{member.user.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{roleLabels[member.role] || member.role}
|
||||
</Badge>
|
||||
{member.title && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({member.title})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
{member.user.email}
|
||||
<StatusIcon className="h-3 w-3 ml-2" />
|
||||
<span className="text-xs">
|
||||
{statusLabels[member.user.status]?.label || member.user.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove {member.user.name} from the team?
|
||||
They will no longer have access to this project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => removeMutation.mutate({ projectId, userId: member.userId })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{(!teamData?.teamMembers || teamData.teamMembers.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">No team members yet.</p>
|
||||
{isTeamLead && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setIsInviteOpen(true)}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite Your First Team Member
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Documents - available via documents page */}
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">About Team Access</p>
|
||||
<p className="mt-1">
|
||||
All team members can view project details and status updates.
|
||||
Only the team lead can invite or remove team members.
|
||||
Invited members will receive an email to set up their account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,243 +1,243 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
LogIn,
|
||||
Eye,
|
||||
Users,
|
||||
Crown,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||
DRAFT: 'secondary',
|
||||
SUBMITTED: 'default',
|
||||
UNDER_REVIEW: 'default',
|
||||
ELIGIBLE: 'default',
|
||||
SEMIFINALIST: 'success',
|
||||
FINALIST: 'success',
|
||||
WINNER: 'success',
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
export function MySubmissionClient() {
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
|
||||
const { data: submissions, isLoading } = trpc.applicant.listMySubmissions.useQuery(
|
||||
undefined,
|
||||
{ enabled: session?.user?.role === 'APPLICANT' }
|
||||
)
|
||||
|
||||
// Not authenticated
|
||||
if (sessionStatus === 'unauthenticated') {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<LogIn className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Sign In Required</h2>
|
||||
<p className="text-muted-foreground text-center mb-6">
|
||||
Please sign in to view your submissions.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/login">Sign In</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading session
|
||||
if (sessionStatus === 'loading' || isLoading) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Not an applicant
|
||||
if (session?.user?.role !== 'APPLICANT') {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-warning mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Access Restricted</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
This page is only available to applicants.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">My Submissions</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Track the status of your project submissions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submissions list */}
|
||||
{!submissions || submissions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Submissions Yet</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
You haven't submitted any projects yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{submissions.map((project) => {
|
||||
const projectStatus = project.status ?? 'SUBMITTED'
|
||||
const programName = project.program?.name
|
||||
const programYear = project.program?.year
|
||||
|
||||
return (
|
||||
<Card key={project.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{project.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={statusColors[projectStatus] || 'secondary'}>
|
||||
{projectStatus.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Meta info */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Created {new Date(project.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
{project.submittedAt ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
Submitted {new Date(project.submittedAt).toLocaleDateString()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4 text-orange-500" />
|
||||
Draft - Not submitted
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
{project.files.length} file(s) uploaded
|
||||
</div>
|
||||
{'teamMembers' in project && project.teamMembers && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{project.teamMembers.length} team member(s)
|
||||
</div>
|
||||
)}
|
||||
{'isTeamLead' in project && project.isTeamLead && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Crown className="h-4 w-4 text-yellow-500" />
|
||||
Team Lead
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status timeline */}
|
||||
{project.submittedAt && (
|
||||
<div className="pt-2">
|
||||
<StatusTracker
|
||||
timeline={[
|
||||
{
|
||||
status: 'SUBMITTED',
|
||||
label: 'Submitted',
|
||||
date: project.submittedAt,
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
status: 'UNDER_REVIEW',
|
||||
label: 'Under Review',
|
||||
date: null,
|
||||
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus),
|
||||
},
|
||||
{
|
||||
status: 'SEMIFINALIST',
|
||||
label: 'Semi-finalist',
|
||||
date: null,
|
||||
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus),
|
||||
},
|
||||
{
|
||||
status: 'FINALIST',
|
||||
label: 'Finalist',
|
||||
date: null,
|
||||
completed: ['FINALIST', 'WINNER'].includes(projectStatus),
|
||||
},
|
||||
]}
|
||||
currentStatus={projectStatus}
|
||||
className="mt-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/my-submission/${project.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
LogIn,
|
||||
Eye,
|
||||
Users,
|
||||
Crown,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
|
||||
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
|
||||
DRAFT: 'secondary',
|
||||
SUBMITTED: 'default',
|
||||
UNDER_REVIEW: 'default',
|
||||
ELIGIBLE: 'default',
|
||||
SEMIFINALIST: 'success',
|
||||
FINALIST: 'success',
|
||||
WINNER: 'success',
|
||||
REJECTED: 'destructive',
|
||||
}
|
||||
|
||||
export function MySubmissionClient() {
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
|
||||
const { data: submissions, isLoading } = trpc.applicant.listMySubmissions.useQuery(
|
||||
undefined,
|
||||
{ enabled: session?.user?.role === 'APPLICANT' }
|
||||
)
|
||||
|
||||
// Not authenticated
|
||||
if (sessionStatus === 'unauthenticated') {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<LogIn className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Sign In Required</h2>
|
||||
<p className="text-muted-foreground text-center mb-6">
|
||||
Please sign in to view your submissions.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/login">Sign In</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading session
|
||||
if (sessionStatus === 'loading' || isLoading) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Not an applicant
|
||||
if (session?.user?.role !== 'APPLICANT') {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-warning mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Access Restricted</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
This page is only available to applicants.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">My Submissions</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Track the status of your project submissions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submissions list */}
|
||||
{!submissions || submissions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Submissions Yet</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
You haven't submitted any projects yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{submissions.map((project) => {
|
||||
const projectStatus = project.status ?? 'SUBMITTED'
|
||||
const programName = project.program?.name
|
||||
const programYear = project.program?.year
|
||||
|
||||
return (
|
||||
<Card key={project.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{project.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={statusColors[projectStatus] || 'secondary'}>
|
||||
{projectStatus.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Meta info */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Created {new Date(project.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
{project.submittedAt ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
Submitted {new Date(project.submittedAt).toLocaleDateString()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4 text-orange-500" />
|
||||
Draft - Not submitted
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
{project.files.length} file(s) uploaded
|
||||
</div>
|
||||
{'teamMembers' in project && project.teamMembers && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{project.teamMembers.length} team member(s)
|
||||
</div>
|
||||
)}
|
||||
{'isTeamLead' in project && project.isTeamLead && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Crown className="h-4 w-4 text-yellow-500" />
|
||||
Team Lead
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status timeline */}
|
||||
{project.submittedAt && (
|
||||
<div className="pt-2">
|
||||
<StatusTracker
|
||||
timeline={[
|
||||
{
|
||||
status: 'SUBMITTED',
|
||||
label: 'Submitted',
|
||||
date: project.submittedAt,
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
status: 'UNDER_REVIEW',
|
||||
label: 'Under Review',
|
||||
date: null,
|
||||
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus),
|
||||
},
|
||||
{
|
||||
status: 'SEMIFINALIST',
|
||||
label: 'Semi-finalist',
|
||||
date: null,
|
||||
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus),
|
||||
},
|
||||
{
|
||||
status: 'FINALIST',
|
||||
label: 'Finalist',
|
||||
date: null,
|
||||
completed: ['FINALIST', 'WINNER'].includes(projectStatus),
|
||||
},
|
||||
]}
|
||||
currentStatus={projectStatus}
|
||||
className="mt-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/my-submission/${project.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,391 +1,391 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect, useCallback } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Users,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Vote,
|
||||
} from 'lucide-react'
|
||||
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}
|
||||
|
||||
const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
const TOKEN_KEY = 'mopc_audience_token_'
|
||||
|
||||
function AudienceVotingContent({ sessionId }: { sessionId: string }) {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [countdown, setCountdown] = useState<number | null>(null)
|
||||
const [hasVotedForProject, setHasVotedForProject] = useState(false)
|
||||
|
||||
// Check for saved token on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(TOKEN_KEY + sessionId)
|
||||
if (saved) {
|
||||
setToken(saved)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// Fetch session data
|
||||
const { data, isLoading, refetch } = trpc.liveVoting.getAudienceSession.useQuery(
|
||||
{ sessionId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
|
||||
// SSE for real-time updates
|
||||
const onSessionStatus = useCallback(() => {
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const onProjectChange = useCallback(() => {
|
||||
setSelectedScore(null)
|
||||
setHasVotedForProject(false)
|
||||
setCountdown(null)
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const { isConnected } = useLiveVotingSSE(sessionId, {
|
||||
onSessionStatus,
|
||||
onProjectChange,
|
||||
})
|
||||
|
||||
// Register mutation
|
||||
const register = trpc.liveVoting.registerAudienceVoter.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setToken(result.token)
|
||||
localStorage.setItem(TOKEN_KEY + sessionId, result.token)
|
||||
toast.success('Registered! You can now vote.')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Vote mutation
|
||||
const castVote = trpc.liveVoting.castAudienceVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Vote recorded!')
|
||||
setHasVotedForProject(true)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Update countdown
|
||||
useEffect(() => {
|
||||
if (data?.timeRemaining !== null && data?.timeRemaining !== undefined) {
|
||||
setCountdown(data.timeRemaining)
|
||||
} else {
|
||||
setCountdown(null)
|
||||
}
|
||||
}, [data?.timeRemaining])
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (countdown === null || countdown <= 0) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev === null || prev <= 0) return 0
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [countdown])
|
||||
|
||||
// Reset vote state when project changes
|
||||
useEffect(() => {
|
||||
setSelectedScore(null)
|
||||
setHasVotedForProject(false)
|
||||
}, [data?.currentProject?.id])
|
||||
|
||||
const handleRegister = () => {
|
||||
register.mutate({
|
||||
sessionId,
|
||||
identifier: identifier.trim() || undefined,
|
||||
identifierType: identifier.includes('@')
|
||||
? 'email'
|
||||
: identifier.trim()
|
||||
? 'name'
|
||||
: 'anonymous',
|
||||
})
|
||||
}
|
||||
|
||||
const handleVote = (score: number) => {
|
||||
if (!token || !data?.currentProject) return
|
||||
setSelectedScore(score)
|
||||
castVote.mutate({
|
||||
sessionId,
|
||||
projectId: data.currentProject.id,
|
||||
score,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <AudienceVotingSkeleton />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Session Not Found</AlertTitle>
|
||||
<AlertDescription>
|
||||
This voting session does not exist or has ended.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.session.allowAudienceVotes) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Users className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Audience Voting Not Available</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Audience voting is not enabled for this session.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Registration step
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Vote className="h-6 w-6 text-primary" />
|
||||
<CardTitle>Audience Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-center text-muted-foreground">
|
||||
Register to participate in audience voting
|
||||
</p>
|
||||
|
||||
{data.session.audienceRequireId && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier">Your Email or Name</Label>
|
||||
<Input
|
||||
id="identifier"
|
||||
placeholder="email@example.com or your name"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for audience voting verification
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data.session.audienceRequireId && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier">Your Name (optional)</Label>
|
||||
<Input
|
||||
id="identifier"
|
||||
placeholder="Enter your name (optional)"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleRegister}
|
||||
disabled={
|
||||
register.isPending ||
|
||||
(data.session.audienceRequireId && !identifier.trim())
|
||||
}
|
||||
>
|
||||
{register.isPending ? 'Registering...' : 'Join Voting'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Voting UI
|
||||
const isVoting = data.session.status === 'IN_PROGRESS'
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Vote className="h-6 w-6 text-primary" />
|
||||
<CardTitle>Audience Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{isVoting && data.currentProject ? (
|
||||
<>
|
||||
{/* Current project */}
|
||||
<div className="text-center space-y-2">
|
||||
<Badge variant="default" className="mb-2">
|
||||
Now Presenting
|
||||
</Badge>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{data.currentProject.title}
|
||||
</h2>
|
||||
{data.currentProject.teamName && (
|
||||
<p className="text-muted-foreground">
|
||||
{data.currentProject.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-primary mb-2">
|
||||
{countdown !== null ? `${countdown}s` : '--'}
|
||||
</div>
|
||||
<Progress
|
||||
value={countdown !== null ? (countdown / 30) * 100 : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Time remaining to vote
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Score buttons */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-center">Your Score</p>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{SCORE_OPTIONS.map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
size="lg"
|
||||
className="h-14 text-xl font-bold"
|
||||
onClick={() => handleVote(score)}
|
||||
disabled={castVote.isPending || countdown === 0}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
1 = Low, 10 = Excellent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Vote status */}
|
||||
{hasVotedForProject && (
|
||||
<Alert className="bg-green-500/10 border-green-500">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<AlertDescription>
|
||||
Your vote has been recorded! You can change it before time runs out.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Waiting state */
|
||||
<div className="text-center py-12">
|
||||
<Clock className="h-16 w-16 text-muted-foreground mx-auto mb-4 animate-pulse" />
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Waiting for Next Project
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{data.session.status === 'COMPLETED'
|
||||
? 'The voting session has ended. Thank you for participating!'
|
||||
: 'Voting will begin when the next project is presented.'}
|
||||
</p>
|
||||
{data.session.status !== 'COMPLETED' && (
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
This page will update automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isConnected ? 'Connected' : 'Reconnecting...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AudienceVotingSkeleton() {
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<Skeleton className="h-6 w-40 mx-auto" />
|
||||
<Skeleton className="h-4 w-56 mx-auto mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AudienceVotingPage({ params }: PageProps) {
|
||||
const { sessionId } = use(params)
|
||||
|
||||
return <AudienceVotingContent sessionId={sessionId} />
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect, useCallback } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Users,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Vote,
|
||||
} from 'lucide-react'
|
||||
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}
|
||||
|
||||
const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
const TOKEN_KEY = 'mopc_audience_token_'
|
||||
|
||||
function AudienceVotingContent({ sessionId }: { sessionId: string }) {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [countdown, setCountdown] = useState<number | null>(null)
|
||||
const [hasVotedForProject, setHasVotedForProject] = useState(false)
|
||||
|
||||
// Check for saved token on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(TOKEN_KEY + sessionId)
|
||||
if (saved) {
|
||||
setToken(saved)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// Fetch session data
|
||||
const { data, isLoading, refetch } = trpc.liveVoting.getAudienceSession.useQuery(
|
||||
{ sessionId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
|
||||
// SSE for real-time updates
|
||||
const onSessionStatus = useCallback(() => {
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const onProjectChange = useCallback(() => {
|
||||
setSelectedScore(null)
|
||||
setHasVotedForProject(false)
|
||||
setCountdown(null)
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const { isConnected } = useLiveVotingSSE(sessionId, {
|
||||
onSessionStatus,
|
||||
onProjectChange,
|
||||
})
|
||||
|
||||
// Register mutation
|
||||
const register = trpc.liveVoting.registerAudienceVoter.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setToken(result.token)
|
||||
localStorage.setItem(TOKEN_KEY + sessionId, result.token)
|
||||
toast.success('Registered! You can now vote.')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Vote mutation
|
||||
const castVote = trpc.liveVoting.castAudienceVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Vote recorded!')
|
||||
setHasVotedForProject(true)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Update countdown
|
||||
useEffect(() => {
|
||||
if (data?.timeRemaining !== null && data?.timeRemaining !== undefined) {
|
||||
setCountdown(data.timeRemaining)
|
||||
} else {
|
||||
setCountdown(null)
|
||||
}
|
||||
}, [data?.timeRemaining])
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (countdown === null || countdown <= 0) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev === null || prev <= 0) return 0
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [countdown])
|
||||
|
||||
// Reset vote state when project changes
|
||||
useEffect(() => {
|
||||
setSelectedScore(null)
|
||||
setHasVotedForProject(false)
|
||||
}, [data?.currentProject?.id])
|
||||
|
||||
const handleRegister = () => {
|
||||
register.mutate({
|
||||
sessionId,
|
||||
identifier: identifier.trim() || undefined,
|
||||
identifierType: identifier.includes('@')
|
||||
? 'email'
|
||||
: identifier.trim()
|
||||
? 'name'
|
||||
: 'anonymous',
|
||||
})
|
||||
}
|
||||
|
||||
const handleVote = (score: number) => {
|
||||
if (!token || !data?.currentProject) return
|
||||
setSelectedScore(score)
|
||||
castVote.mutate({
|
||||
sessionId,
|
||||
projectId: data.currentProject.id,
|
||||
score,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <AudienceVotingSkeleton />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Session Not Found</AlertTitle>
|
||||
<AlertDescription>
|
||||
This voting session does not exist or has ended.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.session.allowAudienceVotes) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Users className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Audience Voting Not Available</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Audience voting is not enabled for this session.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Registration step
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Vote className="h-6 w-6 text-primary" />
|
||||
<CardTitle>Audience Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-center text-muted-foreground">
|
||||
Register to participate in audience voting
|
||||
</p>
|
||||
|
||||
{data.session.audienceRequireId && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier">Your Email or Name</Label>
|
||||
<Input
|
||||
id="identifier"
|
||||
placeholder="email@example.com or your name"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for audience voting verification
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data.session.audienceRequireId && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier">Your Name (optional)</Label>
|
||||
<Input
|
||||
id="identifier"
|
||||
placeholder="Enter your name (optional)"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleRegister}
|
||||
disabled={
|
||||
register.isPending ||
|
||||
(data.session.audienceRequireId && !identifier.trim())
|
||||
}
|
||||
>
|
||||
{register.isPending ? 'Registering...' : 'Join Voting'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Voting UI
|
||||
const isVoting = data.session.status === 'IN_PROGRESS'
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Vote className="h-6 w-6 text-primary" />
|
||||
<CardTitle>Audience Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{isVoting && data.currentProject ? (
|
||||
<>
|
||||
{/* Current project */}
|
||||
<div className="text-center space-y-2">
|
||||
<Badge variant="default" className="mb-2">
|
||||
Now Presenting
|
||||
</Badge>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{data.currentProject.title}
|
||||
</h2>
|
||||
{data.currentProject.teamName && (
|
||||
<p className="text-muted-foreground">
|
||||
{data.currentProject.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-primary mb-2">
|
||||
{countdown !== null ? `${countdown}s` : '--'}
|
||||
</div>
|
||||
<Progress
|
||||
value={countdown !== null ? (countdown / 30) * 100 : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Time remaining to vote
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Score buttons */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-center">Your Score</p>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{SCORE_OPTIONS.map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
size="lg"
|
||||
className="h-14 text-xl font-bold"
|
||||
onClick={() => handleVote(score)}
|
||||
disabled={castVote.isPending || countdown === 0}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
1 = Low, 10 = Excellent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Vote status */}
|
||||
{hasVotedForProject && (
|
||||
<Alert className="bg-green-500/10 border-green-500">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<AlertDescription>
|
||||
Your vote has been recorded! You can change it before time runs out.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Waiting state */
|
||||
<div className="text-center py-12">
|
||||
<Clock className="h-16 w-16 text-muted-foreground mx-auto mb-4 animate-pulse" />
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Waiting for Next Project
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{data.session.status === 'COMPLETED'
|
||||
? 'The voting session has ended. Thank you for participating!'
|
||||
: 'Voting will begin when the next project is presented.'}
|
||||
</p>
|
||||
{data.session.status !== 'COMPLETED' && (
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
This page will update automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isConnected ? 'Connected' : 'Reconnecting...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AudienceVotingSkeleton() {
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<Skeleton className="h-6 w-40 mx-auto" />
|
||||
<Skeleton className="h-4 w-56 mx-auto mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AudienceVotingPage({ params }: PageProps) {
|
||||
const { sessionId } = use(params)
|
||||
|
||||
return <AudienceVotingContent sessionId={sessionId} />
|
||||
}
|
||||
|
||||
@@ -1,215 +1,215 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Waves,
|
||||
} from 'lucide-react'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function StageAudienceVotePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}) {
|
||||
const { sessionId } = use(params)
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [hasVoted, setHasVoted] = useState(false)
|
||||
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
const castVoteMutation = trpc.live.castStageVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Your vote has been recorded!')
|
||||
setHasVoted(true)
|
||||
setLastVotedProjectId(activeProject?.id ?? null)
|
||||
setSelectedScore(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Reset vote state when project changes
|
||||
if (activeProject?.id && activeProject.id !== lastVotedProjectId) {
|
||||
if (hasVoted) {
|
||||
setHasVoted(false)
|
||||
setSelectedScore(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVote = () => {
|
||||
if (!activeProject || selectedScore === null) return
|
||||
castVoteMutation.mutate({
|
||||
sessionId,
|
||||
projectId: activeProject.id,
|
||||
score: selectedScore,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
|
||||
<div className="mx-auto max-w-lg px-4 py-8 space-y-6">
|
||||
{/* MOPC branding header */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Waves className="h-8 w-8 text-brand-blue" />
|
||||
<h1 className="text-2xl font-bold text-brand-blue dark:text-brand-teal">
|
||||
MOPC Live Vote
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Live
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{sseError}</p>
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Paused state */}
|
||||
{isPaused ? (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Pause className="h-16 w-16 text-amber-600 mb-4" />
|
||||
<p className="text-xl font-semibold">Voting Paused</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Please wait for the next project...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : activeProject ? (
|
||||
<>
|
||||
{/* Active project card */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-center">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Voting controls */}
|
||||
<Card>
|
||||
<CardContent className="py-6 space-y-6">
|
||||
{hasVoted ? (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<CheckCircle2 className="h-16 w-16 text-emerald-600 mb-4" />
|
||||
<p className="text-xl font-semibold">Thank you!</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Your vote has been recorded. Waiting for the next project...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-center text-sm font-medium text-muted-foreground">
|
||||
Rate this project from 1 to 10
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-14 text-xl font-bold tabular-nums',
|
||||
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light scale-110'
|
||||
)}
|
||||
onClick={() => setSelectedScore(score)}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-14 text-lg bg-brand-blue hover:bg-brand-blue-light"
|
||||
disabled={selectedScore === null || castVoteMutation.isPending}
|
||||
onClick={handleVote}
|
||||
>
|
||||
{castVoteMutation.isPending ? (
|
||||
'Submitting...'
|
||||
) : selectedScore !== null ? (
|
||||
<>
|
||||
<Star className="mr-2 h-5 w-5" />
|
||||
Vote {selectedScore}/10
|
||||
</>
|
||||
) : (
|
||||
'Select a score to vote'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Star className="h-16 w-16 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-xl font-semibold">Waiting...</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
The next project will appear here shortly.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Monaco Ocean Protection Challenge
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Waves,
|
||||
} from 'lucide-react'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function StageAudienceVotePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}) {
|
||||
const { sessionId } = use(params)
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [hasVoted, setHasVoted] = useState(false)
|
||||
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
const castVoteMutation = trpc.live.castStageVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Your vote has been recorded!')
|
||||
setHasVoted(true)
|
||||
setLastVotedProjectId(activeProject?.id ?? null)
|
||||
setSelectedScore(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Reset vote state when project changes
|
||||
if (activeProject?.id && activeProject.id !== lastVotedProjectId) {
|
||||
if (hasVoted) {
|
||||
setHasVoted(false)
|
||||
setSelectedScore(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVote = () => {
|
||||
if (!activeProject || selectedScore === null) return
|
||||
castVoteMutation.mutate({
|
||||
sessionId,
|
||||
projectId: activeProject.id,
|
||||
score: selectedScore,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
|
||||
<div className="mx-auto max-w-lg px-4 py-8 space-y-6">
|
||||
{/* MOPC branding header */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Waves className="h-8 w-8 text-brand-blue" />
|
||||
<h1 className="text-2xl font-bold text-brand-blue dark:text-brand-teal">
|
||||
MOPC Live Vote
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Live
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{sseError}</p>
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Paused state */}
|
||||
{isPaused ? (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Pause className="h-16 w-16 text-amber-600 mb-4" />
|
||||
<p className="text-xl font-semibold">Voting Paused</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Please wait for the next project...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : activeProject ? (
|
||||
<>
|
||||
{/* Active project card */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-center">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Voting controls */}
|
||||
<Card>
|
||||
<CardContent className="py-6 space-y-6">
|
||||
{hasVoted ? (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<CheckCircle2 className="h-16 w-16 text-emerald-600 mb-4" />
|
||||
<p className="text-xl font-semibold">Thank you!</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Your vote has been recorded. Waiting for the next project...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-center text-sm font-medium text-muted-foreground">
|
||||
Rate this project from 1 to 10
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-14 text-xl font-bold tabular-nums',
|
||||
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light scale-110'
|
||||
)}
|
||||
onClick={() => setSelectedScore(score)}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-14 text-lg bg-brand-blue hover:bg-brand-blue-light"
|
||||
disabled={selectedScore === null || castVoteMutation.isPending}
|
||||
onClick={handleVote}
|
||||
>
|
||||
{castVoteMutation.isPending ? (
|
||||
'Submitting...'
|
||||
) : selectedScore !== null ? (
|
||||
<>
|
||||
<Star className="mr-2 h-5 w-5" />
|
||||
Vote {selectedScore}/10
|
||||
</>
|
||||
) : (
|
||||
'Select a score to vote'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Star className="h-16 w-16 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-xl font-semibold">Waiting...</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
The next project will appear here shortly.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Monaco Ocean Protection Challenge
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const ROLE_DASHBOARDS: Record<string, string> = {
|
||||
SUPER_ADMIN: '/admin',
|
||||
PROGRAM_ADMIN: '/admin',
|
||||
JURY_MEMBER: '/jury',
|
||||
MENTOR: '/mentor',
|
||||
OBSERVER: '/observer',
|
||||
APPLICANT: '/applicant',
|
||||
}
|
||||
|
||||
export default async function SettingsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const dashboardUrl = ROLE_DASHBOARDS[session.user.role] || '/login'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-40 border-b bg-card">
|
||||
<div className="container mx-auto flex h-16 max-w-3xl items-center px-4">
|
||||
<a
|
||||
href={dashboardUrl}
|
||||
className="flex items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container mx-auto max-w-3xl px-4 py-6 lg:py-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { redirect } from 'next/navigation'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const ROLE_DASHBOARDS: Record<string, string> = {
|
||||
SUPER_ADMIN: '/admin',
|
||||
PROGRAM_ADMIN: '/admin',
|
||||
JURY_MEMBER: '/jury',
|
||||
MENTOR: '/mentor',
|
||||
OBSERVER: '/observer',
|
||||
APPLICANT: '/applicant',
|
||||
}
|
||||
|
||||
export default async function SettingsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const dashboardUrl = ROLE_DASHBOARDS[session.user.role] || '/login'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-40 border-b bg-card">
|
||||
<div className="container mx-auto flex h-16 max-w-3xl items-center px-4">
|
||||
<a
|
||||
href={dashboardUrl}
|
||||
className="flex items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container mx-auto max-w-3xl px-4 py-6 lg:py-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function ProfileSettingsPage() {
|
||||
|
||||
// Profile form state
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [bio, setBio] = useState('')
|
||||
const [phoneNumber, setPhoneNumber] = useState('')
|
||||
const [notificationPreference, setNotificationPreference] = useState('EMAIL')
|
||||
@@ -85,6 +86,7 @@ export default function ProfileSettingsPage() {
|
||||
useEffect(() => {
|
||||
if (user && !profileLoaded) {
|
||||
setName(user.name || '')
|
||||
setEmail(user.email || '')
|
||||
const meta = (user.metadataJson as Record<string, unknown>) || {}
|
||||
setBio((meta.bio as string) || '')
|
||||
setPhoneNumber(user.phoneNumber || '')
|
||||
@@ -104,6 +106,7 @@ export default function ProfileSettingsPage() {
|
||||
const handleSaveProfile = async () => {
|
||||
try {
|
||||
await updateProfile.mutateAsync({
|
||||
email: email || undefined,
|
||||
name: name || undefined,
|
||||
bio,
|
||||
phoneNumber: phoneNumber || null,
|
||||
@@ -222,8 +225,16 @@ export default function ProfileSettingsPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" value={user.email} disabled />
|
||||
<p className="text-xs text-muted-foreground">Email cannot be changed</p>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This will be used for login and all notification emails.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Read retention period from system settings (default: 365 days)
|
||||
const retentionSetting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'audit_retention_days' },
|
||||
})
|
||||
|
||||
const retentionDays = retentionSetting
|
||||
? parseInt(retentionSetting.value, 10) || 365
|
||||
: 365
|
||||
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays)
|
||||
|
||||
// Delete audit log entries older than the retention period
|
||||
const result = await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
timestamp: {
|
||||
lt: cutoffDate,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
cleanedUp: result.count,
|
||||
retentionDays,
|
||||
cutoffDate: cutoffDate.toISOString(),
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron audit cleanup failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Read retention period from system settings (default: 365 days)
|
||||
const retentionSetting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'audit_retention_days' },
|
||||
})
|
||||
|
||||
const retentionDays = retentionSetting
|
||||
? parseInt(retentionSetting.value, 10) || 365
|
||||
: 365
|
||||
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays)
|
||||
|
||||
// Delete audit log entries older than the retention period
|
||||
const result = await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
timestamp: {
|
||||
lt: cutoffDate,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
cleanedUp: result.count,
|
||||
retentionDays,
|
||||
cutoffDate: cutoffDate.toISOString(),
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron audit cleanup failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { processDigests } from '@/server/services/email-digest'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine digest type: check query param, or default based on day of week
|
||||
const { searchParams } = new URL(request.url)
|
||||
let digestType = searchParams.get('type') as 'daily' | 'weekly' | null
|
||||
|
||||
if (!digestType) {
|
||||
const dayOfWeek = new Date().getDay()
|
||||
// Monday = 1 → run weekly; all other days → run daily
|
||||
digestType = dayOfWeek === 1 ? 'weekly' : 'daily'
|
||||
}
|
||||
|
||||
const result = await processDigests(digestType)
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
digestType,
|
||||
sent: result.sent,
|
||||
errors: result.errors,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron digest processing failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { processDigests } from '@/server/services/email-digest'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine digest type: check query param, or default based on day of week
|
||||
const { searchParams } = new URL(request.url)
|
||||
let digestType = searchParams.get('type') as 'daily' | 'weekly' | null
|
||||
|
||||
if (!digestType) {
|
||||
const dayOfWeek = new Date().getDay()
|
||||
// Monday = 1 → run weekly; all other days → run daily
|
||||
digestType = dayOfWeek === 1 ? 'weekly' : 'daily'
|
||||
}
|
||||
|
||||
const result = await processDigests(digestType)
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
digestType,
|
||||
sent: result.sent,
|
||||
errors: result.errors,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron digest processing failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date()
|
||||
|
||||
// Delete projects where isDraft=true AND draftExpiresAt has passed
|
||||
const result = await prisma.project.deleteMany({
|
||||
where: {
|
||||
isDraft: true,
|
||||
draftExpiresAt: {
|
||||
lt: now,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
cleanedUp: result.count,
|
||||
timestamp: now.toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron draft cleanup failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const cronSecret = request.headers.get('x-cron-secret')
|
||||
|
||||
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date()
|
||||
|
||||
// Delete projects where isDraft=true AND draftExpiresAt has passed
|
||||
const result = await prisma.project.deleteMany({
|
||||
where: {
|
||||
isDraft: true,
|
||||
draftExpiresAt: {
|
||||
lt: now,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
cleanedUp: result.count,
|
||||
timestamp: now.toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Cron draft cleanup failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +1,145 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
|
||||
const POSTE_API_URL = process.env.POSTE_API_URL || 'https://mail.monaco-opc.com'
|
||||
const POSTE_ADMIN_EMAIL = process.env.POSTE_ADMIN_EMAIL || ''
|
||||
const POSTE_ADMIN_PASSWORD = process.env.POSTE_ADMIN_PASSWORD || ''
|
||||
|
||||
const PASSWORD_MIN_LENGTH = 8
|
||||
const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/
|
||||
|
||||
function validateNewPassword(password: string): string | null {
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`
|
||||
}
|
||||
if (!PASSWORD_REGEX.test(password)) {
|
||||
return 'Password must contain at least one uppercase letter, one lowercase letter, and one number.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authenticated session
|
||||
const session = await auth()
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
const rateLimit = checkRateLimit(`email-change:${ip}`, 3, 15 * 60 * 1000)
|
||||
|
||||
if (!rateLimit.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many attempts. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { email, currentPassword, newPassword } = body as {
|
||||
email: string
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
if (!email || !currentPassword || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'All fields are required.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
|
||||
// Verify the user can only change their own email password
|
||||
if (emailLower !== session.user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only change your own email password.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Email must be an @${MAIL_DOMAIN} address.` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const passwordError = validateNewPassword(newPassword)
|
||||
if (passwordError) {
|
||||
return NextResponse.json({ error: passwordError }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!POSTE_ADMIN_EMAIL || !POSTE_ADMIN_PASSWORD) {
|
||||
console.error('Poste.io admin credentials not configured')
|
||||
return NextResponse.json(
|
||||
{ error: 'Email service is not configured. Contact an administrator.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
// Re-verify current credentials via SMTP
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: {
|
||||
user: emailLower,
|
||||
pass: currentPassword,
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
})
|
||||
|
||||
try {
|
||||
await transporter.verify()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Current password is incorrect.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
} finally {
|
||||
transporter.close()
|
||||
}
|
||||
|
||||
// Change password via Poste.io Admin API
|
||||
const apiUrl = `${POSTE_API_URL}/admin/api/v1/boxes/${encodeURIComponent(emailLower)}`
|
||||
const authHeader = 'Basic ' + Buffer.from(`${POSTE_ADMIN_EMAIL}:${POSTE_ADMIN_PASSWORD}`).toString('base64')
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': authHeader,
|
||||
},
|
||||
body: JSON.stringify({ passwordPlaintext: newPassword }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Poste.io API error:', response.status, await response.text())
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to change password. Please try again or contact an administrator.' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
console.error('Password change error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
|
||||
const POSTE_API_URL = process.env.POSTE_API_URL || 'https://mail.monaco-opc.com'
|
||||
const POSTE_ADMIN_EMAIL = process.env.POSTE_ADMIN_EMAIL || ''
|
||||
const POSTE_ADMIN_PASSWORD = process.env.POSTE_ADMIN_PASSWORD || ''
|
||||
|
||||
const PASSWORD_MIN_LENGTH = 8
|
||||
const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/
|
||||
|
||||
function validateNewPassword(password: string): string | null {
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`
|
||||
}
|
||||
if (!PASSWORD_REGEX.test(password)) {
|
||||
return 'Password must contain at least one uppercase letter, one lowercase letter, and one number.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authenticated session
|
||||
const session = await auth()
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
const rateLimit = checkRateLimit(`email-change:${ip}`, 3, 15 * 60 * 1000)
|
||||
|
||||
if (!rateLimit.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many attempts. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { email, currentPassword, newPassword } = body as {
|
||||
email: string
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
if (!email || !currentPassword || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'All fields are required.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
|
||||
// Verify the user can only change their own email password
|
||||
if (emailLower !== session.user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only change your own email password.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Email must be an @${MAIL_DOMAIN} address.` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const passwordError = validateNewPassword(newPassword)
|
||||
if (passwordError) {
|
||||
return NextResponse.json({ error: passwordError }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!POSTE_ADMIN_EMAIL || !POSTE_ADMIN_PASSWORD) {
|
||||
console.error('Poste.io admin credentials not configured')
|
||||
return NextResponse.json(
|
||||
{ error: 'Email service is not configured. Contact an administrator.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
// Re-verify current credentials via SMTP
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: {
|
||||
user: emailLower,
|
||||
pass: currentPassword,
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
})
|
||||
|
||||
try {
|
||||
await transporter.verify()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Current password is incorrect.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
} finally {
|
||||
transporter.close()
|
||||
}
|
||||
|
||||
// Change password via Poste.io Admin API
|
||||
const apiUrl = `${POSTE_API_URL}/admin/api/v1/boxes/${encodeURIComponent(emailLower)}`
|
||||
const authHeader = 'Basic ' + Buffer.from(`${POSTE_ADMIN_EMAIL}:${POSTE_ADMIN_PASSWORD}`).toString('base64')
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': authHeader,
|
||||
},
|
||||
body: JSON.stringify({ passwordPlaintext: newPassword }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Poste.io API error:', response.status, await response.text())
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to change password. Please try again or contact an administrator.' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
console.error('Password change error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authenticated session
|
||||
const session = await auth()
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
const rateLimit = checkRateLimit(`email-verify:${ip}`, 5, 15 * 60 * 1000)
|
||||
|
||||
if (!rateLimit.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many attempts. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { email, password } = body as { email: string; password: string }
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
|
||||
// Verify the user can only check their own email credentials
|
||||
if (emailLower !== session.user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only verify your own email credentials.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Email must be an @${MAIL_DOMAIN} address.` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: {
|
||||
user: emailLower,
|
||||
pass: password,
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
})
|
||||
|
||||
try {
|
||||
await transporter.verify()
|
||||
return NextResponse.json({ valid: true })
|
||||
} catch {
|
||||
return NextResponse.json({ valid: false, error: 'Invalid email or password.' })
|
||||
} finally {
|
||||
transporter.close()
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
|
||||
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authenticated session
|
||||
const session = await auth()
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||
const rateLimit = checkRateLimit(`email-verify:${ip}`, 5, 15 * 60 * 1000)
|
||||
|
||||
if (!rateLimit.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many attempts. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { email, password } = body as { email: string; password: string }
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailLower = email.toLowerCase().trim()
|
||||
|
||||
// Verify the user can only check their own email credentials
|
||||
if (emailLower !== session.user.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You can only verify your own email credentials.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Email must be an @${MAIL_DOMAIN} address.` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: {
|
||||
user: emailLower,
|
||||
pass: password,
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
})
|
||||
|
||||
try {
|
||||
await transporter.verify()
|
||||
return NextResponse.json({ valid: true })
|
||||
} catch {
|
||||
return NextResponse.json({ valid: false, error: 'Invalid email or password.' })
|
||||
} finally {
|
||||
transporter.close()
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +1,124 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getPresignedUrl, BUCKET_NAME } from '@/lib/minio'
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { projectId, fileIds } = body as {
|
||||
projectId?: string
|
||||
fileIds?: string[]
|
||||
}
|
||||
|
||||
if (!projectId || !fileIds || !Array.isArray(fileIds) || fileIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'projectId and fileIds array are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const userRole = session.user.role
|
||||
|
||||
// Authorization: must be admin or assigned jury/mentor for this project
|
||||
const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
|
||||
|
||||
if (!isAdmin) {
|
||||
// Check if user is assigned as jury
|
||||
const juryAssignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
})
|
||||
|
||||
// Check if user is assigned as mentor
|
||||
const mentorAssignment = await prisma.mentorAssignment.findFirst({
|
||||
where: {
|
||||
mentorId: userId,
|
||||
projectId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!juryAssignment && !mentorAssignment) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You do not have access to this project\'s files' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch file metadata from DB
|
||||
const files = await prisma.projectFile.findMany({
|
||||
where: {
|
||||
id: { in: fileIds },
|
||||
projectId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
objectKey: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No matching files found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate signed download URLs for each file
|
||||
const downloadUrls = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
const downloadUrl = await getPresignedUrl(
|
||||
BUCKET_NAME,
|
||||
file.objectKey,
|
||||
'GET',
|
||||
3600 // 1 hour expiry for bulk downloads
|
||||
)
|
||||
|
||||
return {
|
||||
id: file.id,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
downloadUrl,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[BulkDownload] Failed to get URL for file ${file.id}:`, error)
|
||||
return {
|
||||
id: file.id,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
downloadUrl: null,
|
||||
error: 'Failed to generate download URL',
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
projectId,
|
||||
files: downloadUrls,
|
||||
expiresIn: 3600,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[BulkDownload] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getPresignedUrl, BUCKET_NAME } from '@/lib/minio'
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { projectId, fileIds } = body as {
|
||||
projectId?: string
|
||||
fileIds?: string[]
|
||||
}
|
||||
|
||||
if (!projectId || !fileIds || !Array.isArray(fileIds) || fileIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'projectId and fileIds array are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const userRole = session.user.role
|
||||
|
||||
// Authorization: must be admin or assigned jury/mentor for this project
|
||||
const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
|
||||
|
||||
if (!isAdmin) {
|
||||
// Check if user is assigned as jury
|
||||
const juryAssignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
})
|
||||
|
||||
// Check if user is assigned as mentor
|
||||
const mentorAssignment = await prisma.mentorAssignment.findFirst({
|
||||
where: {
|
||||
mentorId: userId,
|
||||
projectId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!juryAssignment && !mentorAssignment) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You do not have access to this project\'s files' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch file metadata from DB
|
||||
const files = await prisma.projectFile.findMany({
|
||||
where: {
|
||||
id: { in: fileIds },
|
||||
projectId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
objectKey: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No matching files found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate signed download URLs for each file
|
||||
const downloadUrls = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
const downloadUrl = await getPresignedUrl(
|
||||
BUCKET_NAME,
|
||||
file.objectKey,
|
||||
'GET',
|
||||
3600 // 1 hour expiry for bulk downloads
|
||||
)
|
||||
|
||||
return {
|
||||
id: file.id,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
downloadUrl,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[BulkDownload] Failed to get URL for file ${file.id}:`, error)
|
||||
return {
|
||||
id: file.id,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
downloadUrl: null,
|
||||
error: 'Failed to generate download URL',
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
projectId,
|
||||
files: downloadUrls,
|
||||
expiresIn: 3600,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[BulkDownload] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,215 +1,215 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response(JSON.stringify({ error: 'sessionId is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Verify the session exists
|
||||
const session = await prisma.liveVotingSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Session not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Track state for change detection
|
||||
let lastVoteCount = -1
|
||||
let lastAudienceVoteCount = -1
|
||||
let lastProjectId: string | null = null
|
||||
let lastStatus: string | null = null
|
||||
|
||||
const sendEvent = (event: string, data: unknown) => {
|
||||
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
||||
controller.enqueue(encoder.encode(payload))
|
||||
}
|
||||
|
||||
// Send initial connection event
|
||||
sendEvent('connected', { sessionId, timestamp: new Date().toISOString() })
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const currentSession = await prisma.liveVotingSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: {
|
||||
status: true,
|
||||
currentProjectId: true,
|
||||
currentProjectIndex: true,
|
||||
votingEndsAt: true,
|
||||
allowAudienceVotes: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!currentSession) {
|
||||
sendEvent('session_status', { status: 'DELETED' })
|
||||
controller.close()
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for status changes
|
||||
if (lastStatus !== null && currentSession.status !== lastStatus) {
|
||||
sendEvent('session_status', {
|
||||
status: currentSession.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastStatus = currentSession.status
|
||||
|
||||
// Check for project changes
|
||||
if (
|
||||
lastProjectId !== null &&
|
||||
currentSession.currentProjectId !== lastProjectId
|
||||
) {
|
||||
sendEvent('project_change', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
projectIndex: currentSession.currentProjectIndex,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastProjectId = currentSession.currentProjectId
|
||||
|
||||
// Check for vote updates on the current project
|
||||
if (currentSession.currentProjectId) {
|
||||
// Jury votes
|
||||
const juryVoteCount = await prisma.liveVote.count({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (lastVoteCount !== -1 && juryVoteCount !== lastVoteCount) {
|
||||
const latestVotes = await prisma.liveVote.findMany({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
select: {
|
||||
score: true,
|
||||
isAudienceVote: true,
|
||||
votedAt: true,
|
||||
},
|
||||
orderBy: { votedAt: 'desc' },
|
||||
take: 1,
|
||||
})
|
||||
|
||||
const avgScore = await prisma.liveVote.aggregate({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
_avg: { score: true },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
sendEvent('vote_update', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
totalVotes: juryVoteCount,
|
||||
averageScore: avgScore._avg.score,
|
||||
latestVote: latestVotes[0] || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastVoteCount = juryVoteCount
|
||||
|
||||
// Audience votes (separate event)
|
||||
if (currentSession.allowAudienceVotes) {
|
||||
const audienceVoteCount = await prisma.liveVote.count({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (lastAudienceVoteCount !== -1 && audienceVoteCount !== lastAudienceVoteCount) {
|
||||
const audienceAvg = await prisma.liveVote.aggregate({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
_avg: { score: true },
|
||||
})
|
||||
|
||||
sendEvent('audience_vote', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
audienceVotes: audienceVoteCount,
|
||||
audienceAverage: audienceAvg._avg.score,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastAudienceVoteCount = audienceVoteCount
|
||||
}
|
||||
}
|
||||
|
||||
// Stop polling if session is completed
|
||||
if (currentSession.status === 'COMPLETED') {
|
||||
sendEvent('session_status', {
|
||||
status: 'COMPLETED',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
controller.close()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[SSE] Poll error:', error)
|
||||
return true // Keep trying
|
||||
}
|
||||
}
|
||||
|
||||
// Initial poll to set baseline state
|
||||
const shouldContinue = await poll()
|
||||
if (!shouldContinue) return
|
||||
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(async () => {
|
||||
const cont = await poll()
|
||||
if (!cont) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// Clean up on abort
|
||||
request.signal.addEventListener('abort', () => {
|
||||
clearInterval(interval)
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// Stream may already be closed
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response(JSON.stringify({ error: 'sessionId is required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Verify the session exists
|
||||
const session = await prisma.liveVotingSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Session not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Track state for change detection
|
||||
let lastVoteCount = -1
|
||||
let lastAudienceVoteCount = -1
|
||||
let lastProjectId: string | null = null
|
||||
let lastStatus: string | null = null
|
||||
|
||||
const sendEvent = (event: string, data: unknown) => {
|
||||
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
||||
controller.enqueue(encoder.encode(payload))
|
||||
}
|
||||
|
||||
// Send initial connection event
|
||||
sendEvent('connected', { sessionId, timestamp: new Date().toISOString() })
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const currentSession = await prisma.liveVotingSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: {
|
||||
status: true,
|
||||
currentProjectId: true,
|
||||
currentProjectIndex: true,
|
||||
votingEndsAt: true,
|
||||
allowAudienceVotes: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!currentSession) {
|
||||
sendEvent('session_status', { status: 'DELETED' })
|
||||
controller.close()
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for status changes
|
||||
if (lastStatus !== null && currentSession.status !== lastStatus) {
|
||||
sendEvent('session_status', {
|
||||
status: currentSession.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastStatus = currentSession.status
|
||||
|
||||
// Check for project changes
|
||||
if (
|
||||
lastProjectId !== null &&
|
||||
currentSession.currentProjectId !== lastProjectId
|
||||
) {
|
||||
sendEvent('project_change', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
projectIndex: currentSession.currentProjectIndex,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastProjectId = currentSession.currentProjectId
|
||||
|
||||
// Check for vote updates on the current project
|
||||
if (currentSession.currentProjectId) {
|
||||
// Jury votes
|
||||
const juryVoteCount = await prisma.liveVote.count({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (lastVoteCount !== -1 && juryVoteCount !== lastVoteCount) {
|
||||
const latestVotes = await prisma.liveVote.findMany({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
select: {
|
||||
score: true,
|
||||
isAudienceVote: true,
|
||||
votedAt: true,
|
||||
},
|
||||
orderBy: { votedAt: 'desc' },
|
||||
take: 1,
|
||||
})
|
||||
|
||||
const avgScore = await prisma.liveVote.aggregate({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: false,
|
||||
},
|
||||
_avg: { score: true },
|
||||
_count: true,
|
||||
})
|
||||
|
||||
sendEvent('vote_update', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
totalVotes: juryVoteCount,
|
||||
averageScore: avgScore._avg.score,
|
||||
latestVote: latestVotes[0] || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastVoteCount = juryVoteCount
|
||||
|
||||
// Audience votes (separate event)
|
||||
if (currentSession.allowAudienceVotes) {
|
||||
const audienceVoteCount = await prisma.liveVote.count({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (lastAudienceVoteCount !== -1 && audienceVoteCount !== lastAudienceVoteCount) {
|
||||
const audienceAvg = await prisma.liveVote.aggregate({
|
||||
where: {
|
||||
sessionId,
|
||||
projectId: currentSession.currentProjectId,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
_avg: { score: true },
|
||||
})
|
||||
|
||||
sendEvent('audience_vote', {
|
||||
projectId: currentSession.currentProjectId,
|
||||
audienceVotes: audienceVoteCount,
|
||||
audienceAverage: audienceAvg._avg.score,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
lastAudienceVoteCount = audienceVoteCount
|
||||
}
|
||||
}
|
||||
|
||||
// Stop polling if session is completed
|
||||
if (currentSession.status === 'COMPLETED') {
|
||||
sendEvent('session_status', {
|
||||
status: 'COMPLETED',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
controller.close()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[SSE] Poll error:', error)
|
||||
return true // Keep trying
|
||||
}
|
||||
}
|
||||
|
||||
// Initial poll to set baseline state
|
||||
const shouldContinue = await poll()
|
||||
if (!shouldContinue) return
|
||||
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(async () => {
|
||||
const cont = await poll()
|
||||
if (!cont) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// Clean up on abort
|
||||
request.signal.addEventListener('abort', () => {
|
||||
clearInterval(interval)
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// Stream may already be closed
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,216 +1,216 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const { sessionId } = await params
|
||||
|
||||
// Validate session exists
|
||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
||||
where: { sessionId },
|
||||
})
|
||||
|
||||
if (!cursor) {
|
||||
return new Response('Session not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Manually fetch related data since LiveProgressCursor doesn't have these relations
|
||||
let activeProject = null
|
||||
if (cursor.activeProjectId) {
|
||||
activeProject = await prisma.project.findUnique({
|
||||
where: { id: cursor.activeProjectId },
|
||||
select: { id: true, title: true, teamName: true, description: true },
|
||||
})
|
||||
}
|
||||
|
||||
const stageInfo = await prisma.stage.findUnique({
|
||||
where: { id: cursor.stageId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Send initial state
|
||||
type CohortWithProjects = Awaited<ReturnType<typeof prisma.cohort.findMany<{
|
||||
where: { stageId: string }
|
||||
include: { projects: { select: { projectId: true } } }
|
||||
}>>>
|
||||
|
||||
const cohortPromise: Promise<CohortWithProjects> = prisma.cohort
|
||||
.findMany({
|
||||
where: { stageId: cursor.stageId },
|
||||
include: {
|
||||
projects: {
|
||||
select: { projectId: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((cohorts) => {
|
||||
const initData = {
|
||||
activeProject,
|
||||
isPaused: cursor.isPaused,
|
||||
stageInfo,
|
||||
openCohorts: cohorts.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isOpen: c.isOpen,
|
||||
projectIds: c.projects.map((p) => p.projectId),
|
||||
})),
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: init\ndata: ${JSON.stringify(initData)}\n\n`)
|
||||
)
|
||||
|
||||
return cohorts
|
||||
})
|
||||
.catch((): CohortWithProjects => {
|
||||
// Ignore errors on init
|
||||
return []
|
||||
})
|
||||
|
||||
cohortPromise.then((initialCohorts: CohortWithProjects) => {
|
||||
// Poll for updates
|
||||
let lastActiveProjectId = cursor.activeProjectId
|
||||
let lastIsPaused = cursor.isPaused
|
||||
let lastCohortState = JSON.stringify(
|
||||
(initialCohorts ?? []).map((c: { id: string; isOpen: boolean; windowOpenAt: Date | null; windowCloseAt: Date | null }) => ({
|
||||
id: c.id,
|
||||
isOpen: c.isOpen,
|
||||
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
|
||||
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
intervalId = setInterval(async () => {
|
||||
try {
|
||||
const updated = await prisma.liveProgressCursor.findUnique({
|
||||
where: { sessionId },
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: session.ended\ndata: ${JSON.stringify({ reason: 'Session removed' })}\n\n`
|
||||
)
|
||||
)
|
||||
controller.close()
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for cursor changes
|
||||
if (
|
||||
updated.activeProjectId !== lastActiveProjectId ||
|
||||
updated.isPaused !== lastIsPaused
|
||||
) {
|
||||
// Fetch updated active project if changed
|
||||
let updatedActiveProject = null
|
||||
if (updated.activeProjectId) {
|
||||
updatedActiveProject = await prisma.project.findUnique({
|
||||
where: { id: updated.activeProjectId },
|
||||
select: { id: true, title: true, teamName: true, description: true },
|
||||
})
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: cursor.updated\ndata: ${JSON.stringify({
|
||||
activeProject: updatedActiveProject,
|
||||
isPaused: updated.isPaused,
|
||||
})}\n\n`
|
||||
)
|
||||
)
|
||||
|
||||
// Check pause/resume transitions
|
||||
if (updated.isPaused && !lastIsPaused) {
|
||||
controller.enqueue(encoder.encode(`event: session.paused\ndata: {}\n\n`))
|
||||
} else if (!updated.isPaused && lastIsPaused) {
|
||||
controller.enqueue(encoder.encode(`event: session.resumed\ndata: {}\n\n`))
|
||||
}
|
||||
|
||||
lastActiveProjectId = updated.activeProjectId
|
||||
lastIsPaused = updated.isPaused
|
||||
}
|
||||
|
||||
// Poll cohort changes
|
||||
const currentCohorts = await prisma.cohort.findMany({
|
||||
where: { stageId: cursor.stageId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isOpen: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
projects: { select: { projectId: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const currentCohortState = JSON.stringify(
|
||||
currentCohorts.map((c) => ({
|
||||
id: c.id,
|
||||
isOpen: c.isOpen,
|
||||
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
|
||||
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
if (currentCohortState !== lastCohortState) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: cohort.window.changed\ndata: ${JSON.stringify({
|
||||
openCohorts: currentCohorts.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isOpen: c.isOpen,
|
||||
projectIds: c.projects.map((p) => p.projectId),
|
||||
})),
|
||||
})}\n\n`
|
||||
)
|
||||
)
|
||||
lastCohortState = currentCohortState
|
||||
}
|
||||
|
||||
// Send heartbeat to keep connection alive
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
|
||||
} catch {
|
||||
// Connection may be closed, ignore errors
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
})
|
||||
},
|
||||
cancel() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Check if client disconnected
|
||||
request.signal.addEventListener('abort', () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const { sessionId } = await params
|
||||
|
||||
// Validate session exists
|
||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
||||
where: { sessionId },
|
||||
})
|
||||
|
||||
if (!cursor) {
|
||||
return new Response('Session not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Manually fetch related data since LiveProgressCursor doesn't have these relations
|
||||
let activeProject = null
|
||||
if (cursor.activeProjectId) {
|
||||
activeProject = await prisma.project.findUnique({
|
||||
where: { id: cursor.activeProjectId },
|
||||
select: { id: true, title: true, teamName: true, description: true },
|
||||
})
|
||||
}
|
||||
|
||||
const stageInfo = await prisma.stage.findUnique({
|
||||
where: { id: cursor.stageId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Send initial state
|
||||
type CohortWithProjects = Awaited<ReturnType<typeof prisma.cohort.findMany<{
|
||||
where: { stageId: string }
|
||||
include: { projects: { select: { projectId: true } } }
|
||||
}>>>
|
||||
|
||||
const cohortPromise: Promise<CohortWithProjects> = prisma.cohort
|
||||
.findMany({
|
||||
where: { stageId: cursor.stageId },
|
||||
include: {
|
||||
projects: {
|
||||
select: { projectId: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((cohorts) => {
|
||||
const initData = {
|
||||
activeProject,
|
||||
isPaused: cursor.isPaused,
|
||||
stageInfo,
|
||||
openCohorts: cohorts.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isOpen: c.isOpen,
|
||||
projectIds: c.projects.map((p) => p.projectId),
|
||||
})),
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(`event: init\ndata: ${JSON.stringify(initData)}\n\n`)
|
||||
)
|
||||
|
||||
return cohorts
|
||||
})
|
||||
.catch((): CohortWithProjects => {
|
||||
// Ignore errors on init
|
||||
return []
|
||||
})
|
||||
|
||||
cohortPromise.then((initialCohorts: CohortWithProjects) => {
|
||||
// Poll for updates
|
||||
let lastActiveProjectId = cursor.activeProjectId
|
||||
let lastIsPaused = cursor.isPaused
|
||||
let lastCohortState = JSON.stringify(
|
||||
(initialCohorts ?? []).map((c: { id: string; isOpen: boolean; windowOpenAt: Date | null; windowCloseAt: Date | null }) => ({
|
||||
id: c.id,
|
||||
isOpen: c.isOpen,
|
||||
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
|
||||
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
intervalId = setInterval(async () => {
|
||||
try {
|
||||
const updated = await prisma.liveProgressCursor.findUnique({
|
||||
where: { sessionId },
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: session.ended\ndata: ${JSON.stringify({ reason: 'Session removed' })}\n\n`
|
||||
)
|
||||
)
|
||||
controller.close()
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for cursor changes
|
||||
if (
|
||||
updated.activeProjectId !== lastActiveProjectId ||
|
||||
updated.isPaused !== lastIsPaused
|
||||
) {
|
||||
// Fetch updated active project if changed
|
||||
let updatedActiveProject = null
|
||||
if (updated.activeProjectId) {
|
||||
updatedActiveProject = await prisma.project.findUnique({
|
||||
where: { id: updated.activeProjectId },
|
||||
select: { id: true, title: true, teamName: true, description: true },
|
||||
})
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: cursor.updated\ndata: ${JSON.stringify({
|
||||
activeProject: updatedActiveProject,
|
||||
isPaused: updated.isPaused,
|
||||
})}\n\n`
|
||||
)
|
||||
)
|
||||
|
||||
// Check pause/resume transitions
|
||||
if (updated.isPaused && !lastIsPaused) {
|
||||
controller.enqueue(encoder.encode(`event: session.paused\ndata: {}\n\n`))
|
||||
} else if (!updated.isPaused && lastIsPaused) {
|
||||
controller.enqueue(encoder.encode(`event: session.resumed\ndata: {}\n\n`))
|
||||
}
|
||||
|
||||
lastActiveProjectId = updated.activeProjectId
|
||||
lastIsPaused = updated.isPaused
|
||||
}
|
||||
|
||||
// Poll cohort changes
|
||||
const currentCohorts = await prisma.cohort.findMany({
|
||||
where: { stageId: cursor.stageId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isOpen: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
projects: { select: { projectId: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const currentCohortState = JSON.stringify(
|
||||
currentCohorts.map((c) => ({
|
||||
id: c.id,
|
||||
isOpen: c.isOpen,
|
||||
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
|
||||
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
|
||||
}))
|
||||
)
|
||||
|
||||
if (currentCohortState !== lastCohortState) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: cohort.window.changed\ndata: ${JSON.stringify({
|
||||
openCohorts: currentCohorts.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isOpen: c.isOpen,
|
||||
projectIds: c.projects.map((p) => p.projectId),
|
||||
})),
|
||||
})}\n\n`
|
||||
)
|
||||
)
|
||||
lastCohortState = currentCohortState
|
||||
}
|
||||
|
||||
// Send heartbeat to keep connection alive
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
|
||||
} catch {
|
||||
// Connection may be closed, ignore errors
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
})
|
||||
},
|
||||
cancel() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Check if client disconnected
|
||||
request.signal.addEventListener('abort', () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,125 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { LocalStorageProvider } from '@/lib/storage/local-provider'
|
||||
import { getContentType } from '@/lib/storage'
|
||||
import * as fs from 'fs/promises'
|
||||
|
||||
const provider = new LocalStorageProvider()
|
||||
|
||||
/**
|
||||
* Handle GET requests for file downloads
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const key = searchParams.get('key')
|
||||
const action = searchParams.get('action')
|
||||
const expires = searchParams.get('expires')
|
||||
const sig = searchParams.get('sig')
|
||||
|
||||
// Validate required parameters
|
||||
if (!key || !action || !expires || !sig) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify signature and expiry
|
||||
const isValid = LocalStorageProvider.verifySignature(
|
||||
key,
|
||||
action,
|
||||
parseInt(expires),
|
||||
sig
|
||||
)
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired signature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (action !== 'download') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action for GET request' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = provider.getAbsoluteFilePath(key)
|
||||
const data = await fs.readFile(filePath)
|
||||
const contentType = getContentType(key)
|
||||
|
||||
return new NextResponse(data, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
console.error('Error serving file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PUT requests for file uploads
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const key = searchParams.get('key')
|
||||
const action = searchParams.get('action')
|
||||
const expires = searchParams.get('expires')
|
||||
const sig = searchParams.get('sig')
|
||||
|
||||
// Validate required parameters
|
||||
if (!key || !action || !expires || !sig) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify signature and expiry
|
||||
const isValid = LocalStorageProvider.verifySignature(
|
||||
key,
|
||||
action,
|
||||
parseInt(expires),
|
||||
sig
|
||||
)
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired signature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (action !== 'upload') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action for PUT request' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = request.headers.get('content-type') || 'application/octet-stream'
|
||||
const data = Buffer.from(await request.arrayBuffer())
|
||||
|
||||
await provider.putObject(key, data, contentType)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { LocalStorageProvider } from '@/lib/storage/local-provider'
|
||||
import { getContentType } from '@/lib/storage'
|
||||
import * as fs from 'fs/promises'
|
||||
|
||||
const provider = new LocalStorageProvider()
|
||||
|
||||
/**
|
||||
* Handle GET requests for file downloads
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const key = searchParams.get('key')
|
||||
const action = searchParams.get('action')
|
||||
const expires = searchParams.get('expires')
|
||||
const sig = searchParams.get('sig')
|
||||
|
||||
// Validate required parameters
|
||||
if (!key || !action || !expires || !sig) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify signature and expiry
|
||||
const isValid = LocalStorageProvider.verifySignature(
|
||||
key,
|
||||
action,
|
||||
parseInt(expires),
|
||||
sig
|
||||
)
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired signature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (action !== 'download') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action for GET request' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = provider.getAbsoluteFilePath(key)
|
||||
const data = await fs.readFile(filePath)
|
||||
const contentType = getContentType(key)
|
||||
|
||||
return new NextResponse(data, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
console.error('Error serving file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PUT requests for file uploads
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const key = searchParams.get('key')
|
||||
const action = searchParams.get('action')
|
||||
const expires = searchParams.get('expires')
|
||||
const sig = searchParams.get('sig')
|
||||
|
||||
// Validate required parameters
|
||||
if (!key || !action || !expires || !sig) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify signature and expiry
|
||||
const isValid = LocalStorageProvider.verifySignature(
|
||||
key,
|
||||
action,
|
||||
parseInt(expires),
|
||||
sig
|
||||
)
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired signature' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (action !== 'upload') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action for PUT request' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = request.headers.get('content-type') || 'application/octet-stream'
|
||||
const data = Buffer.from(await request.arrayBuffer())
|
||||
|
||||
await provider.putObject(key, data, contentType)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Application error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('root')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<AlertTriangle className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-display font-bold text-brand-blue">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-body text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An unexpected error occurred. Please try again or return to the dashboard.'}
|
||||
</p>
|
||||
{!isChunk && error.digest && (
|
||||
<p className="mt-2 text-tiny text-muted-foreground/60">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-8 flex gap-4">
|
||||
{isChunk ? (
|
||||
<Button size="lg" onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="lg" onClick={() => reset()}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" asChild>
|
||||
<Link href="/">Return to Dashboard</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Application error:', error)
|
||||
|
||||
if (isChunkLoadError(error)) {
|
||||
attemptChunkErrorRecovery('root')
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const isChunk = isChunkLoadError(error)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<AlertTriangle className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-display font-bold text-brand-blue">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-body text-muted-foreground">
|
||||
{isChunk
|
||||
? 'A new version of the platform may have been deployed. Please reload the page.'
|
||||
: 'An unexpected error occurred. Please try again or return to the dashboard.'}
|
||||
</p>
|
||||
{!isChunk && error.digest && (
|
||||
<p className="mt-2 text-tiny text-muted-foreground/60">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-8 flex gap-4">
|
||||
{isChunk ? (
|
||||
<Button size="lg" onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reload Page
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="lg" onClick={() => reset()}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" asChild>
|
||||
<Link href="/">Return to Dashboard</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,296 +1,296 @@
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/fonts/Montserrat-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/fonts/Montserrat-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/fonts/Montserrat-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/fonts/Montserrat-SemiBold.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/fonts/Montserrat-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Source the JS config for extended theme values */
|
||||
@config "../../tailwind.config.ts";
|
||||
|
||||
/* Theme variables - using CSS custom properties with Tailwind v4 @theme */
|
||||
@theme {
|
||||
/* Container */
|
||||
--container-2xl: 1400px;
|
||||
|
||||
/* Custom spacing */
|
||||
--spacing-18: 4.5rem;
|
||||
--spacing-22: 5.5rem;
|
||||
|
||||
/* Font families */
|
||||
--font-sans: 'Montserrat', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
/* Custom font sizes */
|
||||
--text-display-lg: 3rem;
|
||||
--text-display-lg--line-height: 1.1;
|
||||
--text-display-lg--font-weight: 700;
|
||||
|
||||
--text-display: 2.25rem;
|
||||
--text-display--line-height: 1.2;
|
||||
--text-display--font-weight: 700;
|
||||
|
||||
--text-heading: 1.5rem;
|
||||
--text-heading--line-height: 1.3;
|
||||
--text-heading--font-weight: 600;
|
||||
|
||||
--text-subheading: 1.125rem;
|
||||
--text-subheading--line-height: 1.4;
|
||||
--text-subheading--font-weight: 600;
|
||||
|
||||
--text-body: 1rem;
|
||||
--text-body--line-height: 1.5;
|
||||
--text-body--font-weight: 400;
|
||||
|
||||
--text-small: 0.875rem;
|
||||
--text-small--line-height: 1.5;
|
||||
--text-small--font-weight: 400;
|
||||
|
||||
--text-tiny: 0.75rem;
|
||||
--text-tiny--line-height: 1.5;
|
||||
--text-tiny--font-weight: 400;
|
||||
|
||||
/* Brand colors */
|
||||
--color-brand-red: #de0f1e;
|
||||
--color-brand-red-hover: #c00d1a;
|
||||
--color-brand-red-light: #fee2e2;
|
||||
--color-brand-blue: #053d57;
|
||||
--color-brand-blue-light: #0a5a7c;
|
||||
--color-brand-teal: #557f8c;
|
||||
--color-brand-teal-light: #6a9aa8;
|
||||
|
||||
/* Keyframes */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
--animate-fade-in: fade-in 0.2s ease-out;
|
||||
--animate-fade-out: fade-out 0.2s ease-out;
|
||||
--animate-slide-in-from-top: slide-in-from-top 0.3s ease-out;
|
||||
--animate-slide-in-from-bottom: slide-in-from-bottom 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes accordion-down {
|
||||
from { height: 0; }
|
||||
to { height: var(--radix-accordion-content-height); }
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from { height: var(--radix-accordion-content-height); }
|
||||
to { height: 0; }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-top {
|
||||
from { transform: translateY(-10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-bottom {
|
||||
from { transform: translateY(10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* MOPC Brand Colors - mapped to shadcn/ui variables */
|
||||
--background: 0 0% 99.5%;
|
||||
--foreground: 220 13% 18%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 13% 18%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 13% 18%;
|
||||
|
||||
/* Primary - MOPC Red */
|
||||
--primary: 354 90% 47%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Secondary - Warm gray */
|
||||
--secondary: 30 6% 96%;
|
||||
--secondary-foreground: 220 13% 18%;
|
||||
|
||||
--muted: 30 6% 96%;
|
||||
--muted-foreground: 220 8% 46%;
|
||||
|
||||
/* Accent - Light teal tint for hover states */
|
||||
--accent: 194 30% 94%;
|
||||
--accent-foreground: 220 13% 18%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 30 6% 91%;
|
||||
--input: 30 6% 91%;
|
||||
--ring: 220 9% 76%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: 142 76% 36%;
|
||||
--warning: 38 92% 50%;
|
||||
--info: 194 25% 44%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 220 15% 8%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 220 15% 10%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 220 15% 10%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 354 90% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 220 15% 18%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 220 15% 18%;
|
||||
--muted-foreground: 0 0% 64%;
|
||||
|
||||
--accent: 194 20% 18%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 84% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 220 15% 22%;
|
||||
--input: 220 15% 22%;
|
||||
--ring: 220 10% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground overflow-x-hidden;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
*:focus-visible {
|
||||
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
|
||||
}
|
||||
|
||||
.leaflet-container:focus,
|
||||
.leaflet-container:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
ring: none;
|
||||
--tw-ring-shadow: none;
|
||||
--tw-ring-offset-shadow: none;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
@apply bg-primary/20 text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Text balance for better typography */
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
.animate-in {
|
||||
animation: fade-in 0.2s ease-out, slide-in-from-bottom 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Container for admin/jury views */
|
||||
.container-app {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for non-hidden areas */
|
||||
@media (min-width: 768px) {
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/fonts/Montserrat-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/fonts/Montserrat-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/fonts/Montserrat-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/fonts/Montserrat-SemiBold.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('/fonts/Montserrat-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Source the JS config for extended theme values */
|
||||
@config "../../tailwind.config.ts";
|
||||
|
||||
/* Theme variables - using CSS custom properties with Tailwind v4 @theme */
|
||||
@theme {
|
||||
/* Container */
|
||||
--container-2xl: 1400px;
|
||||
|
||||
/* Custom spacing */
|
||||
--spacing-18: 4.5rem;
|
||||
--spacing-22: 5.5rem;
|
||||
|
||||
/* Font families */
|
||||
--font-sans: 'Montserrat', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
/* Custom font sizes */
|
||||
--text-display-lg: 3rem;
|
||||
--text-display-lg--line-height: 1.1;
|
||||
--text-display-lg--font-weight: 700;
|
||||
|
||||
--text-display: 2.25rem;
|
||||
--text-display--line-height: 1.2;
|
||||
--text-display--font-weight: 700;
|
||||
|
||||
--text-heading: 1.5rem;
|
||||
--text-heading--line-height: 1.3;
|
||||
--text-heading--font-weight: 600;
|
||||
|
||||
--text-subheading: 1.125rem;
|
||||
--text-subheading--line-height: 1.4;
|
||||
--text-subheading--font-weight: 600;
|
||||
|
||||
--text-body: 1rem;
|
||||
--text-body--line-height: 1.5;
|
||||
--text-body--font-weight: 400;
|
||||
|
||||
--text-small: 0.875rem;
|
||||
--text-small--line-height: 1.5;
|
||||
--text-small--font-weight: 400;
|
||||
|
||||
--text-tiny: 0.75rem;
|
||||
--text-tiny--line-height: 1.5;
|
||||
--text-tiny--font-weight: 400;
|
||||
|
||||
/* Brand colors */
|
||||
--color-brand-red: #de0f1e;
|
||||
--color-brand-red-hover: #c00d1a;
|
||||
--color-brand-red-light: #fee2e2;
|
||||
--color-brand-blue: #053d57;
|
||||
--color-brand-blue-light: #0a5a7c;
|
||||
--color-brand-teal: #557f8c;
|
||||
--color-brand-teal-light: #6a9aa8;
|
||||
|
||||
/* Keyframes */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
--animate-fade-in: fade-in 0.2s ease-out;
|
||||
--animate-fade-out: fade-out 0.2s ease-out;
|
||||
--animate-slide-in-from-top: slide-in-from-top 0.3s ease-out;
|
||||
--animate-slide-in-from-bottom: slide-in-from-bottom 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes accordion-down {
|
||||
from { height: 0; }
|
||||
to { height: var(--radix-accordion-content-height); }
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from { height: var(--radix-accordion-content-height); }
|
||||
to { height: 0; }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-top {
|
||||
from { transform: translateY(-10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-bottom {
|
||||
from { transform: translateY(10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* MOPC Brand Colors - mapped to shadcn/ui variables */
|
||||
--background: 0 0% 99.5%;
|
||||
--foreground: 220 13% 18%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 13% 18%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 13% 18%;
|
||||
|
||||
/* Primary - MOPC Red */
|
||||
--primary: 354 90% 47%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Secondary - Warm gray */
|
||||
--secondary: 30 6% 96%;
|
||||
--secondary-foreground: 220 13% 18%;
|
||||
|
||||
--muted: 30 6% 96%;
|
||||
--muted-foreground: 220 8% 46%;
|
||||
|
||||
/* Accent - Light teal tint for hover states */
|
||||
--accent: 194 30% 94%;
|
||||
--accent-foreground: 220 13% 18%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 30 6% 91%;
|
||||
--input: 30 6% 91%;
|
||||
--ring: 220 9% 76%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: 142 76% 36%;
|
||||
--warning: 38 92% 50%;
|
||||
--info: 194 25% 44%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 220 15% 8%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 220 15% 10%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 220 15% 10%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 354 90% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 220 15% 18%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 220 15% 18%;
|
||||
--muted-foreground: 0 0% 64%;
|
||||
|
||||
--accent: 194 20% 18%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 84% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 220 15% 22%;
|
||||
--input: 220 15% 22%;
|
||||
--ring: 220 10% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground overflow-x-hidden;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
*:focus-visible {
|
||||
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
|
||||
}
|
||||
|
||||
.leaflet-container:focus,
|
||||
.leaflet-container:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
ring: none;
|
||||
--tw-ring-shadow: none;
|
||||
--tw-ring-offset-shadow: none;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
@apply bg-primary/20 text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Text balance for better typography */
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
.animate-in {
|
||||
animation: fade-in 0.2s ease-out, slide-in-from-bottom 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Container for admin/jury views */
|
||||
.container-app {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for non-hidden areas */
|
||||
@media (min-width: 768px) {
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'MOPC Platform',
|
||||
template: '%s | MOPC',
|
||||
},
|
||||
description: 'Monaco Ocean Protection Challenge - Jury Voting Platform',
|
||||
icons: {
|
||||
icon: '/images/MOPC-blue-small.png',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: 'hsl(var(--background))',
|
||||
color: 'hsl(var(--foreground))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'MOPC Platform',
|
||||
template: '%s | MOPC',
|
||||
},
|
||||
description: 'Monaco Ocean Protection Challenge - Jury Voting Platform',
|
||||
icons: {
|
||||
icon: '/images/MOPC-blue-small.png',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: 'hsl(var(--background))',
|
||||
color: 'hsl(var(--foreground))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
356
src/app/page.tsx
356
src/app/page.tsx
@@ -1,178 +1,178 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import type { Route } from 'next'
|
||||
|
||||
export const metadata: Metadata = { title: 'Monaco Ocean Protection Challenge' }
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth()
|
||||
|
||||
// Redirect authenticated users to their appropriate dashboard
|
||||
if (session?.user) {
|
||||
if (
|
||||
session.user.role === 'SUPER_ADMIN' ||
|
||||
session.user.role === 'PROGRAM_ADMIN'
|
||||
) {
|
||||
redirect('/admin')
|
||||
} else if (session.user.role === 'JURY_MEMBER') {
|
||||
redirect('/jury')
|
||||
} else if (session.user.role === 'MENTOR') {
|
||||
redirect('/mentor' as Route)
|
||||
} else if (session.user.role === 'OBSERVER') {
|
||||
redirect('/observer')
|
||||
} else if (session.user.role === 'APPLICANT') {
|
||||
redirect('/applicant' as Route)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border bg-white">
|
||||
<div className="container-app flex h-16 items-center justify-between">
|
||||
<Image
|
||||
src="/images/MOPC-blue-long.png"
|
||||
alt="MOPC - Monaco Ocean Protection Challenge"
|
||||
width={140}
|
||||
height={45}
|
||||
className="h-10 w-auto"
|
||||
priority
|
||||
/>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<main className="flex flex-1 flex-col">
|
||||
<section className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<h1 className="text-display-lg text-brand-blue">
|
||||
Monaco Ocean Protection Challenge
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-lg text-muted-foreground">
|
||||
Supporting innovative solutions for ocean conservation through fair
|
||||
and transparent project evaluation.
|
||||
</p>
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-12 items-center justify-center rounded-md bg-primary px-8 text-base font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Jury Portal
|
||||
</Link>
|
||||
<a
|
||||
href="https://monaco-opc.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-12 items-center justify-center rounded-md border border-border bg-background px-8 text-base font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="border-t border-border bg-muted/30 px-4 py-16">
|
||||
<div className="container-app">
|
||||
<h2 className="text-center text-heading text-brand-blue">
|
||||
Platform Features
|
||||
</h2>
|
||||
<div className="mt-12 grid gap-8 md:grid-cols-3">
|
||||
<div className="rounded-lg border border-border bg-white p-6">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<svg
|
||||
className="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-subheading text-brand-blue">
|
||||
Secure Evaluation
|
||||
</h3>
|
||||
<p className="mt-2 text-small text-muted-foreground">
|
||||
Jury members access only their assigned projects with complete
|
||||
confidentiality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-white p-6">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-accent/10">
|
||||
<svg
|
||||
className="h-6 w-6 text-accent"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-subheading text-brand-blue">
|
||||
Real-time Progress
|
||||
</h3>
|
||||
<p className="mt-2 text-small text-muted-foreground">
|
||||
Track evaluation progress and manage voting windows with
|
||||
comprehensive dashboards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-white p-6">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-brand-teal/10">
|
||||
<svg
|
||||
className="h-6 w-6 text-brand-teal"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-subheading text-brand-blue">
|
||||
Mobile First
|
||||
</h3>
|
||||
<p className="mt-2 text-small text-muted-foreground">
|
||||
Evaluate projects anywhere with a fully responsive design
|
||||
optimized for all devices.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border bg-brand-blue py-8 text-white">
|
||||
<div className="container-app text-center">
|
||||
<p className="text-small">
|
||||
© {new Date().getFullYear()} Monaco Ocean Protection Challenge. All
|
||||
rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import type { Route } from 'next'
|
||||
|
||||
export const metadata: Metadata = { title: 'Monaco Ocean Protection Challenge' }
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth()
|
||||
|
||||
// Redirect authenticated users to their appropriate dashboard
|
||||
if (session?.user) {
|
||||
if (
|
||||
session.user.role === 'SUPER_ADMIN' ||
|
||||
session.user.role === 'PROGRAM_ADMIN'
|
||||
) {
|
||||
redirect('/admin')
|
||||
} else if (session.user.role === 'JURY_MEMBER') {
|
||||
redirect('/jury')
|
||||
} else if (session.user.role === 'MENTOR') {
|
||||
redirect('/mentor' as Route)
|
||||
} else if (session.user.role === 'OBSERVER') {
|
||||
redirect('/observer')
|
||||
} else if (session.user.role === 'APPLICANT') {
|
||||
redirect('/applicant' as Route)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border bg-white">
|
||||
<div className="container-app flex h-16 items-center justify-between">
|
||||
<Image
|
||||
src="/images/MOPC-blue-long.png"
|
||||
alt="MOPC - Monaco Ocean Protection Challenge"
|
||||
width={140}
|
||||
height={45}
|
||||
className="h-10 w-auto"
|
||||
priority
|
||||
/>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<main className="flex flex-1 flex-col">
|
||||
<section className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<h1 className="text-display-lg text-brand-blue">
|
||||
Monaco Ocean Protection Challenge
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-lg text-muted-foreground">
|
||||
Supporting innovative solutions for ocean conservation through fair
|
||||
and transparent project evaluation.
|
||||
</p>
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-12 items-center justify-center rounded-md bg-primary px-8 text-base font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Jury Portal
|
||||
</Link>
|
||||
<a
|
||||
href="https://monaco-opc.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-12 items-center justify-center rounded-md border border-border bg-background px-8 text-base font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="border-t border-border bg-muted/30 px-4 py-16">
|
||||
<div className="container-app">
|
||||
<h2 className="text-center text-heading text-brand-blue">
|
||||
Platform Features
|
||||
</h2>
|
||||
<div className="mt-12 grid gap-8 md:grid-cols-3">
|
||||
<div className="rounded-lg border border-border bg-white p-6">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<svg
|
||||
className="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-subheading text-brand-blue">
|
||||
Secure Evaluation
|
||||
</h3>
|
||||
<p className="mt-2 text-small text-muted-foreground">
|
||||
Jury members access only their assigned projects with complete
|
||||
confidentiality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-white p-6">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-accent/10">
|
||||
<svg
|
||||
className="h-6 w-6 text-accent"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-subheading text-brand-blue">
|
||||
Real-time Progress
|
||||
</h3>
|
||||
<p className="mt-2 text-small text-muted-foreground">
|
||||
Track evaluation progress and manage voting windows with
|
||||
comprehensive dashboards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-white p-6">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-brand-teal/10">
|
||||
<svg
|
||||
className="h-6 w-6 text-brand-teal"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-subheading text-brand-blue">
|
||||
Mobile First
|
||||
</h3>
|
||||
<p className="mt-2 text-small text-muted-foreground">
|
||||
Evaluate projects anywhere with a fully responsive design
|
||||
optimized for all devices.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border bg-brand-blue py-8 text-white">
|
||||
<div className="container-app text-center">
|
||||
<p className="text-small">
|
||||
© {new Date().getFullYear()} Monaco Ocean Protection Challenge. All
|
||||
rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { httpBatchLink } from '@trpc/client'
|
||||
import superjson from 'superjson'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let browserQueryClient: QueryClient | undefined = undefined
|
||||
|
||||
function getQueryClient() {
|
||||
if (typeof window === 'undefined') {
|
||||
// Server: always make a new query client
|
||||
return makeQueryClient()
|
||||
} else {
|
||||
// Browser: make a new query client if we don't already have one
|
||||
if (!browserQueryClient) browserQueryClient = makeQueryClient()
|
||||
return browserQueryClient
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== 'undefined') return ''
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`
|
||||
}
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = getQueryClient()
|
||||
|
||||
const [trpcClient] = useState(() =>
|
||||
trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
transformer: superjson,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<SessionProvider>
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { httpBatchLink } from '@trpc/client'
|
||||
import superjson from 'superjson'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let browserQueryClient: QueryClient | undefined = undefined
|
||||
|
||||
function getQueryClient() {
|
||||
if (typeof window === 'undefined') {
|
||||
// Server: always make a new query client
|
||||
return makeQueryClient()
|
||||
} else {
|
||||
// Browser: make a new query client if we don't already have one
|
||||
if (!browserQueryClient) browserQueryClient = makeQueryClient()
|
||||
return browserQueryClient
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== 'undefined') return ''
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`
|
||||
}
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = getQueryClient()
|
||||
|
||||
const [trpcClient] = useState(() =>
|
||||
trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
transformer: superjson,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<SessionProvider>
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user