Add jury assignment transfer, cap redistribution, and learning hub overhaul
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m19s
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m19s
- Add getTransferCandidates/transferAssignments procedures for targeted assignment moves between jurors with TOCTOU guards and audit logging - Add getOverCapPreview/redistributeOverCap for auto-redistributing assignments when a juror's cap is lowered below their current load - Add TransferAssignmentsDialog (2-step: select projects, pick destinations) - Extend InlineMemberCap with over-cap detection and redistribute banner - Extend getReassignmentHistory to show ASSIGNMENT_TRANSFER and CAP_REDISTRIBUTE events - Learning hub: replace ResourceType/CohortLevel enums with accessJson JSONB, add coverImageKey, resource detail pages for jury/mentor, shared renderer - Migration: 20260221200000_learning_hub_overhaul Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
122
src/app/(jury)/jury/learning/[id]/page.tsx
Normal file
122
src/app/(jury)/jury/learning/[id]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } 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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
ExternalLink,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
const ResourceRenderer = dynamic(
|
||||
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
export default function JuryResourceDetailPage() {
|
||||
const params = useParams()
|
||||
const resourceId = params.id as string
|
||||
|
||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||
const logAccess = trpc.learningResource.logAccess.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Log access on mount
|
||||
useEffect(() => {
|
||||
if (resourceId) {
|
||||
logAccess.mutate({ id: resourceId })
|
||||
}
|
||||
}, [resourceId])
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
|
||||
window.open(url, '_blank')
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-px w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</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>
|
||||
This resource may have been removed or you don't have access.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/jury/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 justify-between">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Link
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{resource.objectKey && (
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ResourceRenderer
|
||||
title={resource.title}
|
||||
description={resource.description}
|
||||
contentJson={resource.contentJson}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
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 {
|
||||
FileText,
|
||||
Video,
|
||||
Link as LinkIcon,
|
||||
File,
|
||||
Download,
|
||||
ExternalLink,
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
|
||||
const resourceTypeIcons = {
|
||||
PDF: FileText,
|
||||
VIDEO: Video,
|
||||
DOCUMENT: File,
|
||||
LINK: LinkIcon,
|
||||
OTHER: File,
|
||||
}
|
||||
|
||||
const cohortColors = {
|
||||
ALL: 'bg-gray-100 text-gray-800',
|
||||
SEMIFINALIST: 'bg-blue-100 text-blue-800',
|
||||
FINALIST: 'bg-purple-100 text-purple-800',
|
||||
}
|
||||
|
||||
export default function JuryLearningPage() {
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||
|
||||
@@ -81,7 +62,6 @@ export default function JuryLearningPage() {
|
||||
}
|
||||
|
||||
const resources = data?.resources || []
|
||||
const userCohortLevel = data?.userCohortLevel || 'ALL'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -90,11 +70,6 @@ export default function JuryLearningPage() {
|
||||
<p className="text-muted-foreground">
|
||||
Educational resources for jury members
|
||||
</p>
|
||||
{userCohortLevel !== 'ALL' && (
|
||||
<Badge className={cohortColors[userCohortLevel]} variant="outline">
|
||||
Your access level: {userCohortLevel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{resources.length === 0 ? (
|
||||
@@ -110,14 +85,14 @@ export default function JuryLearningPage() {
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{resources.map((resource) => {
|
||||
const Icon = resourceTypeIcons[resource.resourceType]
|
||||
const isDownloading = downloadingId === resource.id
|
||||
const hasContent = !!resource.contentJson
|
||||
|
||||
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 shrink-0">
|
||||
<Icon className="h-5 w-5" />
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium">{resource.title}</h3>
|
||||
@@ -126,36 +101,39 @@ export default function JuryLearningPage() {
|
||||
{resource.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="outline" className={cohortColors[resource.cohortLevel]}>
|
||||
{resource.cohortLevel}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{resource.resourceType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{resource.externalUrl ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{hasContent && (
|
||||
<Link href={`/jury/learning/${resource.id}` as Route}>
|
||||
<Button variant="outline" size="sm">
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Read
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{resource.externalUrl && (
|
||||
<a
|
||||
href={resource.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</Button>
|
||||
</a>
|
||||
) : resource.objectKey ? (
|
||||
)}
|
||||
{resource.objectKey && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(resource.id)}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isDownloading ? 'Loading...' : 'Download'}
|
||||
</Button>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user