419 lines
13 KiB
TypeScript
419 lines
13 KiB
TypeScript
|
|
'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 { 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 { TagInput } from '@/components/shared/tag-input'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
import {
|
||
|
|
ArrowLeft,
|
||
|
|
Save,
|
||
|
|
Loader2,
|
||
|
|
AlertCircle,
|
||
|
|
FolderPlus,
|
||
|
|
Plus,
|
||
|
|
X,
|
||
|
|
} from 'lucide-react'
|
||
|
|
|
||
|
|
function NewProjectPageContent() {
|
||
|
|
const router = useRouter()
|
||
|
|
const searchParams = useSearchParams()
|
||
|
|
const roundIdParam = searchParams.get('round')
|
||
|
|
|
||
|
|
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
|
||
|
|
|
||
|
|
// Form state
|
||
|
|
const [title, setTitle] = useState('')
|
||
|
|
const [teamName, setTeamName] = useState('')
|
||
|
|
const [description, setDescription] = useState('')
|
||
|
|
const [tags, setTags] = useState<string[]>([])
|
||
|
|
const [contactEmail, setContactEmail] = useState('')
|
||
|
|
const [contactName, setContactName] = useState('')
|
||
|
|
const [country, setCountry] = useState('')
|
||
|
|
const [customFields, setCustomFields] = useState<{ key: string; value: string }[]>([])
|
||
|
|
|
||
|
|
// Fetch active programs with rounds
|
||
|
|
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
|
||
|
|
status: 'ACTIVE',
|
||
|
|
includeRounds: true,
|
||
|
|
})
|
||
|
|
|
||
|
|
// Create mutation
|
||
|
|
const createProject = trpc.project.create.useMutation({
|
||
|
|
onSuccess: () => {
|
||
|
|
toast.success('Project created successfully')
|
||
|
|
router.push(`/admin/projects?round=${selectedRoundId}`)
|
||
|
|
},
|
||
|
|
onError: (error) => {
|
||
|
|
toast.error(error.message)
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
// Get all rounds from programs
|
||
|
|
const rounds = programs?.flatMap((p) =>
|
||
|
|
(p.rounds || []).map((r) => ({
|
||
|
|
...r,
|
||
|
|
programName: p.name,
|
||
|
|
}))
|
||
|
|
) || []
|
||
|
|
|
||
|
|
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
|
||
|
|
|
||
|
|
const addCustomField = () => {
|
||
|
|
setCustomFields([...customFields, { key: '', value: '' }])
|
||
|
|
}
|
||
|
|
|
||
|
|
const updateCustomField = (index: number, key: string, value: string) => {
|
||
|
|
const newFields = [...customFields]
|
||
|
|
newFields[index] = { key, value }
|
||
|
|
setCustomFields(newFields)
|
||
|
|
}
|
||
|
|
|
||
|
|
const removeCustomField = (index: number) => {
|
||
|
|
setCustomFields(customFields.filter((_, i) => i !== index))
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleSubmit = () => {
|
||
|
|
if (!title.trim()) {
|
||
|
|
toast.error('Please enter a project title')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (!selectedRoundId) {
|
||
|
|
toast.error('Please select a round')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build metadata
|
||
|
|
const metadataJson: Record<string, unknown> = {}
|
||
|
|
if (contactEmail) metadataJson.contactEmail = contactEmail
|
||
|
|
if (contactName) metadataJson.contactName = contactName
|
||
|
|
if (country) metadataJson.country = country
|
||
|
|
|
||
|
|
// Add custom fields
|
||
|
|
customFields.forEach((field) => {
|
||
|
|
if (field.key.trim() && field.value.trim()) {
|
||
|
|
metadataJson[field.key.trim()] = field.value.trim()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
createProject.mutate({
|
||
|
|
roundId: selectedRoundId,
|
||
|
|
title: title.trim(),
|
||
|
|
teamName: teamName.trim() || undefined,
|
||
|
|
description: description.trim() || undefined,
|
||
|
|
tags: tags.length > 0 ? tags : undefined,
|
||
|
|
metadataJson: Object.keys(metadataJson).length > 0 ? metadataJson : undefined,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
if (loadingPrograms) {
|
||
|
|
return <NewProjectPageSkeleton />
|
||
|
|
}
|
||
|
|
|
||
|
|
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 className="flex items-center gap-2">
|
||
|
|
<FolderPlus className="h-6 w-6 text-primary" />
|
||
|
|
<div>
|
||
|
|
<h1 className="text-2xl font-semibold tracking-tight">Add Project</h1>
|
||
|
|
<p className="text-muted-foreground">
|
||
|
|
Manually create a new project submission
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Round selection */}
|
||
|
|
{!selectedRoundId ? (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Select Round</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Choose the round for this project submission
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
{rounds.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 Rounds</p>
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
Create a round first before adding projects
|
||
|
|
</p>
|
||
|
|
<Button asChild className="mt-4">
|
||
|
|
<Link href="/admin/rounds/new">Create Round</Link>
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder="Select a round" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{rounds.map((round) => (
|
||
|
|
<SelectItem key={round.id} value={round.id}>
|
||
|
|
{round.programName} - {round.name}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
{/* Selected round info */}
|
||
|
|
<Card>
|
||
|
|
<CardContent className="flex items-center justify-between py-4">
|
||
|
|
<div>
|
||
|
|
<p className="font-medium">{selectedRound?.programName}</p>
|
||
|
|
<p className="text-sm text-muted-foreground">{selectedRound?.name}</p>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => setSelectedRoundId('')}
|
||
|
|
>
|
||
|
|
Change Round
|
||
|
|
</Button>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
||
|
|
{/* Basic Info */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Project Details</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Basic information about the project
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="title">Project Title *</Label>
|
||
|
|
<Input
|
||
|
|
id="title"
|
||
|
|
value={title}
|
||
|
|
onChange={(e) => setTitle(e.target.value)}
|
||
|
|
placeholder="e.g., Ocean Cleanup Initiative"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="teamName">Team/Organization Name</Label>
|
||
|
|
<Input
|
||
|
|
id="teamName"
|
||
|
|
value={teamName}
|
||
|
|
onChange={(e) => setTeamName(e.target.value)}
|
||
|
|
placeholder="e.g., Blue Ocean Foundation"
|
||
|
|
/>
|
||
|
|
</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 the project..."
|
||
|
|
rows={4}
|
||
|
|
maxLength={2000}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Tags</Label>
|
||
|
|
<TagInput
|
||
|
|
value={tags}
|
||
|
|
onChange={setTags}
|
||
|
|
placeholder="Select project tags..."
|
||
|
|
maxTags={10}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Contact Info */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Contact Information</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Contact details for the project team
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="contactName">Contact Name</Label>
|
||
|
|
<Input
|
||
|
|
id="contactName"
|
||
|
|
value={contactName}
|
||
|
|
onChange={(e) => setContactName(e.target.value)}
|
||
|
|
placeholder="e.g., John Smith"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="contactEmail">Contact Email</Label>
|
||
|
|
<Input
|
||
|
|
id="contactEmail"
|
||
|
|
type="email"
|
||
|
|
value={contactEmail}
|
||
|
|
onChange={(e) => setContactEmail(e.target.value)}
|
||
|
|
placeholder="e.g., john@example.com"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="country">Country</Label>
|
||
|
|
<Input
|
||
|
|
id="country"
|
||
|
|
value={country}
|
||
|
|
onChange={(e) => setCountry(e.target.value)}
|
||
|
|
placeholder="e.g., Monaco"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Custom Fields */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="flex items-center justify-between">
|
||
|
|
<span>Additional Information</span>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={addCustomField}
|
||
|
|
>
|
||
|
|
<Plus className="mr-2 h-4 w-4" />
|
||
|
|
Add Field
|
||
|
|
</Button>
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Add custom metadata fields for this project
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{customFields.length === 0 ? (
|
||
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||
|
|
No additional fields. Click "Add Field" to add custom information.
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-3">
|
||
|
|
{customFields.map((field, index) => (
|
||
|
|
<div key={index} className="flex gap-2">
|
||
|
|
<Input
|
||
|
|
placeholder="Field name"
|
||
|
|
value={field.key}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateCustomField(index, e.target.value, field.value)
|
||
|
|
}
|
||
|
|
className="flex-1"
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
placeholder="Value"
|
||
|
|
value={field.value}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateCustomField(index, field.key, e.target.value)
|
||
|
|
}
|
||
|
|
className="flex-1"
|
||
|
|
/>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
onClick={() => removeCustomField(index)}
|
||
|
|
>
|
||
|
|
<X className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Actions */}
|
||
|
|
<div className="flex justify-end gap-4">
|
||
|
|
<Button variant="outline" asChild>
|
||
|
|
<Link href="/admin/projects">Cancel</Link>
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
onClick={handleSubmit}
|
||
|
|
disabled={createProject.isPending || !title.trim()}
|
||
|
|
>
|
||
|
|
{createProject.isPending ? (
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Save className="mr-2 h-4 w-4" />
|
||
|
|
)}
|
||
|
|
Create Project
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function NewProjectPageSkeleton() {
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
<Skeleton className="h-9 w-32" />
|
||
|
|
</div>
|
||
|
|
<Skeleton className="h-8 w-48" />
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<Skeleton className="h-6 w-32" />
|
||
|
|
<Skeleton className="h-4 w-64" />
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<Skeleton className="h-10 w-full" />
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function NewProjectPage() {
|
||
|
|
return (
|
||
|
|
<Suspense fallback={<NewProjectPageSkeleton />}>
|
||
|
|
<NewProjectPageContent />
|
||
|
|
</Suspense>
|
||
|
|
)
|
||
|
|
}
|