MOPC-App/src/app/(admin)/admin/projects/[id]/edit/page.tsx

676 lines
21 KiB
TypeScript

'use client'
import { Suspense, use, useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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 { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogo } from '@/components/shared/project-logo'
import { LogoUpload } from '@/components/shared/logo-upload'
import {
ArrowLeft,
Loader2,
AlertCircle,
Trash2,
X,
Plus,
FileText,
Film,
Presentation,
FileIcon,
} from 'lucide-react'
import { formatFileSize } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
const updateProjectSchema = z.object({
title: z.string().min(1, 'Title is required').max(500),
teamName: z.string().optional(),
description: z.string().optional(),
status: z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
]),
tags: z.array(z.string()),
})
type UpdateProjectForm = z.infer<typeof updateProjectSchema>
// File type icons
const fileTypeIcons: Record<string, React.ReactNode> = {
EXEC_SUMMARY: <FileText className="h-4 w-4" />,
PRESENTATION: <Presentation className="h-4 w-4" />,
VIDEO: <Film className="h-4 w-4" />,
OTHER: <FileIcon className="h-4 w-4" />,
}
function EditProjectContent({ projectId }: { projectId: string }) {
const router = useRouter()
const [tagInput, setTagInput] = useState('')
// Fetch project data
const { data: project, isLoading } = trpc.project.get.useQuery({
id: projectId,
})
// Fetch files
const { data: files, refetch: refetchFiles } = trpc.file.listByProject.useQuery({
projectId,
})
// Fetch logo URL
const { data: logoUrl, refetch: refetchLogo } = trpc.logo.getUrl.useQuery({
projectId,
})
// Fetch existing tags for suggestions
const { data: existingTags } = trpc.project.getTags.useQuery({
roundId: project?.roundId ?? undefined,
})
// Mutations
const utils = trpc.useUtils()
const updateProject = trpc.project.update.useMutation({
onSuccess: () => {
utils.project.get.invalidate({ id: projectId })
utils.project.list.invalidate()
router.push(`/admin/projects/${projectId}`)
},
})
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
utils.project.list.invalidate()
utils.round.get.invalidate()
router.push('/admin/projects')
},
})
const deleteFile = trpc.file.delete.useMutation({
onSuccess: () => {
refetchFiles()
},
})
// Initialize form
const form = useForm<UpdateProjectForm>({
resolver: zodResolver(updateProjectSchema),
defaultValues: {
title: '',
teamName: '',
description: '',
status: 'SUBMITTED',
tags: [],
},
})
// Update form when project loads
useEffect(() => {
if (project) {
form.reset({
title: project.title,
teamName: project.teamName || '',
description: project.description || '',
status: (project.status ?? 'SUBMITTED') as UpdateProjectForm['status'],
tags: project.tags || [],
})
}
}, [project, form])
const tags = form.watch('tags')
// Add tag
const addTag = useCallback(() => {
const tag = tagInput.trim()
if (tag && !tags.includes(tag)) {
form.setValue('tags', [...tags, tag])
setTagInput('')
}
}, [tagInput, tags, form])
// Remove tag
const removeTag = useCallback(
(tag: string) => {
form.setValue(
'tags',
tags.filter((t) => t !== tag)
)
},
[tags, form]
)
const onSubmit = async (data: UpdateProjectForm) => {
await updateProject.mutateAsync({
id: projectId,
title: data.title,
teamName: data.teamName || null,
description: data.description || null,
status: data.status,
roundId: project?.roundId ?? undefined,
tags: data.tags,
})
}
const handleDelete = async () => {
await deleteProject.mutateAsync({ id: projectId })
}
const handleDeleteFile = async (fileId: string) => {
await deleteFile.mutateAsync({ id: fileId })
}
if (isLoading) {
return <EditProjectSkeleton />
}
if (!project) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</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" />
<p className="mt-2 font-medium">Project Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/projects">Back to Projects</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
const isPending = updateProject.isPending || deleteProject.isPending
// Filter tag suggestions (exclude already selected)
const tagSuggestions =
existingTags?.filter((t) => !tags.includes(t)).slice(0, 5) || []
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">Edit Project</h1>
<p className="text-muted-foreground">
Update project information and manage files
</p>
</div>
{/* Form */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Project Logo */}
<div className="flex items-start gap-4 pb-4 border-b">
<ProjectLogo
project={project}
logoUrl={logoUrl}
size="lg"
/>
<div className="flex-1 space-y-1">
<FormLabel>Project Logo</FormLabel>
<FormDescription>
Upload a logo for this project. It will be displayed in project lists and cards.
</FormDescription>
<div className="pt-2">
<LogoUpload
project={project}
currentLogoUrl={logoUrl}
onUploadComplete={() => refetchLogo()}
/>
</div>
</div>
</div>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Project title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="teamName"
render={({ field }) => (
<FormItem>
<FormLabel>Team Name</FormLabel>
<FormControl>
<Input
placeholder="Team or organization name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Project description..."
rows={4}
maxLength={2000}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="SUBMITTED">Submitted</SelectItem>
<SelectItem value="ELIGIBLE">Eligible</SelectItem>
<SelectItem value="ASSIGNED">Assigned</SelectItem>
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
<SelectItem value="FINALIST">Finalist</SelectItem>
<SelectItem value="REJECTED">Rejected</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Tags */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Tags</CardTitle>
<CardDescription>
Add tags to categorize this project
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="Add a tag..."
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addTag()
}
}}
/>
<Button type="button" variant="outline" onClick={addTag}>
<Plus className="h-4 w-4" />
</Button>
</div>
{tagSuggestions.length > 0 && tagInput && (
<div className="flex flex-wrap gap-2">
<span className="text-xs text-muted-foreground">
Suggestions:
</span>
{tagSuggestions
.filter((t) =>
t.toLowerCase().includes(tagInput.toLowerCase())
)
.map((tag) => (
<Badge
key={tag}
variant="outline"
className="cursor-pointer hover:bg-muted"
onClick={() => {
if (!tags.includes(tag)) {
form.setValue('tags', [...tags, tag])
}
setTagInput('')
}}
>
{tag}
</Badge>
))}
</div>
)}
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-1 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* Files */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Files</CardTitle>
<CardDescription>
Manage project documents and materials
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{files && files.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>File</TableHead>
<TableHead>Type</TableHead>
<TableHead>Size</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<TableRow key={file.id}>
<TableCell>
<div className="flex items-center gap-2">
{fileTypeIcons[file.fileType] || (
<FileIcon className="h-4 w-4" />
)}
<span className="text-sm truncate max-w-[200px]">
{file.fileName}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{file.fileType.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatFileSize(file.size)}
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete file?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{file.fileName}&quot;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteFile(file.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-sm text-muted-foreground">
No files uploaded yet
</p>
)}
<div className="pt-4 border-t">
<p className="text-sm font-medium mb-3">Upload New Files</p>
<FileUpload
projectId={projectId}
onUploadComplete={() => refetchFiles()}
/>
</div>
</CardContent>
</Card>
{/* Error Display */}
{updateProject.error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 py-4">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">
{updateProject.error.message}
</p>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" asChild>
<Link href={`/admin/projects/${projectId}`}>Cancel</Link>
</Button>
<Button type="submit" disabled={isPending}>
{updateProject.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Changes
</Button>
</div>
</form>
</Form>
{/* Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-lg text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible actions that will permanently affect this project
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={deleteProject.isPending}>
{deleteProject.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Delete Project
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{project.title}&quot; and all
associated files, assignments, and evaluations. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete Project
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{deleteProject.error && (
<p className="mt-2 text-sm text-destructive">
{deleteProject.error.message}
</p>
)}
</CardContent>
</Card>
</div>
)
}
function EditProjectSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-24 w-full" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-16" />
</CardHeader>
<CardContent>
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function EditProjectPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<EditProjectSkeleton />}>
<EditProjectContent projectId={id} />
</Suspense>
)
}