Comprehensive platform audit: security, UX, performance, and visual polish
Phase 1: Security - status transition validation, Zod tightening, DB indexes, transactions Phase 2: Admin UX - search/filter for awards, learning, partners pages Phase 3: Dashboard - Recent Activity feed, Pending Actions card, quick actions Phase 4: Jury - assignments progress/urgency, autosave indicator, divergence highlighting Phase 5: Portals - observer charts, mentor search, login/onboarding polish Phase 6: Messages preview dialog, CsvExportDialog with column selection Phase 7: Performance - query optimizations, loading skeletons, useDebounce hook Phase 8: Visual - AnimatedCard, hover effects, StatusBadge, empty state CTAs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TagInput } from '@/components/shared/tag-input'
|
||||
import { CountrySelect } from '@/components/ui/country-select'
|
||||
import { PhoneInput } from '@/components/ui/phone-input'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -31,15 +33,15 @@ import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
FolderPlus,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
function NewProjectPageContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const roundIdParam = searchParams.get('round')
|
||||
const programIdParam = searchParams.get('program')
|
||||
|
||||
const [selectedProgramId, setSelectedProgramId] = useState<string>(programIdParam || '')
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
|
||||
|
||||
// Form state
|
||||
@@ -49,15 +51,25 @@ function NewProjectPageContent() {
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [contactEmail, setContactEmail] = useState('')
|
||||
const [contactName, setContactName] = useState('')
|
||||
const [contactPhone, setContactPhone] = useState('')
|
||||
const [country, setCountry] = useState('')
|
||||
const [customFields, setCustomFields] = useState<{ key: string; value: string }[]>([])
|
||||
const [city, setCity] = useState('')
|
||||
const [institution, setInstitution] = useState('')
|
||||
const [competitionCategory, setCompetitionCategory] = useState<string>('')
|
||||
const [oceanIssue, setOceanIssue] = useState<string>('')
|
||||
|
||||
// Fetch active programs with rounds
|
||||
// Fetch programs
|
||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
|
||||
status: 'ACTIVE',
|
||||
includeRounds: true,
|
||||
})
|
||||
|
||||
// Fetch wizard config for selected program (dropdown options)
|
||||
const { data: wizardConfig } = trpc.program.getWizardConfig.useQuery(
|
||||
{ programId: selectedProgramId },
|
||||
{ enabled: !!selectedProgramId }
|
||||
)
|
||||
|
||||
// Create mutation
|
||||
const utils = trpc.useUtils()
|
||||
const createProject = trpc.project.create.useMutation({
|
||||
@@ -65,68 +77,46 @@ function NewProjectPageContent() {
|
||||
toast.success('Project created successfully')
|
||||
utils.project.list.invalidate()
|
||||
utils.round.get.invalidate()
|
||||
router.push(`/admin/projects?round=${selectedRoundId}`)
|
||||
router.push('/admin/projects')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Get all rounds from programs
|
||||
const rounds = programs?.flatMap((p) =>
|
||||
(p.rounds || []).map((r) => ({
|
||||
...r,
|
||||
programId: p.id,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
// Get rounds for selected program
|
||||
const selectedProgram = programs?.find((p) => p.id === selectedProgramId)
|
||||
const rounds = selectedProgram?.rounds || []
|
||||
|
||||
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))
|
||||
}
|
||||
// Get dropdown options from wizard config
|
||||
const categoryOptions = wizardConfig?.competitionCategories || []
|
||||
const oceanIssueOptions = wizardConfig?.oceanIssues || []
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Please enter a project title')
|
||||
return
|
||||
}
|
||||
if (!selectedRoundId) {
|
||||
toast.error('Please select a round')
|
||||
if (!selectedProgramId) {
|
||||
toast.error('Please select a program')
|
||||
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,
|
||||
programId: selectedProgramId,
|
||||
roundId: selectedRoundId || undefined,
|
||||
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,
|
||||
country: country || undefined,
|
||||
competitionCategory: competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT' | undefined || undefined,
|
||||
oceanIssue: oceanIssue as 'POLLUTION_REDUCTION' | 'CLIMATE_MITIGATION' | 'TECHNOLOGY_INNOVATION' | 'SUSTAINABLE_SHIPPING' | 'BLUE_CARBON' | 'HABITAT_RESTORATION' | 'COMMUNITY_CAPACITY' | 'SUSTAINABLE_FISHING' | 'CONSUMER_AWARENESS' | 'OCEAN_ACIDIFICATION' | 'OTHER' | undefined || undefined,
|
||||
institution: institution.trim() || undefined,
|
||||
contactPhone: contactPhone.trim() || undefined,
|
||||
contactEmail: contactEmail.trim() || undefined,
|
||||
contactName: contactName.trim() || undefined,
|
||||
city: city.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,64 +146,67 @@ function NewProjectPageContent() {
|
||||
</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}>
|
||||
{/* Program & Round selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Program & Round</CardTitle>
|
||||
<CardDescription>
|
||||
Select the program for this project. Round assignment is optional.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!programs || programs.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 Programs</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a program first before adding projects
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Program *</Label>
|
||||
<Select value={selectedProgramId} onValueChange={(v) => {
|
||||
setSelectedProgramId(v)
|
||||
setSelectedRoundId('') // Reset round on program change
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a round" />
|
||||
<SelectValue placeholder="Select a program" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
{programs.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name} {p.year}
|
||||
</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="space-y-2">
|
||||
<Label>Round (optional)</Label>
|
||||
<Select value={selectedRoundId || '__none__'} onValueChange={(v) => setSelectedRoundId(v === '__none__' ? '' : v)} disabled={!selectedProgramId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No round assigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No round assigned</SelectItem>
|
||||
{rounds.map((r: { id: string; name: string }) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedProgramId && (
|
||||
<>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
@@ -265,6 +258,52 @@ function NewProjectPageContent() {
|
||||
maxTags={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{categoryOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Competition Category</Label>
|
||||
<Select value={competitionCategory} onValueChange={setCompetitionCategory}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryOptions.map((opt: { value: string; label: string }) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oceanIssueOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Ocean Issue</Label>
|
||||
<Select value={oceanIssue} onValueChange={setOceanIssue}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select ocean issue..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{oceanIssueOptions.map((opt: { value: string; label: string }) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="institution">Institution</Label>
|
||||
<Input
|
||||
id="institution"
|
||||
value={institution}
|
||||
onChange={(e) => setInstitution(e.target.value)}
|
||||
placeholder="e.g., University of Monaco"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -299,11 +338,28 @@ function NewProjectPageContent() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="country">Country</Label>
|
||||
<Input
|
||||
id="country"
|
||||
<Label>Contact Phone</Label>
|
||||
<PhoneInput
|
||||
value={contactPhone}
|
||||
onChange={setContactPhone}
|
||||
defaultCountry="MC"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Country</Label>
|
||||
<CountrySelect
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
onChange={setCountry}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">City</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="e.g., Monaco"
|
||||
/>
|
||||
</div>
|
||||
@@ -311,65 +367,6 @@ function NewProjectPageContent() {
|
||||
</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>
|
||||
@@ -377,7 +374,7 @@ function NewProjectPageContent() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={createProject.isPending || !title.trim()}
|
||||
disabled={createProject.isPending || !title.trim() || !selectedProgramId}
|
||||
>
|
||||
{createProject.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
Reference in New Issue
Block a user