MOPC-App/src/app/(admin)/admin/projects/page.tsx

654 lines
23 KiB
TypeScript
Raw Normal View History

'use client'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useSearchParams, usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Plus,
MoreHorizontal,
ClipboardList,
Eye,
Pencil,
FileUp,
Users,
Search,
Trash2,
Loader2,
Sparkles,
} from 'lucide-react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo'
import { Pagination } from '@/components/shared/pagination'
import {
ProjectFiltersBar,
type ProjectFilters,
} from './project-filters'
const statusColors: Record<
string,
'default' | 'success' | 'secondary' | 'destructive' | 'warning'
> = {
SUBMITTED: 'secondary',
ELIGIBLE: 'default',
ASSIGNED: 'default',
SEMIFINALIST: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
WITHDRAWN: 'secondary',
}
function parseFiltersFromParams(
searchParams: URLSearchParams
): ProjectFilters & { page: number } {
return {
search: searchParams.get('q') || '',
statuses: searchParams.get('status')
? searchParams.get('status')!.split(',')
: [],
roundId: searchParams.get('round') || '',
competitionCategory: searchParams.get('category') || '',
oceanIssue: searchParams.get('issue') || '',
country: searchParams.get('country') || '',
wantsMentorship:
searchParams.get('mentorship') === 'true'
? true
: searchParams.get('mentorship') === 'false'
? false
: undefined,
hasFiles:
searchParams.get('hasFiles') === 'true'
? true
: searchParams.get('hasFiles') === 'false'
? false
: undefined,
hasAssignments:
searchParams.get('hasAssign') === 'true'
? true
: searchParams.get('hasAssign') === 'false'
? false
: undefined,
page: parseInt(searchParams.get('page') || '1', 10),
}
}
function filtersToParams(
filters: ProjectFilters & { page: number }
): URLSearchParams {
const params = new URLSearchParams()
if (filters.search) params.set('q', filters.search)
if (filters.statuses.length > 0)
params.set('status', filters.statuses.join(','))
if (filters.roundId) params.set('round', filters.roundId)
if (filters.competitionCategory)
params.set('category', filters.competitionCategory)
if (filters.oceanIssue) params.set('issue', filters.oceanIssue)
if (filters.country) params.set('country', filters.country)
if (filters.wantsMentorship !== undefined)
params.set('mentorship', String(filters.wantsMentorship))
if (filters.hasFiles !== undefined)
params.set('hasFiles', String(filters.hasFiles))
if (filters.hasAssignments !== undefined)
params.set('hasAssign', String(filters.hasAssignments))
if (filters.page > 1) params.set('page', String(filters.page))
return params
}
const PER_PAGE = 20
export default function ProjectsPage() {
const pathname = usePathname()
const searchParams = useSearchParams()
const parsed = parseFiltersFromParams(searchParams)
const [filters, setFilters] = useState<ProjectFilters>({
search: parsed.search,
statuses: parsed.statuses,
roundId: parsed.roundId,
competitionCategory: parsed.competitionCategory,
oceanIssue: parsed.oceanIssue,
country: parsed.country,
wantsMentorship: parsed.wantsMentorship,
hasFiles: parsed.hasFiles,
hasAssignments: parsed.hasAssignments,
})
const [page, setPage] = useState(parsed.page)
const [searchInput, setSearchInput] = useState(parsed.search)
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (searchInput !== filters.search) {
setFilters((f) => ({ ...f, search: searchInput }))
setPage(1)
}
}, 300)
return () => clearTimeout(timer)
}, [searchInput, filters.search])
// Sync URL
const syncUrl = useCallback(
(f: ProjectFilters, p: number) => {
const params = filtersToParams({ ...f, page: p })
const qs = params.toString()
window.history.replaceState(null, '', qs ? `${pathname}?${qs}` : pathname)
},
[pathname]
)
useEffect(() => {
syncUrl(filters, page)
}, [filters, page, syncUrl])
// Reset page when filters change
const handleFiltersChange = (newFilters: ProjectFilters) => {
setFilters(newFilters)
setPage(1)
}
// Build tRPC query input
const queryInput = {
search: filters.search || undefined,
statuses:
filters.statuses.length > 0
? (filters.statuses as Array<
| 'SUBMITTED'
| 'ELIGIBLE'
| 'ASSIGNED'
| 'SEMIFINALIST'
| 'FINALIST'
| 'REJECTED'
>)
: undefined,
roundId: filters.roundId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
oceanIssue: filters.oceanIssue
? (filters.oceanIssue as
| 'POLLUTION_REDUCTION'
| 'CLIMATE_MITIGATION'
| 'TECHNOLOGY_INNOVATION'
| 'SUSTAINABLE_SHIPPING'
| 'BLUE_CARBON'
| 'HABITAT_RESTORATION'
| 'COMMUNITY_CAPACITY'
| 'SUSTAINABLE_FISHING'
| 'CONSUMER_AWARENESS'
| 'OCEAN_ACIDIFICATION'
| 'OTHER')
: undefined,
country: filters.country || undefined,
wantsMentorship: filters.wantsMentorship,
hasFiles: filters.hasFiles,
hasAssignments: filters.hasAssignments,
page,
perPage: PER_PAGE,
}
const utils = trpc.useUtils()
const { data, isLoading } = trpc.project.list.useQuery(queryInput)
const { data: filterOptions } = trpc.project.getFilterOptions.useQuery()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
// Fetch rounds for the AI tagging dialog
const { data: programs } = trpc.program.list.useQuery()
const { data: allRounds } = trpc.round.list.useQuery(
{ programId: programs?.[0]?.id ?? '' },
{ enabled: !!programs?.[0]?.id }
)
// AI batch tagging mutation
const batchTagProjects = trpc.tag.batchTagProjects.useMutation({
onSuccess: (result) => {
if (result.errors && result.errors.length > 0) {
// Show first error if there are any
toast.error(`AI Tagging issue: ${result.errors[0]}`)
} else if (result.processed === 0 && result.skipped === 0 && result.failed === 0) {
toast.info('No projects to tag - all projects in this round already have tags')
} else {
toast.success(
`AI Tagging complete: ${result.processed} tagged, ${result.skipped} skipped, ${result.failed} failed`
)
}
setAiTagDialogOpen(false)
setSelectedRoundForTagging('')
utils.project.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to generate AI tags')
},
})
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
toast.success('Project deleted successfully')
utils.project.list.invalidate()
setDeleteDialogOpen(false)
setProjectToDelete(null)
},
onError: (error) => {
toast.error(error.message || 'Failed to delete project')
},
})
const handleDeleteClick = (project: { id: string; title: string }) => {
setProjectToDelete(project)
setDeleteDialogOpen(true)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
<p className="text-muted-foreground">
Manage submitted projects across all rounds
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
AI Tags
</Button>
<Button variant="outline" asChild>
<Link href="/admin/projects/import">
<FileUp className="mr-2 h-4 w-4" />
Import
</Link>
</Button>
<Button asChild>
<Link href="/admin/projects/new">
<Plus className="mr-2 h-4 w-4" />
Add Project
</Link>
</Button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search projects by title, team, or description..."
className="pl-10"
/>
</div>
{/* Filters */}
<ProjectFiltersBar
filters={filters}
filterOptions={filterOptions}
onChange={handleFiltersChange}
/>
{/* Content */}
{isLoading ? (
<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-64" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-9 w-9" />
</div>
))}
</div>
</CardContent>
</Card>
) : data && data.projects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No projects found</p>
<p className="text-sm text-muted-foreground">
{filters.search ||
filters.statuses.length > 0 ||
filters.roundId ||
filters.competitionCategory ||
filters.oceanIssue ||
filters.country
? 'Try adjusting your filters'
: 'Import projects via CSV or create them manually'}
</p>
{!filters.search && filters.statuses.length === 0 && (
<div className="mt-4 flex gap-2">
<Button asChild>
<Link href="/admin/projects/import">
<FileUp className="mr-2 h-4 w-4" />
Import CSV
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/admin/projects/new">
<Plus className="mr-2 h-4 w-4" />
Add Project
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
) : data ? (
<>
{/* Desktop table */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Round</TableHead>
<TableHead>Files</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED'
return (
<TableRow
key={project.id}
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
>
<TableCell>
<Link
href={`/admin/projects/${project.id}`}
className="flex items-center gap-3 after:absolute after:inset-0 after:content-['']"
>
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div>
<p className="font-medium hover:text-primary">
{truncate(project.title, 40)}
</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
</p>
</div>
</Link>
</TableCell>
<TableCell>
<div>
<div className="flex items-center gap-2">
<p>{project.round?.name ?? '-'}</p>
{project.status === 'REJECTED' && (
<Badge variant="destructive" className="text-xs">
Eliminated
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{project.round?.program?.name}
</p>
</div>
</TableCell>
<TableCell>{project.files?.length ?? 0}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
{project._count.assignments}
</div>
</TableCell>
<TableCell>
<Badge
variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}
>
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="relative z-10 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/projects/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick({ id: project.id, title: project.title })
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)})}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{data.projects.map((project) => (
<Link
key={project.id}
href={`/admin/projects/${project.id}`}
className="block"
>
<Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<ProjectLogo
project={project}
size="md"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base line-clamp-2">
{project.title}
</CardTitle>
<Badge
variant={
statusColors[project.status ?? 'SUBMITTED'] || 'secondary'
}
className="shrink-0"
>
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</div>
<CardDescription>{project.teamName}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<div className="flex items-center gap-2">
<span>{project.round?.name ?? '-'}</span>
{project.status === 'REJECTED' && (
<Badge variant="destructive" className="text-xs">
Eliminated
</Badge>
)}
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span>{project._count.assignments} jurors</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{/* Pagination */}
<Pagination
page={data.page}
totalPages={data.totalPages}
total={data.total}
perPage={PER_PAGE}
onPageChange={setPage}
/>
</>
) : null}
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Project</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{projectToDelete?.title}&quot;? This will
permanently remove the project, all its files, assignments, and evaluations.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => projectToDelete && deleteProject.mutate({ id: projectToDelete.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteProject.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* AI Tagging Dialog */}
<AlertDialog open={aiTagDialogOpen} onOpenChange={setAiTagDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" />
Generate AI Tags
</AlertDialogTitle>
<AlertDialogDescription>
Use AI to automatically generate expertise tags for all projects in a round
that don&apos;t have tags yet. This helps with jury matching and filtering.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="round-select">Select Round</Label>
<Select
value={selectedRoundForTagging}
onValueChange={setSelectedRoundForTagging}
>
<SelectTrigger id="round-select">
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{filterOptions?.rounds?.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name} ({round.program?.name})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-sm text-muted-foreground">
Only projects without existing tags will be processed. Existing tags are preserved.
</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (selectedRoundForTagging) {
batchTagProjects.mutate({ roundId: selectedRoundForTagging })
}
}}
disabled={!selectedRoundForTagging || batchTagProjects.isPending}
>
{batchTagProjects.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{batchTagProjects.isPending ? 'Processing...' : 'Generate Tags'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}