Files
MOPC-Portal/src/app/(admin)/admin/learning/new/page.tsx
Matt 7bc2b84d1d
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m5s
refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow
The AWARD_MASTER role split sponsor jurors into a parallel UI that hid
project files (only showed when the award was anchored to an evaluation
round) and duplicated the jury voting path with no real difference in
authority — tie-break and finalize were already governed by AwardJuror.isChair
regardless of the user's global role. Inviting a juror via the award page
defaulted to AWARD_MASTER, randomly fragmenting jury panels.

This collapses the role into JURY_MEMBER + isChair:

- specialAward.getMyAwardDetail now returns evaluation scores, chair
  visibility into other jurors' votes, and juror roster
- specialAward.submitVote accepts an optional justification per vote
- specialAward.confirmWinner moves from awardMasterProcedure to
  protectedProcedure (juror+chair check inside)
- bulkInviteJurors creates JURY_MEMBER accounts and, when the award has
  a juryGroupId, also adds them to that JuryGroup so they appear on
  the round-page jury panel
- jury award page renders justification, eval-score badges, and a
  chair tools panel with vote tally + finalize-winner CTA
- juryGroup.list includes attached SpecialAwards; the jury-list UI
  shows a trophy pill alongside round pills
- (award-master) route group, awardMasterProcedure, AWARD_MASTER role
  enum value, and AWARD_MASTER_DECISION decisionMode are deleted
- migration demotes any residual AWARD_MASTER users to JURY_MEMBER and
  recreates the UserRole enum without the value

Coup de Coeur on prod: Didier (the sponsor juror added today as
AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and
attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward
itself was linked to that group (juryGroupId was NULL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:21:09 +02:00

348 lines
12 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
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 { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { Separator } from '@/components/ui/separator'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { toast } from 'sonner'
import {
ArrowLeft,
Save,
Loader2,
Settings,
Eye,
} from 'lucide-react'
// Dynamically import editors to avoid SSR issues
const BlockEditor = dynamic(
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
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" />
),
}
)
const ROLE_OPTIONS = [
{ value: 'JURY_MEMBER', label: 'Jury Members' },
{ value: 'MENTOR', label: 'Mentors' },
{ value: 'OBSERVER', label: 'Observers' },
{ value: 'APPLICANT', label: 'Applicants' },
]
type AccessRule =
| { type: 'everyone' }
| { type: 'roles'; roles: string[] }
| { type: 'jury_group'; juryGroupIds: string[] }
| { type: 'round'; roundIds: string[] }
export default function NewLearningResourcePage() {
const router = useRouter()
// Form state
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contentJson, setContentJson] = useState<string>('')
const [externalUrl, setExternalUrl] = useState('')
const [isPublished, setIsPublished] = useState(false)
const [programId, setProgramId] = useState<string | null>(null)
const [previewing, setPreviewing] = useState(false)
// Access rules state
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
// API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
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,
})
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 {
toast.error('Failed to upload file')
throw new Error('Upload failed')
}
}
const buildAccessJson = (): AccessRule[] | null => {
if (accessMode === 'everyone') return null
if (accessMode === 'roles' && selectedRoles.length > 0) {
return [{ type: 'roles', roles: selectedRoles }]
}
return null
}
const handleSubmit = useCallback(async () => {
if (!title.trim()) {
toast.error('Please enter a title')
return
}
try {
await createResource.mutateAsync({
programId,
title,
description: description || undefined,
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
accessJson: buildAccessJson(),
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')
}
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles])
// Ctrl+S save
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSubmit()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSubmit])
return (
<div className="flex min-h-screen flex-col">
{/* Sticky toolbar */}
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button variant="ghost" size="sm" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<div className="flex items-center gap-2">
<Button
variant={previewing ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewing(!previewing)}
>
<Eye className="mr-2 h-4 w-4" />
{previewing ? 'Edit' : 'Preview'}
</Button>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<Settings className="mr-2 h-4 w-4" />
Settings
</Button>
</SheetTrigger>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>Resource Settings</SheetTitle>
<SheetDescription>
Configure publishing, access, and metadata
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-6">
{/* Publish toggle */}
<div className="flex items-center justify-between">
<div>
<Label>Published</Label>
<p className="text-sm text-muted-foreground">
Make visible to users
</p>
</div>
<Switch
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<Separator />
{/* Program */}
<div className="space-y-2">
<Label>Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger>
<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>
<Separator />
{/* Access Rules */}
<div className="space-y-3">
<Label>Access Rules</Label>
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="everyone">Everyone</SelectItem>
<SelectItem value="roles">By Role</SelectItem>
</SelectContent>
</Select>
{accessMode === 'roles' && (
<div className="space-y-2 rounded-lg border p-3">
{ROLE_OPTIONS.map((role) => (
<label key={role.value} className="flex items-center gap-2 text-sm">
<Checkbox
checked={selectedRoles.includes(role.value)}
onCheckedChange={(checked) => {
setSelectedRoles(
checked
? [...selectedRoles, role.value]
: selectedRoles.filter((r) => r !== role.value)
)
}}
/>
{role.label}
</label>
))}
</div>
)}
</div>
<Separator />
{/* External URL */}
<div className="space-y-2">
<Label>External URL</Label>
<Input
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
<p className="text-xs text-muted-foreground">
Optional link to an external resource
</p>
</div>
</div>
</SheetContent>
</Sheet>
<Button
size="sm"
onClick={handleSubmit}
disabled={createResource.isPending || !title.trim()}
>
{createResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save
</Button>
</div>
</div>
{/* Content area */}
<div className="flex-1 px-4 py-8">
{previewing ? (
<ResourceRenderer
title={title || 'Untitled'}
description={description || null}
contentJson={contentJson ? JSON.parse(contentJson) : null}
/>
) : (
<div className="mx-auto max-w-3xl space-y-4">
{/* Inline title */}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Untitled"
autoFocus
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
/>
{/* Inline description */}
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description..."
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
/>
{/* Divider */}
<hr className="border-border" />
{/* Block editor */}
<BlockEditor
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[400px]"
/>
</div>
)}
</div>
</div>
)
}